> ## Documentation Index
> Fetch the complete documentation index at: https://docs.get-exo.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Pagination

> Opaque keyset cursors, a three-field envelope, and one family where pagination is opt-in.

**List routes return `{ data, hasMore, nextCursor }` and you page by passing `nextCursor` back unmodified.**

Cursors are keyset, not offset. A row inserted while you are paging cannot shift the window and make you skip or repeat an item, which is the failure offset pagination has and this does not.

## The envelope

```json theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
{
  "data": [
    { "id": "ingest:4a1e88f0-9c2d-4f31-b7a0-5e6c1d820b93:7d3c9b1e04a2", "title": "Pricing working session" },
    { "id": "ingest:4a1e88f0-9c2d-4f31-b7a0-5e6c1d820b93:6d40f2a8c917", "title": "Q3 planning" }
  ],
  "hasMore": true,
  "nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA3LTE0VDA5OjEyOjAzWiIsImlkIjoibl81YjAyIn0"
}
```

| Field        | Type           | What it is                                                                |
| ------------ | -------------- | ------------------------------------------------------------------------- |
| `data`       | array          | The page of rows, newest first.                                           |
| `hasMore`    | boolean        | Whether another page exists.                                              |
| `nextCursor` | string or null | Pass this back as `cursor` to get the next page. `null` on the last page. |

There is no total count. Counting every matching row on every request is a cost you would pay on every page for a number almost no caller uses.

## Parameters

| Parameter  | Type    | Default | Behaviour                                                                           |
| ---------- | ------- | ------- | ----------------------------------------------------------------------------------- |
| `cursor`   | string  | none    | The opaque `nextCursor` from the previous page. Omit for the first page.            |
| `pageSize` | integer | `25`    | **Clamped** into the range 1 to 100. Out-of-range values are coerced, not rejected. |

`pageSize` clamping is worth noting: asking for 500 gives you 100 and a 200, not a 422. Read `hasMore` rather than inferring the end of the list from the number of rows you received.

## Paging through a list

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  # first page
  curl -sS "https://api.get-exo.com/v1/graph/nodes?pageSize=50" \
    -H "X-Exo-API-Key: $EXO_KEY" > page.json

  # next page
  CURSOR=$(jq -r '.nextCursor' page.json)
  curl -sS "https://api.get-exo.com/v1/graph/nodes?pageSize=50&cursor=$CURSOR" \
    -H "X-Exo-API-Key: $EXO_KEY"
  ```

  ```python Python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  import httpx


  def iter_nodes(client: httpx.Client, page_size: int = 50):
      cursor = None
      while True:
          params = {"pageSize": page_size}
          if cursor:
              params["cursor"] = cursor
          page = client.get("/v1/graph/nodes", params=params).raise_for_status().json()
          yield from page["data"]
          if not page["hasMore"]:
              return
          cursor = page["nextCursor"]
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  async function* iterNodes(key: string, pageSize = 50) {
    let cursor: string | null = null;
    for (;;) {
      const params = new URLSearchParams({ pageSize: String(pageSize) });
      if (cursor) params.set("cursor", cursor);
      const response = await fetch(
        `https://api.get-exo.com/v1/graph/nodes?${params}`,
        { headers: { "X-Exo-API-Key": key } },
      );
      if (!response.ok) throw new Error(`${response.status}`);
      const page = await response.json();
      yield* page.data;
      if (!page.hasMore) return;
      cursor = page.nextCursor;
    }
  }
  ```
</CodeGroup>

## Cursors are opaque

A cursor encodes the position of the last row you were served. Treat it as a token: pass it back exactly as received, do not decode it, do not construct one, and do not carry one between routes.

Any tampering fails with 400 [`invalid_cursor`](/errors/invalid_cursor): bad base64, bad JSON, a missing key, or a timestamp that does not parse.

## Which routes paginate

| Route                                                                   | Notes                                              |
| ----------------------------------------------------------------------- | -------------------------------------------------- |
| [`GET /v1/graph/nodes`](/api-reference/graph/list-graph-nodes)          |                                                    |
| [`GET /v1/graph/edges`](/api-reference/graph/list-graph-edges)          |                                                    |
| [`GET /v1/graph/domains`](/api-reference/graph/list-graph-domains)      |                                                    |
| [`GET /v1/jobs`](/api-reference/jobs/list-jobs)                         |                                                    |
| [`GET /v1/subjects`](/api-reference/subjects/list-subjects)             |                                                    |
| [`GET /v1/contradictions`](/api-reference/insights/list-contradictions) |                                                    |
| [`GET /v1/proposals`](/api-reference/insights/list-proposals)           |                                                    |
| [`GET /v1/webhooks`](/api-reference/events/list-webhooks)               |                                                    |
| [`GET /v1/exports`](/api-reference/exports/list-exports)                |                                                    |
| [`GET /v1/sandbox/keys`](/api-reference/keys/list-sandbox-keys)         |                                                    |
| [`GET /v1/team/members`](/api-reference/team/list-team-members)         |                                                    |
| [`GET /v1/recall/{recall_type}`](/api-reference/recall/get-recall)      | Opt-in, and only two of the four views. See below. |

Two lists deliberately do not paginate. [`GET /v1/import`](/api-reference/ingest/get-import) is a fixed window of the 20 most recent imports, and [`GET /v1/keys`](/api-reference/keys/list-keys) returns the caller's keys in full.

## Recall pagination is opt-in

`GET /v1/recall/{recall_type}` serves four different shapes from one route, and only two of them are lists.

* **`insights` and `temporal`** accept `cursor` and `pageSize`.
* **`identity` and `graph`** are composite documents. Sending either parameter returns 400 [`pagination_unsupported`](/errors/pagination_unsupported), with the supported views named in `suggestedAction`.

Send neither parameter and the response keeps its original, unpaginated shape. That is why this is described as opt-in: pagination changes the response, so it only happens when you ask for it.

## Next

<Columns cols={2}>
  <Card title="Recall" icon="brain" href="/api-reference/recall">
    The four recall views and what each one returns.
  </Card>

  <Card title="Errors" icon="triangle-alert" href="/platform/errors">
    The problem envelope and the full code catalogue.
  </Card>
</Columns>
