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:
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
nextCursoras opaque. Store it, pass it back, never inspect it. nullmeans done. Don’t loop on an emptydataarray as your stop condition — a page can legitimately be the last non-empty one, withnextCursor: nulltelling 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.