# 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

```json
{
  "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](/developers/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

```bash
# 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:

```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:

```javascript
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#:

```csharp
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.
