Skip to content

Pagination

Two models, one rule of thumb: offset pages for browsing, cursors for iterating.

Every list response uses the same envelope:

{
  "data": [ ... ],
  "pagination": { ... },
  "meta": { "dataset": "...", "api_version": "v1", "last_updated": "...", "generated_at": "..." },
  "links": { "self": "...", "next": "..." }
}

links.next is a ready-to-request URL for the next page (or null on the last one) — in either pagination mode, you can simply follow it.

Every timestamp the envelope carries (generated_at, last_updated, and the discovery fields updated_at / imported_at) is RFC 3339 UTC with seconds precision and a Z suffix — one format everywhere: 2026-07-25T07:53:40Z.

Offset pagination

The default. ?limit= (1–1000, default 50) and ?offset= (default 0):

curl -H "Authorization: Bearer $API_KEY" \
  "https://api.canovix.ro/v1/geospatial/siruta?limit=50&offset=100"
{
  "data": [ ... ],
  "pagination": {
    "total": 16978,
    "count": 50,
    "limit": 50,
    "offset": 100,
    "has_next": true,
    "next_offset": 150
  },
  "meta": { ... },
  "links": { "self": "...", "next": ".../siruta?limit=50&offset=150" }
}

Combines freely with filters, sort, and fields. It's the right choice for UIs: page numbers, "1–50 of 16,978" labels, jumping to page N.

Skip the count when you don't need it

Computing total is the expensive part of a filtered page on millions of rows. Add ?count=false and total comes back null while has_next/next_offset still work — the cheap way to page through without caring how many rows match.

Very large datasets omit total by default

On the largest datasets the counting scan costs far more than the page it accompanies — on the 19M-row company registry it was 331 ms against 5 ms for the rows themselves. There, pagination.total is null unless you ask: pass ?count=true to get the exact figure. has_next, next_offset and links.next work either way, so paging code needs no change; only a "N of M" label does.

Always read total rather than assuming it is present — it is null whenever the count was skipped, whether by your ?count=false or by this default.

Deep offsets are expensive

offset=100000 makes the database read and discard 100,000 rows before returning any. If you're walking a large dataset front to back, use a cursor — or skip pagination entirely with a bulk export.

Cursor pagination

Keyset iteration for deep scans: page 10,000 costs the same as page 1. Start with ?cursor=start and follow pagination.next_cursor (or just links.next) until it is null:

{
  "data": [ ... ],
  "pagination": { "count": 500, "limit": 500, "has_next": true, "next_cursor": "djEuMTIzNDU2" },
  "meta": { ... },
  "links": { "self": "...", "next": "...?cursor=djEuMTIzNDU2&limit=500" }
}
curl -H "Authorization: Bearer $API_KEY" \
  "https://api.canovix.ro/v1/transport/vehicle_registry?cursor=start&limit=500"
# ...then follow pagination.next_cursor (or links.next):
curl -H "Authorization: Bearer $API_KEY" \
  "https://api.canovix.ro/v1/transport/vehicle_registry?cursor=djEuMTIzNDU2&limit=500"
def iter_rows(client, path, limit=1000, **filters):
    cursor = "start"
    while cursor is not None:
        page = client.get(
            path, params={**filters, "cursor": cursor, "limit": limit}
        ).json()
        yield from page["data"]
        cursor = page["pagination"]["next_cursor"]

for row in iter_rows(client, "/v1/transport/vehicle_registry", county="CLUJ"):
    ...
async function* iterRows(path, filters = {}, limit = 1000) {
  let cursor = "start";
  while (cursor !== null) {
    const params = new URLSearchParams({ ...filters, cursor, limit });
    const page = await (
      await fetch(`${BASE}${path}?${params}`, {
        headers: { Authorization: `Bearer ${API_KEY}` },
      })
    ).json();
    yield* page.data;
    cursor = page.pagination.next_cursor;
  }
}

for await (const row of iterRows("/v1/transport/vehicle_registry", { county: "CLUJ" })) {
  // ...
}

Rules of the cursor mode:

  • The cursor token is opaque — store and echo it, never parse or construct it.
  • Rows arrive in stable insertion order; filters and fields work as usual.
  • No total — not counting matches is what makes deep iteration cheap.
  • Cannot be combined with offset or sort (422 if you try).
  • Cursors don't survive a dataset re-ingest that replaces the table; restart from cursor=start if a long-running walk starts returning errors.

Which one — or neither?

You want to… Use
Show a paged table / page numbers in a UI offset
Know how many rows match a filter offset (pagination.total)
Page cheaply without a row count offset + ?count=false
Get the exact total on a very large dataset offset + ?count=true
Walk every row of a big dataset cursor
Load a whole dataset into a file, DataFrame, or database bulk export