Pagination
Cursor paging that never repeats or drops a row, and why there is no page parameter.
Collections page with an opaque cursor. There is no page and no offset — not as a simplification, but because offset paging silently duplicates and drops rows near a page boundary, which is the section below.
The envelope
{
"data": [ /* up to `limit` rows */ ],
"meta": {
"limit": 250,
"count": 250,
"has_more": true
},
"links": {
"next": "https://api.pokemontcgapi.com/v1/cards?limit=250&q=set.code%3As4a&cursor=eyJrIjpbIjI1MCJdLCJpZCI6InM0YS0yNTAifQ"
}
}| Field | Meaning |
|---|---|
meta.limit | The page size actually applied, after clamping. |
meta.count | Rows in this page. |
meta.total_count | Rows matching the filter. Present on sets and artists — 615 and 399 respectively on an unfiltered read of 2026-08-27 — and absent on card queries, because counting a filtered card query exactly costs more than the query. Do not treat its absence as zero. |
meta.has_more | Whether another page exists. Equivalent to links.next being present. |
links.next | The complete URL of the next page, filters and sort included. Follow it verbatim. |
The loop
Follow links.next until it is gone. That is the whole protocol.
export async function* pages(start: string, key: string) {
let next: string | null = start;
while (next) {
const res = await fetch(next, { headers: { "X-Api-Key": key } });
if (!res.ok) throw new Error((await res.json()).error.code);
const page = await res.json();
yield page.data;
// Never rebuild the URL: the cursor is signed with the sort order,
// and a hand-assembled one is rejected rather than silently skewed.
next = page.links?.next ?? null;
}
}Why keyset, not offset
Two reasons, and the second one is the one that actually costs you data.
Cost
With OFFSET, the database still reads and discards every preceding row, so page 400 costs four hundred times page 1. That is why offset-paged APIs end up capping the offset at some arbitrary depth and telling you to filter harder. With a keyset cursor the query jumps straight into the index: page 1 and page 10,000 cost the same, and there is nothing to cap.
Correctness
This is the real one. An ORDER BY that is not unique leaves ties in undefined order, and the database is free to return them differently between two queries. Rows near a page boundary then appear twice or not at all — with no error, no warning, and nothing in your logs.
Every sort here ends with id as a tiebreaker — whether you asked for it or not — so the order is total and the boundary is exact. Pagination never repeats or drops a row, at any page depth.
The check that matters
Page a set that crosses the 250 boundary and count distinct ids. On 2026-08-27, q=set.code:s4a (Shiny Star V) paged in two requests and returned 330 rows and 330 distinct ids. If the two numbers agree, paging is sound. It is a two-minute test and it is worth running against any catalogue API you rely on, including this one.
Sorting
orderBy takes up to four comma-separated keys; a leading - reverses one.
orderBy=-release_date,name- The tiebreaker (
idfor cards,codefor sets,slugfor artists) is appended automatically. Naming it explicitly only sets its direction; it never becomes a fifth key. - Nulls sort last ascending and first descending, and the cursor predicate follows the same convention — so a nullable sort key pages correctly instead of stalling on the null group.
- An unknown key is
400 INVALID_PARAMETERwith the valid list indetails.valid_fields. For cards on 2026-08-27 that list wasid,name,number,rarity,hp,index_eur,last_price_at,updated_at,created_at,release_date,set,artist.
Cursor rules
- Opaque. It is base64url JSON today; treat it as bytes. Its contents are not a contract.
- Bound to the sort. It carries a signature of
orderBy. Reusing it under a different sort returns400 INVALID_CURSORrather than silently skipping rows. - Not a bookmark. It encodes a position in an ordering, not a snapshot. Rows inserted before your position while you page will not appear; that is inherent to keyset paging, not a defect.
- Not durable. Do not persist one for tomorrow. To resume across days, record the last
idyou imported and re-query from there, or re-read byupdated_at.
Choosing a page size
limit runs from 1 to 250 and defaults to 50. Because credits are charged per request and not per row, a bigger page is strictly cheaper: 250 rows in one call costs one credit, the same 250 rows in five calls costs five.
Above 250 you get 400 LIMIT_EXCEEDED, with details.max and details.requested so the ceiling is machine-readable rather than something you learn from a message.
Filter before you page
Walking all 52,337 cards nightly (measured 2026-08-27) is the most expensive way to stay current. Every card carries updated_at, and orderBy=-updated_at is accepted — so re-read what moved instead of re-reading everything.