Pagination

Cursor rules for list endpoints, and the list envelope.

Every list route answers the same envelope. data is the page, and pages.next.starting_after is an opaque cursor that names the next page, or null when the page was the last one.

List envelope
{
  "type": "list",
  "data": [
    { "type": "conversation", "id": "…", "…": "…" }
  ],
  "pages": {
    "type": "pages",
    "per_page": 20,
    "next": { "starting_after": "eyJ2IjoxLCJzIjoibGFzdF9hY3Rpdml0eV9hdCIsIm8iOiJkZXNjIiwiayI6WyIyMDI2LTA4LTMxVDExOjAyOjAzLjQ1NloiLCJmNGY2ZmI2MC05YzExLTQzZTItOGJiMy02NTczMWE3ZmI2N2MiXX0" }
  }
}

Request parameters

  • per_page is an integer from 1 to 150, default 20.
  • starting_after is the cursor from the previous page's next.
  • sort and order vary per entity; a cursor only works with the same sort and direction it was minted under.

Keyset, not offset

Pagination walks the sort key rather than an offset. Nothing is skipped when a row is inserted between pages, the way an OFFSET page would be. The tradeoff: a row whose sort key moves between pages can appear twice, and clients deduplicate on id.

The cursor is a base64url-encoded JSON object that pins the version, the sort, the direction and the last row's sort key plus id. A cursor minted under one sort but sent with another answers 400 invalid_cursor at the route, before any query runs.

Note

Some reads are not paginated. GET /api/conversations/counts returns one row per view, and the saved-replies and outbound collections cap at 150 because their screens walk the whole list.