# Pagination

Source: https://kirora.io/docs/api/reference/pagination

Page through any list with an opaque cursor from meta.pagination.nextCursor; there is no page parameter.

Every list endpoint is **cursor-paginated**, except a few short ones that return
everything in one response: `listContactTopics`, `listStandardEventNames`,
`listCampaignLinks`, `listSenders` and `listWebhookEvents`. Use the returned cursor to
request the next page.

## The parameters

| Parameter | Description |
| --- | --- |
| `limit` | Items per page. Default 20, maximum 100. Some endpoints cap lower; the reference gives the real maximum per operation. |
| `cursor` | Opaque position marker. Omit for the first page. |

Filters are ordinary query parameters beside these — `query` (search text, at least 1
character) on the lists that take it, plus per-endpoint ones such as `status`. The
longest `query` differs by list; see [Limits](https://kirora.io/docs/api/limits#reading-lists-and-reports). Keep
every filter the same across a traversal.

## Date ranges

Every date range in the API is half-open: the start is inclusive and the end exclusive,
so consecutive ranges never overlap or leave a gap. Both bounds are ISO 8601.

- **Filters on a list's items** are named for the field they filter, `<field>After`
  (at or after) and `<field>Before` (before): `createdAfter` and `createdBefore` on
  contacts and messages, `occurredAfter` and `occurredBefore` on the event log. Each takes
  a date-time with an offset.
- **Report windows**, the period a report or chart aggregates, are `from` (inclusive) and
  `to` (exclusive) on every report, the campaign series and journey statistics. Each
  takes a date-time, or a date (`YYYY-MM-DD`) meaning the start of that day in the
  workspace timezone. To include a whole last day, pass the day after it:

```
GET /v1/reports/overview?from=2026-10-01&to=2026-10-08     1 to 7 October
```

A report's response gives the window it covered as `from` and `to` instants, with the
`timezone` its days are in. Omit both for the last 30 days. A report window ends no later
than the end of today and touches at most 366 days; a series covers at most 72 hours or
366 days of buckets.

## The response

```jsonc
// A list response; "data" holds the page's items
{
  "data": [ … ],
  "meta": { "pagination": { "limit": 20, "nextCursor": "eyJpZCI6IjAxOGYi…" } }
}
```

Pass `nextCursor` back as `cursor` to get the next page. **`nextCursor` is `null` on the
last page** — that, not an empty `data` array, is the signal to stop. A full page can
still be the last one.

```
GET /v1/contacts?limit=50
GET /v1/contacts?limit=50&cursor=eyJpZCI6IjAxOGYi…
GET /v1/contacts?limit=50&cursor=eyJpZCI6IjAxOGYj…   → nextCursor: null
```

## There is no total, except on contacts and segment contacts

Lists do not report how many items there are, and that is deliberate. An exact count costs
a scan of every matching row on every page — cheap for fifty campaigns, not for a million
contacts or a year of messages — and it goes stale the moment a row is written, so a
cursor walk and a total can disagree even when both are correct. Only the counts that
answer a question in their own right exist: a segment's `memberCount`, kept by its
background evaluation, and the stats endpoints for delivery.

A client that wants to show a size can say what it knows for certain: the items on the
page it holds, and — when `nextCursor` is `null` on the first page — that those are all
of them.

The one exception is `GET /v1/contacts`, because the contact list is where teams ask
"how many match?": its first page carries `meta.count`, the contacts matching the filters
counted up to 10,000, and `meta.countExact`, `false` when the count stopped at that cap.
The count stops at the cap so it stays cheap however large the workspace, and both are
`null` on later pages (any request with a `cursor`), since they do not change between
pages.

`GET /v1/segments/{id}/contacts` carries the same `meta.count` and `meta.countExact`:
the matching contacts counted up to 10,000, on the first page only. A list carries no
count of its own: `GET /v1/contacts?listId=` counts its members the same way.

## The cursor is opaque

It is base64url-encoded JSON, but that is an implementation detail and **not part of the
contract**: do not decode it, construct one, derive a page number from it, or persist
one as a durable bookmark. A malformed cursor returns 400 `VALIDATION_ERROR`.

A cursor belongs to the filters it was issued under: every query parameter except
`cursor` and `limit`. Sent with different filters — another `status`, `query` or date range —
it returns 400 `VALIDATION_ERROR` with an issue at `cursor` (`in: "query"`), rather than a
page of the wrong list. Start again without `cursor` when the filters change.

Keep `limit` the same across a run. Changing it mid-traversal is not an error, but the
cursor encodes position, not an offset, so mixing sizes makes the result harder to reason
about than it looks.

## Consistency

Lists are read from live data. A row inserted or deleted while you are part-way through a
traversal may be missed or repeated across page boundaries; cursor pagination avoids the
large-scale repeats that offset pagination causes, but it is not a snapshot.

Segment listings deserve particular care. `GET /v1/segments/{id}/contacts` evaluates the
segment on read, so it can return a different population between pages if contacts are
changing underneath. `GET /v1/contacts?segmentId=` reads the segment's members as of its
last background sweep, so it can lag behind recent changes. If you need a stable audience,
use a list.

## Exporting

To take the whole send log as a file, see [Message log and exports](https://kirora.io/docs/api/messages#exporting).

## Next steps

- [Rate limits](https://kirora.io/docs/api/reference/rate-limits) — how fast you may walk a list
- [Errors](https://kirora.io/docs/api/reference/errors) — every code and which to retry
- [Versioning](https://kirora.io/docs/api/reference/versioning) — what stays stable in `/v1`