# Pagination

> How to read long lists: limit, cursor, has_more and stable ordering.

Source: https://diko.app/en/api-docs/pagination

## One model

Every list is paged the same way: you send `limit` and `cursor` and you get `next_cursor` back.

```json
{
  "object": "list",
  "data": [],
  "has_more": true,
  "next_cursor": "eyJ2IjoxLCJrIjoiMjAyNi0xMC0xMSJ9.k3J9mQ"
}
```

| Field | Meaning |
|---|---|
| `data` | the items of this page |
| `has_more` | whether more items follow |
| `next_cursor` | cursor for the next page. In lists it is `null` when `has_more` is `false`; in the event stream it is always returned |

> **Important:**
> Follow `has_more`, not the page size. A page can be shorter than `limit` and `has_more` can still be `true`.

## limit

| List | Default | Range |
|---|---|---|
| most lists | 20 | 1 to 100 |
| transcript segments (`format=json`) | 200 | 1 to 1000 |
| events | 20 | 1 to 100 |

An invalid value returns 400 [`invalid_parameter`](https://diko.app/en/api-docs/errors.md#invalid_parameter) with `error.param: "limit"`.

## cursor

- A cursor is **opaque**: do not try to parse or build one. Send exactly what you got from `next_cursor`.
- List cursors are tied to the user, the route, the sort order and the filters. If you change the filters or the sort order between pages, the API returns 400 [`invalid_cursor`](https://diko.app/en/api-docs/errors.md#invalid_cursor). Then start again without a `cursor`.
- List cursors are meant for one paging run. Do not store them for long.
- The **event stream cursor** is a position in the stream, so you can store it and continue later. `cursor=latest` starts from now: an empty page with the current `next_cursor` is returned.

## Ordering

Ordering is stable: by the chosen key and then `id`, so items neither repeat nor go missing between pages.

| List | Sort key |
|---|---|
| `GET /meetings` | `created_at` (default) or `updated_at` (`sort`); direction `order=desc` (default) or `asc` |
| `GET /library/transcripts` | `added_at`; direction `order` |
| `GET /library/imports` | `created_at`, newest first; lists the imports of the last 90 days |
| `GET /events` | the order in which events were committed |

Filter rules:

- **Repeated filters** are repeated: `status=ready&status=failed`.
- **Dates** are an RFC 3339 date or date-time. A date-time needs a time zone (`2026-10-11T09:30:00Z` or `+03:00`); a bare date such as `2026-10-11` means 00:00 UTC. Bounds are exclusive: `_after` means "later than", `_before` means "earlier than".
- **Bounds are compared in milliseconds**, the resolution of the timestamps in responses. So you can pass back a timestamp you received (`updated_after=<updated_at of the last item>`) and you never get that same item again; an item that shares the millisecond of your bound is not returned either. Rows changed by one operation (archiving several meetings at once, for example) often share a single timestamp, so never rely on `updated_after` alone to move from one page to the next.
- `updated_after` and `updated_at` only filter; the order of the list still follows `sort` and `order`.
- **Incremental sync.** Inside one run, read the pages in order (`sort=updated_at&order=asc`) by following `next_cursor` until `has_more` is `false`: the order is the key and then `id`, so items that share a timestamp are neither repeated nor skipped. Use `updated_after` only to start the **next** run: send the largest `updated_at` you saw, moved back by a safety margin (a minute is plenty), and skip the items you have already processed by `id`. A page that comes back empty is not a reliable end of the run. If you must not miss any change, read [`GET /events`](https://diko.app/en/api-docs/webhooks.md#the-event-stream-as-a-fallback) instead.

## Reading every item

**Python**

```python
import os

import requests

BASE = "https://api.diko.app/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['DIKO_API_KEY']}"}


def paginate(path, params=None):
    params = {"limit": 100, **(params or {})}
    while True:
        response = requests.get(f"{BASE}{path}", headers=HEADERS, params=params, timeout=30)
        response.raise_for_status()
        page = response.json()
        yield from page["data"]
        if not page["has_more"]:
            return
        params["cursor"] = page["next_cursor"]


for meeting in paginate("/meetings", {"status": ["ready"]}):
    print(meeting["id"], meeting["title"])
```

**TypeScript**

```typescript
const BASE = "https://api.diko.app/api/v1"
const headers = { Authorization: `Bearer ${process.env.DIKO_API_KEY}` }

async function* paginate<T>(path: string, params: Record<string, string> = {}): AsyncGenerator<T> {
  let cursor: string | null = null
  do {
    const query = new URLSearchParams({ limit: "100", ...params, ...(cursor ? { cursor } : {}) })
    const response = await fetch(`${BASE}${path}?${query}`, { headers })
    if (!response.ok) throw new Error(`${response.status} ${await response.text()}`)
    const page: { data: T[]; has_more: boolean; next_cursor: string | null } = await response.json()
    yield* page.data
    cursor = page.has_more ? page.next_cursor : null
  } while (cursor)
}

for await (const meeting of paginate<{ id: string; title: string }>("/meetings", { status: "ready" })) {
  console.log(meeting.id, meeting.title)
}
```

## Transcripts and `revision`

Pages of transcript segments carry a `revision` field in the list envelope. The cursor is tied to it: if the transcript changes while you page (for example because grammar corrections were approved), the API returns 409 [`transcript_changed`](https://diko.app/en/api-docs/errors.md#transcript_changed). Then read the transcript again without a `cursor`. If you need the whole text in one call, use `format=text`, `srt` or `vtt`.

## The event stream

`GET /events` uses the same model but always returns `next_cursor`: even when `has_more` is `false`, store it and start from it next time. Only **committed** events are shown, in commit order, so none is skipped.

- Events are kept for **30 days**. Without a `cursor` the stream starts at the oldest event still kept.
- Diko records events only while the account has a key that is not revoked or an enabled webhook endpoint. Meetings and imports that finished earlier produce no events, so the stream does not back-fill history. Read history with the list endpoints.
- When the page has events, `next_cursor` points at the last of them.
- When the page is **empty**, `next_cursor` is the current position of the stream (never behind the cursor you sent), so a poller that stores it does not scan the same range again.
- A cursor that is not an event cursor (not `latest` and not one this API gave you) returns 400 [`invalid_cursor`](https://diko.app/en/api-docs/errors.md#invalid_cursor).

More: [Events and webhooks](https://diko.app/en/api-docs/webhooks.md#the-event-stream-as-a-fallback).
