# Pagination

> One envelope and one rule for all seven paginated lists. Pass next_cursor back as cursor, and stop when it is null.

Source: https://docs.amomic.in/api/pagination

---

Every list on this API returns the same two-field envelope and follows the same stopping rule: keep going while `next_cursor` is a string, and stop when it is `null`. Learn it once and it applies to all eight list endpoints.

```json
{
  "items": [ … ],
  "next_cursor": "…" 
}
```

That is the entire contract. No total, no page numbers, no links, no metadata block.

## What actually paginates

| Endpoint | `limit` default | `limit` max | Filters | Order | Cursor is |
|---|---|---|---|---|---|
| `GET /conversations` | `25` | `100` | `agent_id` | Latest activity first | The last item's `updated_at` |
| `GET /conversations/{id}/messages` | `100` | `200` | – | Oldest first | The last item's `message_id` |
| `GET /contacts` | `25` | `100` | – | Newest contact first | The last item's `contact_id` |
| `GET /campaigns` | `25` | `100` | `status` | Latest activity first | Opaque |
| `GET /campaigns/{id}/recipients` | `100` | `200` | `status` | Latest activity first | Opaque |
| `GET /apps/duesly/subscribers` | `25` | `200` | – | Newest subscriber first | Opaque |
| `GET /apps/duesly/dues` | `25` | `200` | `status`, `scope` | Latest due date first; soonest first for `scope=overdue` and `scope=upcoming` | Opaque |
| `GET /agents` | – | – | – | – | Always `null` |

`GET /campaigns` without `status` lists every campaign except archived ones; pass `status=archived` to see those. The status values for campaigns, recipients, and dues are listed on [Campaigns](/api/campaigns) and [Duesly (payments)](/api/apps-duesly).

`GET /agents` returns the identical envelope, `items` plus `next_cursor`, but accepts no `limit`, accepts no `cursor`, and never sets `next_cursor` to anything but `null`. It gives you every agent in the workspace in one response. Write your client so that consuming it through the same generic walk function is harmless: the loop reads one page, sees a `null` cursor, and stops. See [Agents and channels](/api/discovery).

Nothing else on the API returns a list. There is no `GET /messages`, no `GET /calls`, and no ticket list. [Overview](/api/overview) has the full route table.

## The stopping rule

**Stop when `next_cursor` is `null`.** That is the only signal, and it is correct on every list.

How the server decides differs in one detail, and it only matters if you count requests:

- **Conversations, messages, and contacts** set a cursor whenever the page came back **full**. If the count equals the `limit` you asked for, there might be more. If it is even one short, there cannot be, so the cursor is `null`.
- **Campaigns, recipients, and Duesly** look one row ahead, and set a cursor only when there **is** another row.

```
limit=25, got 25 items  →  next_cursor: "2026-10-14T05:41:09.204118+00:00"   keep going
limit=25, got 24 items  →  next_cursor: null                                   stop
limit=25, got 0 items   →  next_cursor: null                                   stop
```

Do not infer "more pages" from the size of `items` yourself, and do not stop early because a page looked short in a way you did not expect. Stop when the cursor is `null`, and only then.

::: note One extra request at the boundary
On conversations, messages, and contacts, if the number of matching rows is an exact multiple of your `limit`, the last real page comes back full, so it carries a cursor. Following it returns `{"items": [], "next_cursor": null}` and the walk ends there.

That empty page is correct behaviour, not an error. A loop that handles it naturally (process zero items, see a null cursor, stop) needs no special case. A loop that assumes every page has at least one item does need one.
:::

## Using the cursor

Pass `next_cursor` back as the `cursor` query parameter on the next request. Change nothing else — keep the same `limit` and the same filters.

```
GET /api/v1/conversations?limit=50
GET /api/v1/conversations?limit=50&cursor=2026-10-14T05%3A41%3A09.204118%2B00%3A00
```

::: warning URL-encode it
The conversations cursor is an ISO timestamp with a `+00:00` offset, and a raw `+` in a query string means a space. Use your HTTP client's parameter encoding (`URLSearchParams` in Node, `params={}` in `requests`, `--data-urlencode` with `curl -G`), never string concatenation. The other cursors can carry characters that need encoding too, so encode every cursor the same way. A cursor that loses its `+` is a cursor that does not mean what you sent.
:::

### Treat it as opaque

The values are not random: for conversations the cursor is the last row's `updated_at`, for messages the last row's `message_id`, and for contacts the last row's `contact_id`. Knowing that helps you read a log line. It is not permission to build one. The campaign and Duesly cursors have no documented shape at all.

