Skip to content
← BlogDeveloper

Cursor pagination explained: why the Norbelys API doesn't use page numbers

Page numbers silently skip or double-serve rows when data changes mid-list. Why the Norbelys API paginates on (createdAt, id) instead, with a working example.

By Gabriel Lara, Developer Relations, Norbelys

Founder-reviewed ·How we research and correct articles

If you’ve built against enough REST APIs, you’ve probably reached for ?page=2&limit=50 without thinking twice about it. It works fine in a demo. It breaks in production the moment someone else is writing to the same table while you’re reading it — which, on a platform where imports, enrollments and inbound replies are constantly mutating the people list, is not a rare edge case. It’s every request.

The Norbelys API doesn’t offer page numbers at all. Every list endpoint — GET /v1/people, GET /v1/messages, GET /v1/jobs, all of it — takes an opaque cursor and returns one to fetch the next page. This post explains the concrete bug that forces the choice, and shows you how to walk a full people list end to end.

The bug page numbers have

Say you’re exporting all 12,000 contacts in an organization, 50 per page, sorted newest-first. You fetch page 1, get rows 1–50. While you’re processing that page, three new contacts get imported. You fetch page 2 — but “page 2” means “skip 50, take 50,” and skip/offset is defined against the table’s current state, not the state it had when you started.

Two new rows landed above row 1. Everything shifted down by however many rows were inserted. Depending on exactly when the insert happened relative to your two requests, you either:

  • See duplicates — a row that was on page 1 last time reappears on page 2, because it got pushed down past your offset boundary.
  • Skip rows entirely — a row that would have been on page 2 gets pushed onto page 1 instead, and you never fetch page 1 again, so you never see it.

Neither failure throws an error. Your export just quietly comes back wrong, and you find out later when a customer asks why a contact they know exists isn’t in your sync. This isn’t a Norbelys-specific quirk — it’s inherent to OFFSET-based pagination against any table under concurrent writes. Deletes make it worse: a soft-archived row (see how DELETE differs from GDPR erasure) disappearing from the default list view shifts every offset below it too.

The fix: a keyset cursor over (createdAt, id)

Cursor (a.k.a. keyset) pagination doesn’t ask “give me rows 51–100.” It asks “give me the rows that come after this specific row I already saw.” The cursor Norbelys returns encodes the last row’s (createdAt, id) pair — the id breaks ties when two rows share a millisecond timestamp, since cuid2s aren’t ordered but are always unique.

Because the next page is defined relative to a row you actually saw, not a count that can drift, new inserts anywhere in the table can’t shift your position. A row inserted after you started exporting simply doesn’t exist yet from the cursor’s point of view — you’ll see it on your next full sync, not skip or duplicate it on this one.

GET /v1/people?limit=50
  → { data: [...50 people], nextCursor: "eyJjcmVhdGVkQXQiOi4uLn0" }

GET /v1/people?limit=50&cursor=eyJjcmVhdGVkQXQiOi4uLn0
  → { data: [...next 50], nextCursor: "..." }

GET /v1/people?limit=50&cursor=...
  → { data: [...last few], nextCursor: null }   // no more pages

The cursor is opaque — treat it as a token, not something you parse or construct yourself. Its internal shape can change between API versions; your integration should only ever pass back the nextCursor value the API already gave you.

Walking a full list, end to end

Here’s a realistic loop that drains the entire people collection for an organization, one page at a time, stopping when nextCursor comes back null:

paginate-people.sh
$ curl -s "https://api.norbelys.com/v1/people?limit=100" \$ -H "Authorization: Bearer $NORBELYS_API_KEY"{ "data": [ { "id": "con_9f2k...", "givenName": "Priya", ... }, ... ], "nextCursor": "eyJjcmVhdGVkQXQiOjE3NTM2NDgwMDAwMDAsImlkIjoiY29uXzlmMmsifQ"}# feed nextCursor straight back in — don't decode or rebuild it$ curl -s "https://api.norbelys.com/v1/people?limit=100&cursor=eyJjcmVhdGVkQXQiOjE3NTM2NDgwMDAwMDAsImlkIjoiY29uXzlmMmsifQ" \$ -H "Authorization: Bearer $NORBELYS_API_KEY"

The equivalent loop in JavaScript:

async function* listAllPeople(apiKey) {
  let cursor;
  do {
    const url = new URL("https://api.norbelys.com/v1/people");
    url.searchParams.set("limit", "100");
    if (cursor) url.searchParams.set("cursor", cursor);

    const res = await fetch(url, {
      headers: { Authorization: `Bearer ${apiKey}` },
    });
    const { data, nextCursor } = await res.json();

    yield* data;
    cursor = nextCursor;
  } while (cursor);
}

for await (const person of listAllPeople(process.env.NORBELYS_API_KEY)) {
  console.log(person.id, person.email);
}

Three habits that keep this correct:

  • Treat nextCursor as opaque. Store it, pass it back, never inspect it.
  • null means done. Don’t loop on an empty data array as your stop condition — a page can legitimately be the last non-empty one, with nextCursor: null telling you so explicitly.
  • Don’t try to resume “from page 4.” There’s no page 4. If a sync job dies partway through, persist the last cursor you successfully processed and resume from there — that’s the entire recovery story, and it’s simpler than offset math ever was.

Why this matters more on a shared, multi-tenant table

Norbelys is a shared, multi-tenant platform, which means the underlying data sees far more concurrent write traffic than a single tenant’s own activity would suggest — imports, bulk jobs, warmup sends and inbound replies from other organizations are landing at the same time yours are. Offset drift isn’t a once-a-quarter fluke under that kind of load; it’s the default outcome of OFFSET-based pagination, and cursor pagination is what makes list endpoints safe to build automation on top of.

See the developer portal for the full OpenAPI spec covering every paginated endpoint, and audience management for how the people resource fits into list building and segmentation.

Cursor pagination FAQ

Can I jump straight to page 5 with cursor pagination?

No — cursors only support sequential forward paging from wherever you last stopped. If you need arbitrary jump-to-page navigation for a UI, that's a sign you want a smaller, filtered result set (search or filter parameters) rather than paging deep into a large unfiltered list.

Does a smaller limit make pagination more or less reliable?

Reliability doesn't depend on limit — cursor pagination is correct at any page size because each page is defined relative to the last row seen, not a row count. A smaller limit just means more round trips.

What happens if I reuse an old cursor after a lot of time has passed?

It still works. The cursor encodes a position in the (createdAt, id) ordering, and that position doesn't expire — rows created or archived since then are simply included or excluded correctly relative to it, the same as any fresh cursor.

Is cursor pagination unique to the people endpoint?

No — it's uniform across every list endpoint in the API, including messages, jobs, senders, programs and suppressions. Learning the pattern once on people transfers directly to every other resource.