Pagination
Every list endpoint on the Not AI Public REST API uses the same cursor envelope. Pick a pageSize, walk the cursor until hasMore is false, and treat the cursor itself as opaque.
Envelope
{
"data": [ /* page of items */ ],
"pagination": {
"nextCursor": "eyJ0b2tlbiI6Ii4uLiJ9",
"hasMore": true,
"limit": 50
}
}
| Field | Meaning |
|---|---|
data |
The page of items. Same shape as the single-item endpoint for the same resource, minus a few fields where it would be wasteful. |
pagination.nextCursor |
Opaque token to pass back as ?cursor=... on the next request. Present whenever hasMore is true. May be absent or empty on the last page. |
pagination.hasMore |
true if there is at least one more page after this one. false is the terminal signal. |
pagination.limit |
The effective page size the server used on this response. May be smaller than the pageSize you requested if you exceeded the per-tier cap. |
Cursors are opaque
Treat the nextCursor string as a single opaque token. Do not decode it, do not split on = or :, do not assume it is base64, do not assume it is a timestamp, do not assume two cursors for the same endpoint share a prefix. The underlying encoding may differ between endpoints and may change between releases. The only contract is that whatever the server returned in nextCursor can be passed back as ?cursor=... to get the next page.
Query parameters
| Parameter | Type | Meaning |
|---|---|---|
cursor |
string | Opaque token from the previous response’s pagination.nextCursor. Omit on the first request. |
pageSize |
integer | Page size, 1–100. Default 50. Above the per-tier cap the server clamps to the cap and surfaces the effective value back in pagination.limit. See Rate Limits for the per-tier cap table. |
The request-side parameter is pageSize; the response-side echo (the effective page size the server actually used) is pagination.limit. They’re related but not symmetric. The server can clamp.
Iterating a list
# First page
curl "https://api.isnotai.com/v1/sessions?pageSize=50" \
-H "Authorization: Bearer $ISNOTAI_API_KEY"
# Next page (cursor copied from the response)
curl "https://api.isnotai.com/v1/sessions?pageSize=50&cursor=eyJ0b2tlbiI6Ii4uLiJ9" \
-H "Authorization: Bearer $ISNOTAI_API_KEY"
The same loop in Python:
import os
import requests
BASE_URL = "https://api.isnotai.com"
HEADERS = {"Authorization": f"Bearer {os.environ['ISNOTAI_API_KEY']}"}
def iterate(path, params=None):
params = dict(params or {})
while True:
response = requests.get(f"{BASE_URL}{path}", headers=HEADERS, params=params, timeout=10)
response.raise_for_status()
payload = response.json()
for item in payload["data"]:
yield item
pagination = payload["pagination"]
if not pagination.get("hasMore"):
return
params["cursor"] = pagination["nextCursor"]
for session in iterate("/v1/sessions", {"pageSize": 100}):
print(session["id"])
The same loop in Node.js:
const BASE_URL = "https://api.isnotai.com";
const HEADERS = { Authorization: `Bearer ${process.env.ISNOTAI_API_KEY}` };
async function* iterate(path, params = {}) {
let cursor;
while (true) {
const url = new URL(`${BASE_URL}${path}`);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
if (cursor) url.searchParams.set("cursor", cursor);
const response = await fetch(url, { headers: HEADERS });
if (!response.ok) throw new Error(`Not AI API ${response.status}`);
const payload = await response.json();
for (const item of payload.data) yield item;
if (!payload.pagination?.hasMore) return;
cursor = payload.pagination.nextCursor;
}
}
for await (const session of iterate("/v1/sessions", { pageSize: 100 })) {
console.log(session.id);
}
The same loop in C#:
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
var baseUrl = "https://api.isnotai.com";
var apiKey = Environment.GetEnvironmentVariable("ISNOTAI_API_KEY")!;
using var client = new HttpClient { BaseAddress = new Uri(baseUrl) };
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
static async IAsyncEnumerable<JsonElement> Iterate(HttpClient client, string path, IDictionary<string, string>? extraQuery = null)
{
string? cursor = null;
while (true)
{
var query = new List<string>();
if (extraQuery != null)
{
foreach (var (k, v) in extraQuery) query.Add($"{Uri.EscapeDataString(k)}={Uri.EscapeDataString(v)}");
}
if (cursor != null) query.Add($"cursor={Uri.EscapeDataString(cursor)}");
var url = query.Count > 0 ? $"{path}?{string.Join("&", query)}" : path;
using var response = await client.GetAsync(url);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<JsonElement>();
foreach (var item in payload.GetProperty("data").EnumerateArray()) yield return item;
var pagination = payload.GetProperty("pagination");
if (!pagination.TryGetProperty("hasMore", out var hasMore) || !hasMore.GetBoolean()) yield break;
cursor = pagination.GetProperty("nextCursor").GetString();
}
}
await foreach (var session in Iterate(client, "/v1/sessions", new Dictionary<string, string> { ["pageSize"] = "100" }))
{
Console.WriteLine(session.GetProperty("id").GetString());
}
Endpoints that paginate
The cursor envelope applies to every list endpoint in v1:
GET /v1/sessionsGET /v1/sessions/{sessionId}/eventsGET /v1/usersGET /v1/users/{userId}/sessionsGET /v1/courses
Endpoints that return a single resource (/v1/sessions/{id}, /v1/sessions/{id}/details, /v1/integration) and aggregate endpoints (/v1/stats, /v1/analytics, /v1/usage) do not paginate. They use the single-item envelope { "data": { ... } } instead.
Practical notes
- A page may legitimately contain fewer items than the
pageSizeyou asked for (or thepagination.limitechoed back) even whenhasMoreis true. Always trusthasMore, not the array length, as the loop terminator. - Cursors are tied to the exact filter set they were issued under. If you change
startDate,status, or any other filter mid-walk, throw the cursor away and restart from the first page. - If a cursor ever returns
400 INVALID_REQUEST, restart the walk from the first page rather than retrying.