Core Concepts
Pagination
Every list endpoint (GET /v1/messages, GET /v1/contacts,
GET /v1/conversations, GET /v1/labels, GET /v1/webhook_deliveries,
etc.) uses the same opaque cursor pagination contract.
Request
Section titled “Request”GET /v1/messages?limit=25&cursor=eyJpZCI6Im1zZ18wMUhYWVoiLCJ0cyI6IjIwMjYtMDUtMjNUMTA6MDA6MDAuMDAwWiJ9 HTTP/1.1| Query param | Default | Range | Notes |
|---|---|---|---|
limit | 25 | 1-100 | Page size. Out-of-range → 400 invalid_limit. |
cursor | — | opaque | Echo next_cursor from the previous response, verbatim. |
Resource-specific filters (status, conversation_id, direction,
created_after, etc.) live alongside cursor — see each endpoint’s
reference for the exact set.
Response
Section titled “Response”{ "data": [ { "...": "..." }, { "...": "..." } ], "has_more": true, "next_cursor": "eyJpZCI6Im1zZ18wMUhYWVoiLCJ0cyI6IjIwMjYtMDUtMjNUMTA6MDA6MDAuMDAwWiJ9", "request_id": "req_…"}| Field | Meaning |
|---|---|
data | Array of resources (zero or more). |
has_more | true if at least one more page is available. |
next_cursor | Opaque string to pass back on the next call. null when has_more is false. |
request_id | Echoes the X-Request-Id header. |
Iteration recipe
Section titled “Iteration recipe”async function paginate<T>(url: string, headers: HeadersInit): Promise<T[]> { const out: T[] = [] let cursor: string | null = null do { const u = new URL(url) if (cursor) u.searchParams.set('cursor', cursor) const res = await fetch(u, { headers }) const body = await res.json() as ListResponse<T> out.push(...body.data) cursor = body.next_cursor } while (cursor) return out}
// Usageconst all = await paginate<MessageResource>( 'https://api-kckit.kirim.chat/v1/messages?conversation_id=cnv_…', { Authorization: `Bearer ${process.env.KIRIM_KEY}` },)Cursor semantics
Section titled “Cursor semantics”- Opaque. Do not parse, modify, or assume internal structure. The encoding may change without notice and without a major version bump — the only thing Kirim promises is that the cursor you got back works on the next call.
- Tamper-evident. A malformed or modified cursor returns
400 invalid_cursor. - Filter-bound. A cursor is only valid for the same filter set
it was generated against. If you change
status=opentostatus=resolvedbetween pages, generate a fresh request without a cursor. - Stable under concurrent inserts. Keyset pagination on
(created_at DESC, id DESC)means a row inserted while you’re paginating either appears on a page you’ve already seen (and is missing from later pages — accept this) or on a page you’ll see later (always with deterministic ordering).
Default ordering
Section titled “Default ordering”Different resources have different natural orderings — pagination itself is the same.
| Endpoint | Order |
|---|---|
GET /v1/messages | (created_at DESC, id DESC) |
GET /v1/contacts | (created_at DESC, id DESC) |
GET /v1/conversations | (last_message_at DESC NULLS LAST, id DESC) |
GET /v1/labels | (created_at DESC, id DESC) |
GET /v1/webhook_deliveries | (created_at DESC, id DESC) |
GET /v1/templates | (created_at DESC, id DESC) |
No ?sort= parameter is exposed in v1 — the default is the only
order. A future ?sort=… parameter can be added non-breakingly with
the current default still applying when unset.
Anti-patterns
Section titled “Anti-patterns”- Don’t
?limit=100then drop most of the results client-side. Apply the resource-specific filters server-side; you save your read budget and reduce latency. - Don’t loop forever on
next_cursor. A bug in your code that forgets to passcursorwill paginate the first page repeatedly. Always exit whenhas_moreisfalse. - Don’t paginate to materialise full tables for export. Use the dashboard’s CSV export feature instead — it streams server-side without burning your API quota.