Pagination
Cursor-based, both directions, constant cost at any history size
List endpoints (GET /v1/messages, GET /v1/workspaces) paginate by
cursor and always return rows newest-first.
The envelope
Walking the list
- First request — no cursor, optionally
limit(1–100, default 20). - Older items: request again with
?cursor=<next_cursor>. - Newer items:
?cursor=<prev_cursor>. From the top of the list this returns rows created after your first request — a built-in “load fresh” poll; an emptydatameans you’re at the top. - Stop when
next_cursorisnull(equivalently, whenhas_nextisfalse).
Rules cursors enforce
- Keep the query identical between hops. The cursor is fingerprinted with
your filters and
limit; changing them mid-walk returns400 validation.cursor_filters_mismatch. Start a fresh walk instead. - Cursors are opaque. Don’t parse or construct them — malformed values
return
400 validation.cursor_invalid.
About total
Counting is expensive on large histories, so total appears only on the
first page (no cursor) and is capped at 10 000: "total": 10000, "total_capped": true means “10 000 or more”. While walking with cursors,
total is null — use has_next and has_prev.
Filtering messages
GET /v1/messages composes these filters (all AND-ed), and each one is part of
the cursor fingerprint:
created_at takes one or two values:
- Two values → an inclusive range, order irrelevant
(
created_at=2026-10-01T00:00:00Z&created_at=2026-10-31T23:59:59Z). - One full timestamp → exact match (
created_at=2026-10-07T09:30:00Z). - One date, no time → that whole calendar day (
created_at=2026-10-07).
Include an offset for local-time filtering (2026-10-07T00:00:00+07:00); a
value without an offset is read as UTC. To show a user their own day, send the
two local-day boundaries with their offset.