Concepts

Pagination & Incremental Sync

List endpoints are cursor-paginated, and most of them double as a sync surface: pass updated_since and you get only what changed, plus a watermark to store for the next run.

Cursor mechanics

There is no page number and no offset. Cursors are keyset-based: each page's meta.next_cursor encodes where the walk stopped, so a row inserted while you are halfway through a collection cannot make you skip or repeat one the way an offset would.

{
  "data": [ /* up to `limit` rows */ ],
  "meta": {
    "request_id": "req_...",
    "has_more": true,
    "next_cursor": "cur_...",
    "synced_through": "2026-08-03T11:59:55Z"
  }
}
  • meta.has_more — whether another page exists. This is the loop condition.
  • meta.next_cursor — a cur_-prefixed string, or null on the last page. It is opaque: store it and send it back, never parse it.
  • meta.synced_through — the sync watermark, present only on the final page. See below.
Every way a cursor can be wrong — truncated, edited, issued under a different sort — answers the single code cursor_invalid. One code means your client needs exactly one branch: discard the saved cursor and restart from the first page.
There is no total count, deliberately. An exact count over a mutating multi-million-row table is a full scan on every page, stale by the time you read it. Use has_more to decide whether to keep going.

Parameters

limitintegerdefault: 251..100
Rows per page. Above the maximum is a validation error, not a silent clamp.
cursorstring
The next_cursor from the previous page. Bound to the sort it was issued under.
sortstringdefault: updated_at
Which timestamp orders the walk. The allowed values differ per endpoint — the reference lists each endpoint's set. Only non-nullable columns are sortable: keyset pagination over a column that can be null is not resumable.
orderstringdefault: desc
Direction of travel through the sort key (asc or desc).
updated_sincestring (ISO 8601)
Only rows modified at or after this instant. Feed back the last synced_through you stored.

An unsupported value never degrades silently: sort_unsupported and filter_unsupported mean your code is wrong and retrying will not help — distinct from cursor_invalid, which means your saved state is stale. See errors.

Walking a collection

# First page
curl "https://api.mailneo.co/api/v2/subscribers?limit=100" \
  -H "x-api-key: $MAILNEO_API_KEY"

# While meta.has_more is true, pass meta.next_cursor back
curl "https://api.mailneo.co/api/v2/subscribers?limit=100&cursor=$CURSOR" \
  -H "x-api-key: $MAILNEO_API_KEY"

Incremental sync with updated_since

Walking every row on every run is wasteful and will hit the rate limit long before it hits correctness problems. Endpoints that support sync accept updated_since and return a watermark in meta.synced_through. The loop:

# First run: no watermark, walk every page.
curl "https://api.mailneo.co/api/v2/subscribers?limit=100" \
  -H "x-api-key: $MAILNEO_API_KEY"

# Follow meta.next_cursor until meta.has_more is false, then store the
# meta.synced_through from that FINAL page.
curl "https://api.mailneo.co/api/v2/subscribers?limit=100&cursor=$CURSOR" \
  -H "x-api-key: $MAILNEO_API_KEY"

# Every run after that: only what changed.
curl "https://api.mailneo.co/api/v2/subscribers?updated_since=$SYNCED_THROUGH" \
  -H "x-api-key: $MAILNEO_API_KEY"

Why synced_through appears only on the final page

The watermark asserts "everything changed up to this instant has been handed to you". Mid-walk, that is not yet true — pages you have not fetched may hold rows the watermark would claim to cover. If the server emitted it on every page, a client that crashed mid-walk and persisted it would silently skip those rows on every future run, forever — the worst kind of sync bug, because nothing ever looks wrong. So the server refuses to emit it until has_more is false, and the one honest place to read it is the final envelope.

Three rules that keep a sync correct

  • Upsert by id, never insert. The watermark is held 5 seconds behind real time so a row written during your request cannot slip between two runs. The cost of that safety is occasionally re-seeing a row you already have.
  • Store the watermark only after the last page. Saving it mid-walk and crashing loses every row you had not read yet.
  • Deletes are invisible. A deleted row has no timestamp to be newer than your watermark, so it simply stops appearing. If your system must mirror deletions, reconcile with a periodic full walk.
Not every list supports sync. Where the underlying record has no modification timestamp, updated_sinceis absent rather than accepted-and-ignored — an endpoint that accepted it without honouring it would let you build a poll that silently missed rows forever. Each endpoint's reference page shows exactly which parameters it takes.