Skip to content

Pagination

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

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 and Duesly (payments).

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.

Nothing else on the API returns a list. There is no GET /messages, no GET /calls, and no ticket list. 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.
Text
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.

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.

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

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

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.

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:

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 for the validation shape.

Walking a list correctly #

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

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);
}

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

Conversations

The endpoints that paginate — the conversation object, the transcript, and a worked incremental sync.

Agents & channels

GET /agents — the list that returns this envelope but never paginates.

Rate limits

120 per key per minute — the ceiling every backfill meets first.

Sync conversations

The complete recipe — watermarks, scheduling, and where webhooks fit alongside a walk.