# Pagination

Cursor-only, opaque, meta.pagination.nextCursor — and why there is no page parameter

Every list endpoint is **cursor-paginated**. There is no `page` or `offset` parameter, and
there will not be one — do not ship helpers that imply otherwise.

## The parameters

| Parameter | |
| --- | --- |
| `limit` | Items per page. Default 20, maximum 100. Some endpoints cap lower; the reference gives the real maximum per operation. |
| `cursor` | Opaque position marker. Omit for the first page. |

Filters are ordinary query parameters beside these — `q` (search text, 1–200 characters) on
the campaign, contact, form, journey, segment and template lists, plus per-endpoint ones such
as `status`. Keep every filter the same across a traversal.

## The response

```json
{
  "data": [ … ],
  "meta": { "pagination": { "limit": 20, "nextCursor": "eyJpZCI6IjAxOGYi…" } }
}
```

Pass `nextCursor` back as `cursor` to get the next page. **`nextCursor` is `null` on the
last page** — that, not an empty `data` array, is the signal to stop. A full page can
still be the last one.

```
GET /v1/contacts?limit=50
GET /v1/contacts?limit=50&cursor=eyJpZCI6IjAxOGYi…
GET /v1/contacts?limit=50&cursor=eyJpZCI6IjAxOGYj…   → nextCursor: null
```

## There is no total

Lists do not report how many items there are, and that is deliberate. An exact count costs
a scan of every matching row on every page — cheap for fifty campaigns, not for a million
contacts or a year of messages — and it goes stale the moment a row is written, so a
cursor walk and a total can disagree even when both are correct. Only the counts that
answer a question in their own right exist: `GET /v1/segments/{id}/count` for an audience,
and the stats endpoints for delivery.

A client that wants to show a size can say what it knows for certain: the items on the
page it holds, and — when `nextCursor` is `null` on the first page — that those are all
of them.

## The cursor is opaque

It is base64url-encoded JSON today. That is an implementation detail and is **not part of
the contract**: do not decode it, construct one, derive a page number from it, or persist
one as a durable bookmark. A malformed cursor returns 400 `VALIDATION_ERROR`.

Keep `limit` the same across a run. Changing it mid-traversal is not an error, but the
cursor encodes position, not an offset, so mixing sizes makes the result harder to reason
about than it looks.

## What an SDK should offer

Expose the raw paged call *and* an auto-paginating iterator, and make the iterator the
obvious choice:

```ts
for await (const contact of client.contacts.list({ limit: 100 })) {
  …
}
```

Rules for the iterator: stop when `nextCursor` is `null`, propagate errors rather than
truncating silently, and do not buffer the whole result set in memory before yielding —
the point of streaming a million contacts is not to hold a million contacts.

## Consistency

Lists are read from live data. A row inserted or deleted while you are part-way through a
traversal may be missed or repeated across page boundaries; cursor pagination avoids the
gross duplication that offset pagination causes, but it is not a snapshot.

Segment listings deserve particular care: a segment is **evaluated on read** rather than
stored as a fixed membership list, so `GET /v1/contacts?segmentId=` can legitimately
return a different population between pages if contacts are changing underneath. If you
need a stable audience, use a group.

## Exporting

To take the whole send log, or a large part of it, ask for a file instead of paging
`GET /v1/messages/deliveries`. The export takes the same filters as the list and applies
them the same way, including `q`, part of the recipient's address or name, matched
against the contact. It needs the `messages:read` scope.

```
POST /v1/messages/exports            { "status": "failed", "createdAfter": "2026-10-01T00:00:00Z" }
GET  /v1/messages/exports/{id}       → status: queued, generating, then ready, failed or expired
GET  /v1/messages/exports/{id}/download
```

Send `{}` to export every message. A filter that matches no message is refused with
400 `NOTHING_TO_EXPORT`. The file is built in the background, so poll the export until
`status` is `ready`. Downloading earlier answers 409 `EXPORT_UNAVAILABLE` ("not ready
yet"). A failed export answers the same 409 with `details.status: "failed"` and the
message "The export failed; create a new one".

The file is CSV, one row per message, newest first, with the columns of a sent message
(`templateId` and `templateVersion` stand for `template`). It is kept for the contact-file
retention period, given in `expiresAt`. After that the export reads `expired` and the
download answers 410 `EXPORT_UNAVAILABLE`. An export too large for one file fails with `failureCode`
`EXPORT_TOO_LARGE`; narrow the date range and ask again.

Any key of the workspace can read an export, not only the key that asked for it.

## Next steps

- [Rate limits](/docs/api/rate-limits) — how fast you may walk a list
- [Errors](/docs/api/errors) — every code and which to retry
- [Versioning](/docs/api/versioning) — what stays stable in `/v1`