Pagination
Hatched ships two pagination envelopes — cursor (canonical for new endpoints) and offset (legacy). This page documents both shapes, the SDK helpers that walk them, and how to add cursor pagination to a server-side route.
Every Hatched list endpoint returns one of two envelopes. New endpoints use cursor pagination; older endpoints still return the offset shape. The SDK has helpers for both — application code doesn't need to care which shape it's reading.
Cursor pagination (canonical)
The canonical envelope — emitted by the server-side cursorPaginate
helper, the shape new endpoints should adopt:
{
"data": [ /* page rows */ ],
"pagination": {
"nextCursor": "eyJrIjoiMjAyNi0wNS0yNVQxODoxMjozNFoiLCJpZCI6IjkyZS4uLn0",
"hasMore": true,
"limit": 50
}
}The cursor is opaque — treat it as a JWT-ish blob. It encodes the sort key + tie-breaker id of the last row in the current page, so the next call returns the rows immediately after it.
The cursor resources shipped today do not yet return this envelope.
Each exposes the cursor at the top level on its own data key:
notifications.list → { notifications, nextCursor, unreadCount, pausedUntil },
feed.teamEvents.list → { events, next_cursor } (snake_case),
webhooks.deliveries → { data, nextCursor }. The { data, pagination }
envelope is what cursorPaginate produces for new server routes; until a
resource is wired to it, adapt the resource's real shape into the
paginateCursor fetcher (see Walking pages from the SDK).
Why cursor. Stable under concurrent writes (no missed/duplicated
rows when new data lands between page calls), supports unbounded streams
(no upper bound from total reaching memory limits), and aligns with
keyset indexing so latency stays flat as data grows.
Request
Pass cursor and limit query params:
GET /api/v1/widget/notifications?cursor=eyJrIjoiMjAy...&limit=100cursor(optional) — omit on the first request; pass the previous response'snextCursor(the canonical envelope nests it underpagination.nextCursor) on subsequent requests.limit(optional) — page size, clamped server-side. ThecursorPaginatehelper defaults to 50 and clamps to[1, 200].
Response invariants
data.lengthis always≤ pagination.limit.pagination.hasMore === (pagination.nextCursor !== null).- When
nextCursor === null, the cursor chain is exhausted.
Offset pagination (legacy)
The envelope:
{
"data": [ /* page rows */ ],
"meta": { "total": 1284, "page": 1, "limit": 50 }
}Why we still ship it. Several endpoints already exposed this shape publicly. The contract is preserved; new endpoints just don't add to the surface.
Request
Pass page and limit:
GET /api/v1/buddies?page=2&limit=100page(1-indexed, default 1).limit(default 20, max 100). Alimitabove 100 is rejected with422 validation_failed.
Response invariants
meta.totalis the authoritative termination signal — the SDK paginator stops when(page-1) * limit + data.length >= total. Don't infer "this is the last page" fromdata.length < limit: filtered endpoints (status filter, soft deletes, audience scoping) routinely return a short page in the middle of the stream.data.lengthmay be0on the final page (whentotalis an exact multiple oflimit).
Walking pages from the SDK
The SDK exposes two pairs of helpers. Each returns an
AsyncIterableIterator<T> so you can for await … break to stop early
without fetching the next page.
Cursor
paginateCursor / collectCursor expect the canonical
{ data, pagination: { nextCursor } } envelope. Today's cursor resources
return the cursor at the top level on their own data key, so map the
resource's real shape into that envelope inside the fetcher:
import { paginateCursor, collectCursor } from '@hatched/sdk-js';
for await (const note of paginateCursor((cursor) =>
hatched.notifications.list({ cursor, limit: 100 }).then((page) => ({
data: page.notifications,
pagination: { nextCursor: page.nextCursor, hasMore: page.nextCursor !== null, limit: 100 },
})),
)) {
if (note.id === target) break;
}
// or buffer everything
const all = await collectCursor((cursor) =>
hatched.notifications.list({ cursor, limit: 100 }).then((page) => ({
data: page.notifications,
pagination: { nextCursor: page.nextCursor, hasMore: page.nextCursor !== null, limit: 100 },
})),
);Offset
import { paginate, collect } from '@hatched/sdk-js';
for await (const buddy of paginate(
(page) => hatched.buddies.list({ page, limit: 100, status: 'active' }),
)) {
console.log(buddy.id);
}
const active = await collect(
(page) => hatched.buddies.list({ page, limit: 100, status: 'active' }),
);Both pairs accept a maxPages runaway guard and an AbortSignal:
const ac = new AbortController();
setTimeout(() => ac.abort(), 5_000);
const bounded = await collectCursor(
(cursor) =>
hatched.notifications.list({ cursor, signal: ac.signal }).then((page) => ({
data: page.notifications,
pagination: { nextCursor: page.nextCursor, hasMore: page.nextCursor !== null, limit: 50 },
})),
{ signal: ac.signal, maxPages: 20 },
);Server-side: adding cursor pagination to a new endpoint
For new endpoints in apps/api, use the shared cursor helper instead
of writing keyset logic by hand:
import { cursorPaginate } from '@/common/pagination/cursor';
@Get('items')
async list(@Query() query: CursorListItemsDto) {
const qb = this.itemsRepo
.createQueryBuilder('item')
.where('item.customer_id = :customerId', { customerId });
return cursorPaginate({
qb,
sortColumn: 'item.created_at',
tieBreakerColumn: 'item.id',
direction: 'DESC',
limit: query.limit,
cursor: query.cursor,
});
}The helper produces the canonical envelope, applies a keyset filter so
the SQL plan stays on an index, and uses the row's createdAt + id
as the cursor tuple by default. Override toCursor for endpoints sorted
by a different column.
Choosing a sort key
The sort column must be monotonic within the result set — usually
created_at. The tie-breaker is always a unique id (UUID). With those
two together, two rows can never tie, so the keyset predicate is exact
and the cursor never drops or duplicates rows.
When to migrate an offset endpoint to cursor
Existing offset endpoints stay as-is. Migrate when one of these is true:
- The list grows unbounded (events, operations, ledger entries) and
OFFSET N LIMIT Mstarts to scan a problematic number of pages. - Consumers report missed/duplicated rows under concurrent writes.
- You need to expose a streaming API on top of the same data.
When you migrate: add the cursor query param alongside page; serve
the cursor envelope when cursor is present, the offset envelope
otherwise. Consumers pick the matching SDK helper — paginateCursor /
collectCursor for the new shape, paginate / collect for legacy
offset. There is no envelope autodetection on the client; using the wrong
helper either misses rows or loops forever, so the two pairs are explicit
by design.
Companion docs
- Listing endpoints — every list endpoint with its current pagination shape.
- SDK reference —
paginate,paginateCursor,collect,collectCursor.