Skip to content
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.

GET /v1/messages?limit=25&cursor=eyJpZCI6Im1zZ18wMUhYWVoiLCJ0cyI6IjIwMjYtMDUtMjNUMTA6MDA6MDAuMDAwWiJ9 HTTP/1.1
Query paramDefaultRangeNotes
limit251-100Page size. Out-of-range → 400 invalid_limit.
cursoropaqueEcho 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.

{
"data": [ { "...": "..." }, { "...": "..." } ],
"has_more": true,
"next_cursor": "eyJpZCI6Im1zZ18wMUhYWVoiLCJ0cyI6IjIwMjYtMDUtMjNUMTA6MDA6MDAuMDAwWiJ9",
"request_id": "req_…"
}
FieldMeaning
dataArray of resources (zero or more).
has_moretrue if at least one more page is available.
next_cursorOpaque string to pass back on the next call. null when has_more is false.
request_idEchoes the X-Request-Id header.
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
}
// Usage
const all = await paginate<MessageResource>(
'https://api-kckit.kirim.chat/v1/messages?conversation_id=cnv_…',
{ Authorization: `Bearer ${process.env.KIRIM_KEY}` },
)
  • 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=open to status=resolved between 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).

Different resources have different natural orderings — pagination itself is the same.

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

  • Don’t ?limit=100 then 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 pass cursor will paginate the first page repeatedly. Always exit when has_more is false.
  • 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.