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.