- **Do not construct a cursor** from a timestamp you have lying around. There is no documented behaviour for a value we did not issue.
- **Do not parse a cursor** to learn where you are. For conversations, `items[-1].updated_at` is the same value and is part of the response contract; for messages, `items[-1].message_id` is.
- **Do not persist a cursor** as a long-lived bookmark between scheduled runs. It is a position inside one walk, not a subscription. Persist a value from the data instead — the highest `updated_at` you have processed — and start each run from page one. [Conversations](/api/conversations) has that pattern written out.

A cursor from one list has no meaning on another. They are different types that happen to share a parameter name.

## Ordering

Each list has a fixed order (see the table above). The two conversation lists order in opposite directions, and both choices are deliberate.

**Conversations: newest activity first.** Ordered by `updated_at` descending. Page one is what changed most recently, which is what makes an incremental sync possible — you walk forward until you reach something you have already seen, then stop.

**Messages: oldest first.** Ordered by `message_id` ascending, and those ids are time-ordered, so this is chronological order. Paginating forward reads the conversation from its opening line to its last turn. That is reading order, and it is the order you want when you are rendering a transcript or feeding one to a model.

::: warning A live list is not a stable snapshot
Conversations are ordered by recent activity, so a conversation that receives a message *while you are paginating* jumps to the front of the list — behind the cursor you already passed. You will not see it in this walk, and a conversation that moves from page 3 to page 1 can push another one across the page boundary you already crossed, so you can also see the same conversation twice.

Neither is a bug you can configure away; it is what "ordered by mutable field" means. Design around it:

- **Upsert, never append.** Key on `conversation_id` and `message_id` so a duplicate is a no-op.
- **Let the next run catch what moved.** A conversation that jumped forward has a newer `updated_at` than your watermark, so the next scheduled walk picks it up first.

Messages do not have this problem. `message_id` never changes and new turns are always appended at the end, so a forward walk is stable. Campaigns and recipients are ordered by activity too, so the same advice applies to them while a campaign is running.
:::

## Choosing a limit

Bigger pages mean fewer requests, and requests are the scarce resource — you have [120 per key per minute](/api/rate-limits).

| Situation | Suggested `limit` |
|---|---|
| Backfilling a workspace's history | The maximum: `100` conversations or contacts, `200` messages |
| An incremental sync every few minutes | `100` conversations; you will usually stop inside page one |
| Rendering a list in your own UI | Match your page size, `25`–`50` |

A `limit` outside the endpoint's bounds is a `400`, not a silent clamp:

```json title="400 Bad Request"
{
  "error": {
    "code": "invalid_argument",
    "message": "Request validation failed.",
    "details": {
      "errors": [
        {
          "loc": ["query", "limit"],
          "msg": "Input should be less than or equal to 100",
          "type": "less_than_equal"
        }
      ]
    },
    "request_id": "req_01JQRS4T8XZ0AB1CD2EF3GH4",
    "correlation_id": "cor_01JQRS4T8XZ0AB1CD2EF3GH4"
  }
}
```

Note the maxima differ by list: `100` for conversations, contacts, and campaigns; `200` for messages, recipients, and the Duesly lists. Sending `limit=200` to `/conversations` fails. See [Errors](/api/errors) for the validation shape.

## Walking a list correctly

One generic function handles every list on the API, including the one that does not paginate.

::: codegroup

```javascript Node.js
const BASE = "https://services.amomic.in/api/v1";
const HEADERS = { Authorization: `Bearer ${process.env.AMOMIC_API_KEY}` };

/**
 * Yields every item across every page.
 * Works for every list: /conversations, /contacts, /campaigns, and the rest.
 * /agents simply returns one page with a null cursor.
 */
async function* paginate(path, query = {}) {
  let cursor = null;

  for (;;) {
    const params = new URLSearchParams(query);
    if (cursor) params.set("cursor", cursor); // URLSearchParams encodes the +

    const response = await fetch(`${BASE}${path}?${params}`, { headers: HEADERS });

    if (!response.ok) {
      const { error } = await response.json();
      throw new Error(`${error.code}: ${error.message} (${error.request_id})`);
    }

    const { items, next_cursor } = await response.json();

    // An empty final page is normal when the row count divides evenly.
    for (const item of items) yield item;

    if (!next_cursor) return; // the only stop condition
    cursor = next_cursor;
  }
}

// Every conversation handled by one agent.
for await (const conversation of paginate("/conversations", {
  limit: "100",
  agent_id: agentId,
})) {
  await upsertConversation(conversation); // idempotent — pages can overlap
}

// A full transcript, oldest turn first.
const turns = [];
for await (const message of paginate(`/conversations/${conversationId}/messages`, {
  limit: "200",
})) {
  turns.push(message);
}

// Every recipient of a campaign that failed.
const failed = [];
for await (const recipient of paginate(`/campaigns/${campaignId}/recipients`, {
  status: "failed",
  limit: "200",
})) {
  failed.push(recipient);
}
```

