Reference

Pagination

List endpoints page with a cursor — ask for a page size, then follow next_cursor until it runs out.

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:

limitintegerOptional
How many items to return per page. Defaults to 50, with a maximum of 100. Values above 100 are clamped to 100; values that aren't a positive integer fall back to the default.
cursorstringOptional
An opaque cursor returned as next_cursor by the previous page. Omit it on the first request. Pass it verbatim — never construct or modify one yourself.

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).

The cursor is opaque: it's a base64url-encoded snapshot of the last row's position. Treat it as a black box — pass it back unchanged. Don't decode, parse, or hand-build one. Its internal shape is not part of the API contract and may change.

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
}
Drive your loop off 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.

Auto-pagination over a large account can issue many requests in quick succession. Each one counts against your rate limit, so honor 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.