Lists & pagination
Every endpoint that returns a collection returns the same object. Learn it once and it applies everywhere.
{
"data": [ { "id": "…" }, { "id": "…" } ],
"next_cursor": null,
"has_more": false
}
| Field | Type | Meaning |
|---|---|---|
data | array | The rows. Empty when nothing matches — never null, and never a 404. |
next_cursor | string | null | Opaque resume point. Pass it back as cursor. Null when there is no continuation — the last page, or a collection that isn’t paginated. |
has_more | boolean | Whether another page exists. |
total | integer | Total matching rows. Only on offset-paged endpoints — see below. |
data is always an array and the envelope is always an object, so a client can
be written once against this shape. Treat next_cursor as opaque: it is not a
timestamp or an ID, its encoding is not part of the contract, and constructing
one yourself will break.
Not every collection is paginated#
Small collections — sending identities, email types, stages, custom fields —
return everything in one response, with next_cursor: null and
has_more: false. Those fields are not padding: they are the truthful answer
(“there is nothing after this”), and they mean an endpoint can gain real
pagination later without changing shape a second time.
Write the loop anyway. Code that stops when has_more is false is correct
today on every endpoint and stays correct if one of them starts paginating.
Two paging mechanisms#
Most paginated endpoints are cursor-paged: pass the previous response’s
next_cursor back as cursor. Cursors are stable under inserts — a row added
while you are paging will not shift the window and cause a row to repeat or be
skipped, which is exactly what page-number paging does under a live write load.
A few are offset-paged, taking limit and offset. Those also return
total, since the count is already known. Cursor-paged endpoints deliberately
omit total rather than pay for a second full scan to produce it.
You can tell which is which from the endpoint’s parameters in the reference. The envelope does not change either way.
Paging to the end#
CURSOR=""
while : ; do
RESP=$(curl -s "$BASE/api/contacts?limit=200${CURSOR:+&cursor=$CURSOR}" \
-H "X-API-Key: $API_KEY")
echo "$RESP" | jq -c '.data[]' >> contacts.ndjson
[ "$(echo "$RESP" | jq -r '.has_more')" = "true" ] || break
CURSOR=$(echo "$RESP" | jq -r '.next_cursor')
done
Two things worth doing:
- Keep the sort stable across pages. A cursor encodes the sort it was
created under, so changing
sortordirmid-run is rejected rather than silently re-ordering. - For a full read-out of contacts, use an export instead. One export produces one CSV; paging the same audience can cost thousands of requests against your rate-limit budget.
Unknown fields#
Responses gain fields over time. A client must ignore fields it does not recognise rather than reject the response — that is the compatibility posture the whole API is written to, and it is what lets new data ship without a breaking change.