```python Python
import os
from collections.abc import Iterator

import requests

BASE = "https://services.amomic.in/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['AMOMIC_API_KEY']}"}


def paginate(path: str, **query: str | int) -> Iterator[dict]:
    """Yield every item across every page.

    Works for every list: /conversations, /contacts, /campaigns, and the rest.
    /agents simply returns one page with a null cursor.
    """
    cursor: str | None = None

    while True:
        params = dict(query)
        if cursor:
            params["cursor"] = cursor  # requests encodes the + for you

        response = requests.get(f"{BASE}{path}", headers=HEADERS, params=params, timeout=30)

        if not response.ok:
            error = response.json()["error"]
            raise RuntimeError(
                f"{error['code']}: {error['message']} ({error.get('request_id')})"
            )

        page = response.json()

        # An empty final page is normal when the row count divides evenly.
        yield from page["items"]

        if not page["next_cursor"]:  # the only stop condition
            return
        cursor = page["next_cursor"]


# Every conversation handled by one agent.
for conversation in paginate("/conversations", limit=100, agent_id=agent_id):
    upsert_conversation(conversation)  # idempotent: pages can overlap

# A full transcript, oldest turn first.
turns = list(paginate(f"/conversations/{conversation_id}/messages", limit=200))

# Every recipient of a campaign that failed.
failed = list(paginate(f"/campaigns/{campaign_id}/recipients", status="failed", limit=200))
```

:::

Three properties make those loops correct rather than nearly correct:

- **The cursor is the only stop condition.** Not an item count, not an empty page, not a comparison against `limit`.
- **The query stays constant across pages.** Changing `limit` or a filter such as `agent_id` or `status` mid-walk pairs a cursor with a different result set.
- **The consumer is idempotent.** Because a live list can hand you the same row twice, `upsert` is the only safe verb.

## What does not exist

Do not build against any of these. They are not missing features waiting on a release; the envelope has two fields and that is the design.

| You might expect | Reality |
|---|---|
| `total`, `count`, `total_pages` | No count in the list envelope. For a campaign, the campaign object itself carries `recipients`, `sent`, `delivered`, and the other counters. |
| `has_more` | Redundant. `next_cursor !== null` is the same fact. |
| `prev_cursor` | Forward only. To go back, restart the walk. |
| `page=3`, `offset=50` | Cursor only. Offsets skip and duplicate rows when the underlying list is changing, which this one always is. |
| `sort` or `order` | Fixed per endpoint. See the table at the top of this page. |
| `Link` headers | Nothing is in the response headers. The cursor is in the body. |
| A cursor that expires | Not documented, so do not build a refresh path — but also do not hold one for hours. Persist a watermark instead. |

## Pagination and your rate limit

Each page is one request against the [120 per key per minute](/api/rate-limits) budget.

A first-run backfill is where this bites: one request per page of conversations, plus one request per page of transcript for each conversation. A workspace with two thousand conversations is well over two thousand requests, which is at least eighteen minutes at the limit even if nothing else is running.

Two things make that survivable:

- **Sleep between pages**, or use the largest `limit` each endpoint allows so you need fewer of them.
- **Give the backfill its own API key.** Scopes and rate limits are per key, so a backfill on a separate key cannot starve your production sends. [Scopes](/api/scopes) covers creating a narrow, read-only key for exactly this.

A `429` mid-walk is recoverable: the cursor you are holding is still valid, so back off and retry the same request rather than restarting.

## Related

::: cards

::: card Conversations
[The endpoints that paginate](/api/conversations) — the conversation object, the transcript, and a worked incremental sync.
:::

::: card Agents & channels
[GET /agents](/api/discovery) — the list that returns this envelope but never paginates.
:::

::: card Rate limits
[120 per key per minute](/api/rate-limits) — the ceiling every backfill meets first.
:::

::: card Sync conversations
[The complete recipe](/recipes/sync-conversations) — watermarks, scheduling, and where webhooks fit alongside a walk.
:::

:::
