The message collection can be large, so the API returns it one page at a time. GET /v1/messages is cursor-based. You ask for a page of a given size, and each response returns a next_cursor that points at the row after the last one you received. You follow that cursor to walk the whole collection. The cursor uses no page number and no offset, so it never skips a row and never repeats one when the data changes under 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 id as 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 will not 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.
curl "https://api.sendara.dev/v1/messages?limit=50" \
-H "Authorization: Bearer sk_live_xxx"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": "transactional", "created_at": "2026-06-14T09:15:00Z" }
],
"next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNi0xNFQwOToxNTowMFoiLCJpZCI6Im1zZ185ZjIxIn0"
}The collection lives under a named key, such as 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.
curl "https://api.sendara.dev/v1/messages?limit=50&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wNi0xNFQwOToxNTowMFoiLCJpZCI6Im1zZ185ZjIxIn0" \
-H "Authorization: Bearer sk_live_xxx"Know when you are done
When no row follows the page that you received, next_cursor comes back as null. That is 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.
const all = [];
for await (const message of sendara.messages.list({
status: "bounced",
limit: 100,
})) {
all.push(message);
}
console.log(`fetched ${all.length} bounced messages`);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",
"status": 400
}
}An out-of-range or non-numeric limit never errors. It is clamped to the 1…100 range (or the default of 50) so a bad value cannot take a request down.