Pagination
Cursor paging that never repeats or drops a row, and why there is no page parameter.
Collections page with an opaque cursor. page and offset do not exist: sending either returns 400 INVALID_PARAMETER, with details.did_you_mean: "cursor" (existing direct accounts may temporarily get a Warning and meta.warnings instead). This API paginates with a cursor: follow links.next of the previous page (see /docs/pagination). links.next is the complete URL; meta.has_more says whether it exists. Offset paging silently duplicates and drops rows near a page boundary, as explained 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, where an unfiltered read is the size of the whole collection, 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.
For all cards, start at GET /v1/cards?limit=250&orderBy=id. For Japanese or Simplified Chinese cards, add q=set.region:JP or q=set.region:CN. Use GET /v1/sets/{code}/cards?limit=250 for a particular set. For a complete import, a flat card list uses fewer requests than separate loops for every set; see the measured comparison and examples.
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. Card and sealed-product lists are priced per 50 rows requested, rounded up: 250 rows in one call costs five credits, the same as five calls of 50, and a limit of 12 still costs one. Set, series and artist lists cost one credit per page whatever the limit. Priced includes have their own tariffs; see credit costs.
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 the whole card catalogue nightly 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.