Every list endpoint is cursor-paginated, except a few short ones that return
everything in one response: listContactTopics, listStandardEventNames,
listCampaignLinks, listSenders and listWebhookEvents. Use the returned cursor to
request the next page.
The parameters
| Parameter | Description |
|---|---|
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 — query (search text, at least 1
character) on the lists that take it, plus per-endpoint ones such as status. The
longest query differs by list; see Limits. Keep
every filter the same across a traversal.
Date ranges
Every date range in the API is half-open: the start is inclusive and the end exclusive, so consecutive ranges never overlap or leave a gap. Both bounds are ISO 8601.
- Filters on a list’s items are named for the field they filter,
<field>After(at or after) and<field>Before(before):createdAfterandcreatedBeforeon contacts and messages,occurredAfterandoccurredBeforeon the event log. Each takes a date-time with an offset. - Report windows, the period a report or chart aggregates, are
from(inclusive) andto(exclusive) on every report, the campaign series and journey statistics. Each takes a date-time, or a date (YYYY-MM-DD) meaning the start of that day in the workspace timezone. To include a whole last day, pass the day after it:
GET /v1/reports/overview?from=2026-10-01&to=2026-10-08 1 to 7 OctoberA report’s response gives the window it covered as from and to instants, with the
timezone its days are in. Omit both for the last 30 days. A report window ends no later
than the end of today and touches at most 366 days; a series covers at most 72 hours or
366 days of buckets.
The response
// A list response; "data" holds the page's items
{
"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: nullThere is no total, except on contacts and segment contacts
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: a segment’s memberCount, kept by its
background evaluation, 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 one exception is GET /v1/contacts, because the contact list is where teams ask
“how many match?”: its first page carries meta.count, the contacts matching the filters
counted up to 10,000, and meta.countExact, false when the count stopped at that cap.
The count stops at the cap so it stays cheap however large the workspace, and both are
null on later pages (any request with a cursor), since they do not change between
pages.
GET /v1/segments/{id}/contacts carries the same meta.count and meta.countExact:
the matching contacts counted up to 10,000, on the first page only. A list carries no
count of its own: GET /v1/contacts?listId= counts its members the same way.
The cursor is opaque
It is base64url-encoded JSON, but that is an implementation detail and 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.
A cursor belongs to the filters it was issued under: every query parameter except
cursor and limit. Sent with different filters — another status, query or date range —
it returns 400 VALIDATION_ERROR with an issue at cursor (in: "query"), rather than a
page of the wrong list. Start again without cursor when the filters change.
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.
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 large-scale repeats that offset pagination causes, but it is not a snapshot.
Segment listings deserve particular care. GET /v1/segments/{id}/contacts evaluates the
segment on read, so it can return a different population between pages if contacts are
changing underneath. GET /v1/contacts?segmentId= reads the segment’s members as of its
last background sweep, so it can lag behind recent changes. If you need a stable audience,
use a list.
Exporting
To take the whole send log as a file, see Message log and exports.