Collections like GET /v1/messages can be large, so the API returns them one page at a time. Pagination is cursor-based: you ask for a page of a given size, and each response hands back a next_cursor that points at the row after the last one you received. You follow that cursor to walk the whole collection — no page numbers, no offsets, no rows skipped or repeated when data changes underneath you.
How it works
Every paginated request takes two query parameters, and every response carries a next_cursor field:
Results are ordered newest first — by created_at descending, with idas a stable tie-breaker. Because the cursor encodes that exact position rather than a numeric offset, inserting or deleting rows between page fetches never shifts the window: you won't see a row twice or miss one that slipped across a page boundary.
Fetch the first page
Omit cursor on the first request. Set limit to the page size you want — up to 100.
The response wraps the page in a typed array plus a cursor:
{
"messages": [
{ "id": "msg_a1b2c3", "channel": "email", "status": "delivered",
"message_type": "transactional", "created_at": "2026-06-14T10:00:00Z" },
{ "id": "msg_9f21", "channel": "email", "status": "bounced",
"message_type": "marketing", "created_at": "2026-06-14T09:15:00Z" }
],
"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNi0xNFQwOToxNTowMFoiLCJpZCI6Im1zZ185ZjIxIn0"
}The collection lives under a named key — messages for GET /v1/messages. The next_cursor field sits alongside it at the top level.
Fetch the next page
Pass the next_cursor from the previous response back as the cursor query parameter. Keep limit the same (or change it — the cursor is independent of page size).
Knowing when you're done
When there are no more rows after the page you just received, next_cursor comes back as null. That's your signal to stop — there is no further page to fetch.
{
"messages": [
{ "id": "msg_0001", "channel": "email", "status": "delivered",
"message_type": "transactional", "created_at": "2026-06-12T08:00:00Z" }
],
"next_cursor": null
}next_cursor, not the page length. A full page can still be the last page, and an empty collection returns "messages": [] with "next_cursor": null — never a missing field.Auto-paginate the whole collection
To pull every row, loop until next_cursor is null, feeding each page's cursor into the next request. Use the maximum limit of 100 to minimize round-trips, and carry any filters (like status or a date range) on every call.
X-RateLimit-Remaining and back off on a 429 using the Retry-After header rather than hammering the endpoint. See the API reference for rate-limit details.Filters and pagination together
Filters narrow what is paginated; the cursor controls where you are within that filtered set. On GET /v1/messages you can combine channel, status, and a from/to time range (both RFC 3339) with limit and cursor. Keep the filters identical across every page of a single walk — changing them mid-walk invalidates the cursor's position.
curl -G "https://api.sendara.dev/v1/messages" \
-H "Authorization: Bearer sk_live_xxx" \
--data-urlencode "status=bounced" \
--data-urlencode "from=2026-06-01T00:00:00Z" \
--data-urlencode "to=2026-06-14T23:59:59Z" \
--data-urlencode "limit=100"Errors
A malformed or tampered cursor is rejected with 400 invalid_request rather than silently returning the first page — so a corrupted cursor never makes you re-read data you already have. Always pass back exactly what the API returned.
{
"error": {
"code": "invalid_request",
"message": "Invalid cursor"
}
}An out-of-range or non-numeric limit never errors — it is clamped to the 1…100range (or the default of 50) so a bad value can't take a request down.