Menu

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

{
  "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:

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