View as Markdown

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/sessions
  • GET /v1/sessions/{sessionId}/events
  • GET /v1/users
  • GET /v1/users/{userId}/sessions
  • GET /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 pageSize you asked for (or the pagination.limit echoed back) even when hasMore is true. Always trust hasMore, 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.