Migrating a Pokémon TCG app before 1 March 2027: a checklist
A field-by-field checklist for moving a card app off a sunsetting catalogue API: IDs, pagination, prices, images, incremental sync and budget.
A great many hobby projects and a fair number of production apps read their Pokémon card data from one catalogue API that stops serving on 1 March 2027 and no longer accepts new registrations. The dated announcement is quoted in full, with a link to the source, at the top of our migration guide; read it there rather than taking our word for it. That guide is the field-by-field reference. This post is the checklist: the same work, in the order that avoids doing any of it twice.
A word on where you would be moving to. This API is built in Europe, and it shows in the data: Cardmarket quotes in EUR sit on every card next to TCGplayer quotes in USD, and the Japanese and Simplified Chinese print lines are catalogued next to the Western one, with their own set codes and card numbers. The live counts per print region are on the coverage page, read from the API when you open it. pokemontcgapi.com is an independent service and is not affiliated with, endorsed by or a reseller of Cardmarket or TCGplayer; each marketplace appears here as the stated source of a price row, the way a newspaper names the exchange a quote came from.
1. Inventory what you actually read
Before touching a single URL, grep your code for the fields you read from the old response: ids, names, numbers, images, prices, legalities, set metadata. Most apps use fewer than ten. Write them down in a file next to the adapter. That list is the migration; everything below is a row on it, and any field not on it is work you do not have to do.
Be honest about the fields you read but never display. A price block that is parsed and stored but not shown anywhere is still a dependency, and it is the kind that breaks silently when the shape changes underneath it.
2. Resolve IDs, do not rewrite them
Our card object has an id and a nullable legacy_id. The single-card lookup and the batch lookup both accept a historical alias when its prefix is not itself a canonical set code, so a stored base1-4 resolves to the card whose canonical id is bs-4. An alias is a recorded relationship, not a string-rewriting rule: do not generate new ids by replacing a prefix, because there is no guaranteed mapping for every id in an existing dataset.
# Set PTCG_API_KEY to your own key before running these examples.
curl --fail-with-body \
'https://api.pokemontcgapi.com/v1/cards/base1-4' \
--header "X-Api-Key: ${PTCG_API_KEY}"The lookup costs 1 credit and answers with the canonical id in the body. Keep the original provider id beside the canonical one in your own table; it lets you audit any match later without changing what the original record meant. Put every record the lookup did not resolve into a review queue, then compare set, collector number, print region and image before linking it by hand. A name alone is not enough: the same Pikachu has been printed dozens of times.
3. Replace page numbers with cursors
Collections return a {data, meta, links} envelope and page with limit plus an opaque cursor. Follow the complete links.next URL until it is absent; meta.has_more tells you whether another page exists. A saved page number does not translate into a cursor, so any bookmarked position from the old API is simply discarded and the walk starts again.
For the first full import, do not loop over sets. Start at GET /v1/cards?limit=250&orderBy=id and follow links.next: pages fill across set boundaries, so the whole catalogue takes fewer requests than a loop that opens a new walk for every set. Add include=translations if you need names in other languages; it keeps the plain catalogue cost. The pagination guide has the protocol in full.
4. Decide what a price means before you show it
This is the step people skip, and the one that produces the most support tickets. Every quote here carries its source, its basis (an asking price, a published guide value, or a figure we derived), its currency, condition, printing and as_of date. EUR quotes come from Cardmarket and USD quotes from TCGplayer; both can be present on the same card. The composite index_eur is a separate, nullable field with its own methodology, and it is not a renamed lowest offer or last sale.
curl --fail-with-body \
'https://api.pokemontcgapi.com/v1/cards/base1-4/prices' \
--header "X-Api-Key: ${PTCG_API_KEY}"The call costs 2 credits and separates data.index from data.quotes. Pick the dimensions your app requires, and pick them once: an EUR asking price and a USD guide value are two different facts, and averaging across currencies or across bases produces a number that means nothing. Show the currency and the as_of date next to the value. An empty quote array or a null index means no value for that response, not a value of zero.
5. Keep images and set metadata explicit
Relations are omitted unless you ask for them. include=images,set on a card request adds an images[] array, where each entry has a url, a face, a size and a locale; there is no fixed small/large pair, so choose the entry by size rather than by position. The embedded set carries code, slug, a nullable legacy_id, and the logo_url and symbol_url fields, which are nullable too.
- Pick the image size in code, by the
sizefield, and fall back to the next size down when the one you want is absent. - Keep a placeholder for a set without a logo or symbol. Some sets in the Japanese and Chinese lines never had one.
- Resolve a set by
code; a set alias such asbase1resolves to the same record, and the returnedcodeis the canonical one.
6. Sync incrementally after the first import
GET /v1/changes is an append-only log of catalogue changes with monotonic ids, and each page costs 1 credit, an empty page included. The trap is the order of operations: capture the watermark BEFORE the import, not after, or every change that lands while the import is running is lost.
- Read the feed once with
since=0&limit=1and storemeta.watermark. That is the newest change id at the moment your import begins. - Run the full import from step 3.
- Read the feed from the stored watermark, apply each page, and fetch the affected records. Commit
meta.next_sinceas your new position only after those fetches succeed; a position committed before the fetch is a change you will never see again. - Poll on a schedule. A position older than what the feed retains answers 410
SYNC_CURSOR_TOO_OLD, and the honest answer to that is a fresh import with a new watermark.
7. Budget in credits, not requests
Every route has a cost in credits, and the cost of a list depends on what you include, not on how many rows come back. The routes in this checklist cost the following; ceil means round up, and list pricing uses the requested limit.
| Route | Request shape | Credits |
|---|---|---|
GET /v1/cards/{id} | Plain catalogue, images and set included | 1 |
GET /v1/cards | Plain catalogue, any limit up to 250 | 1 |
GET /v1/cards | include=index | ceil(limit / 50) |
GET /v1/cards | include=prices | 4 × ceil(limit / 25) |
GET /v1/cards/{id}/prices | Current quotes | 2 |
GET /v1/sets, GET /v1/sets/{code} | Catalogue | 1 |
GET /v1/sealed, GET /v1/sealed/{id} | Plain catalogue | 1 |
GET /v1/changes | A page of events | 1 |
Two consequences. A full catalogue import at 250 cards a page costs one credit per page, so the page count is the budget, and adding include=index to every page multiplies it by five. The plans and their monthly credits are on the pricing page; the numbers there are the same ones the API applies.
8. Run both side by side for a week
Do not flip traffic on the day the adapter compiles. Take a sample of two hundred cards that your users actually open, read them from both APIs, and diff names, numbers and images. For prices, compare the meaning rather than the value: a EUR asking price will not equal a USD market figure, and it should not.
While the two run in parallel, log the X-Credits-Cost header on every response and watch the RateLimit-* family; together they tell you what the migration will cost in production before it costs anything. Then flip, and keep the old identifiers and the old observations until the new mapping has survived a week of real traffic.
Timeline
A trial key needs no card and is granted once: 800 credits over 30 days, enough for the inventory, the id resolution and a test import. Leave at least a month of parallel running before the deadline, which puts the start of the work no later than January 2027 for a small app and earlier for anything with a sync worker.
Where to go next
The quickstart gets a key into a first request in a few minutes, and the migration guide has the field-by-field table this checklist summarises.
Questions
- Do I need to change my card IDs?
- No. Resolve each stored id through the single-card or batch lookup, which accepts historical aliases, and keep the original id beside the canonical one the API returns. Records that do not resolve go into a review queue rather than being rewritten by hand.
- Are EUR prices available on every card?
- Where a source covers the card. A quote is present when a marketplace has observed that printing, and its as_of date says how recent the observation is; a card with no quotes answers with an empty array, not a zero. The coverage page reports price sources and catalogue counts per print region. Coverage, measured live
- Can I sync only what changed?
- Yes. The change feed at /v1/changes is an append-only log with monotonic ids. Capture its watermark before the first import, then read from that position, apply each page and commit the next position only after the affected records have been fetched.