Pagination
List endpoints return a cursor, not a page number:
{ "data": [ /* ... */ ], "has_more": true, "next_cursor": "MTc1NTY0ODAwMDAwMDAwMDAwMC42ZjFjMmQzZS00YTVi..."}To get the next page, pass it back:
GET /v1/contacts?limit=100&cursor=MTc1NTY0ODAwMDAwMDAwMDAwMC42ZjFjMmQzZS00YTVi...Stop when has_more is false. When it is false, next_cursor is absent.
Why not page numbers
Section titled “Why not page numbers”These tables take inserts constantly. Between fetching page 1 and page 2 the head of the list moves, so a page-numbered walk both repeats rows and skips them, and neither is visible from the outside. A cursor is anchored to the exact row it was produced from, so the walk stays consistent no matter what arrives while you are reading.
Looping
Section titled “Looping”async function* allContacts(apiKey) { let cursor = null
do { const url = new URL('https://k-message.kerneltics.com/v1/contacts') url.searchParams.set('limit', '100') if (cursor) url.searchParams.set('cursor', cursor)
const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } }) if (!res.ok) throw new Error(`contacts: ${res.status}`)
const page = await res.json() yield* page.data cursor = page.has_more ? page.next_cursor : null } while (cursor)}Limits
Section titled “Limits”limit defaults to 50 and caps at 200. A larger value is clamped rather
than rejected: asking for 1000 means “as many as possible”, and failing the
request teaches you nothing a clamp does not.
Treat the cursor as opaque
Section titled “Treat the cursor as opaque”It is a token to hand back, nothing more. Do not parse it or construct one. The internal ordering behind it can change without notice, and code that decoded it would break when it does.
A malformed cursor returns 422 rather than silently starting from the
beginning, because a paging loop that quietly resets never terminates and never
reports anything wrong.
Filtering while paging
Section titled “Filtering while paging”Filters combine with cursors. Keep them identical across every request in one walk; changing a filter mid-walk gives a cursor from one result set to a different one, and the results will not be what you expect.
The most useful filter is updated_since on contacts, which is what makes a
nightly sync possible without pulling everything:
GET /v1/contacts?updated_since=2026-08-19T00:00:00Z&limit=200