Skip to content
pokemontcgapi.com
Documentation

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

page.json
{
  "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"
  }
}
FieldMeaning
meta.limitThe page size actually applied, after clamping.
meta.countRows in this page.
meta.total_countRows 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_moreWhether another page exists. Equivalent to links.next being present.
links.nextThe 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.

pages.ts
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.txt
orderBy=-release_date,name
  • The tiebreaker (id for cards, code for sets, slug for 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_PARAMETER with the valid list in details.valid_fields. For cards on 2026-08-27 that list was id, 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 returns 400 INVALID_CURSOR rather 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 id you imported and re-query from there, or re-read by updated_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.

view this page as markdown