Skip to content
pokemontcgapi.com

apps

Build a Pokémon card collection app

Mirror the catalogue once, keep it current with a change feed, and value a binder without hammering the API on every screen.

Who this is for

You are building a collection tracker, a binder app, or an inventory tool for a shop, and you need the catalogue available offline and current.

Plan you need

A local mirror is a Growth job: the trial covers 1,000 distinct cards and Developer 30,000 a month, and the whole catalogue is past 57,000. On Growth, import all cards through /v1/cards?limit=250&orderBy=id, following links.next; a page of 250 costs five credits, including when you request images or translations, so the full import is about 1,155 credits. The quickstart gives dated card counts. Keeping a mirror current through the change feed costs one credit per poll, plus the requests to fetch changed records.

A collection app is a mirror with opinions. The user browses sets, marks what they own, and expects the app to feel instant — which means the catalogue has to be local. It also has to be current, because a set releasing and not appearing for two days is the kind of thing that gets a one-star review. Those two requirements pull in opposite directions unless you have a way to fetch only what changed.

The import is one pass through the flat card list, with pages filled across set boundaries. Keep set_code on each card so the app can still organize its catalogue by set. For only Japanese or Simplified Chinese printings, add q=set.region:JP or q=set.region:CN. Use /v1/sets/{code}/cards when importing one selected set. After the initial import, poll the change feed, fetch affected records and save the checkpoint after applying them.

Keep a card catalogue in sync without a full daily import

After the first import, GET /v1/changes tells your application which entities changed. Read every page, fetch the records you need, and save the returned cursor only after the changes are applied. The feed costs 1 credit per call, including an empty one; fetching the records costs their normal credits. Capture meta.watermark before the initial import and consume changes from there, so anything that changes during the import is replayed instead of lost. If your cursor is older than the retained history the feed answers 410 and you need a fresh import.

The trial covers this: 800 credits after confirming your address, and the feed is not restricted to paid plans. Endpoint reference · Get a key

The code

Capture the change feed watermark before importing, page the flat card list, then replay changes after that watermark. This also catches mutations made during the import. For the measured request counts, see /docs/quickstart.

import.js
const BASE = "https://api.pokemontcgapi.com";
const KEY = process.env.PTCG_API_KEY;

async function api(url) {
  const res = await fetch(url.startsWith("http") ? url : BASE + url, {
    headers: { "X-Api-Key": KEY },
  });
  if (!res.ok) throw new Error(res.status + " " + (await res.text()));
  return res.json();
}

export async function importCatalogue(db) {
  const baseline = await api("/v1/changes?since=0&limit=1");
  const watermark = baseline.meta.watermark;
  let next =
    "/v1/cards?limit=250&orderBy=id&include=images&select=" +
    "id,name,set_code,number,number_sort,rarity,supertype,subtypes,types,hp,artist_name";

  while (next) {
    const page = await api(next);
    await db.upsertCards(page.data);
    next = page.links?.next ?? null;
  }
  await db.setSyncCursor(watermark);
  await catchUp(db);
}

// Incremental sync. One integer is a complete resume position.
export async function catchUp(db) {
  let since = await db.getSyncCursor();

  for (;;) {
    const page = await api(
      "/v1/changes?since=" + since + "&kind=CARD,SET,PRICE,IMAGE&limit=250",
    );
    if (page.data.length === 0) break;

    for (const change of page.data) {
      // Process strictly in id order; changed_at is not unique.
      if (change.op === "DELETE") await db.remove(change.kind, change.entity_id);
      else await db.refetch(change.kind, change.entity_id);
    }

    // meta.next_since is the cursor for the next call; meta.behind says how
    // far you still are from the newest entry.
    since = page.meta.next_since;
    await db.setSyncCursor(since);
    if (!page.meta.has_more) break;
  }
}

Mirror the catalogue, do not proxy it

Every screen in a collection app is a query over the catalogue: cards in a set, cards by type, cards this user owns, cards missing from a set. Answering those from a remote API means a network round trip per scroll, which is both slow and expensive. Import once into your own store and every one of those becomes a local query you can index however your UI needs.

Import through /v1/cards?limit=250&orderBy=id and keep set_code in your own schema. The flat list uses fewer requests for the complete catalogue; a set endpoint is useful when only that set is needed. Ask for the fields your app renders and request images or translations explicitly. Browse /v1/sets separately if you also need set metadata.

Stay current with the change feed

The change feed is an append-only stream of every catalogue mutation, written by database triggers rather than by application code, so nothing can be modified without appearing in it. Each entry carries a monotonic id, the kind of entity, its id, and whether it was created, updated or deleted. You store one integer — the highest id you have applied — and that integer is a complete description of how far along you are.

Capture meta.watermark before importing and replay from it once the import finishes. Save meta.next_since only after applying the page and fetching its affected records. Make repeated events safe to apply again and handle deletions. If the checkpoint expires and the API returns 410 SYNC_CURSOR_TOO_OLD, start a fresh import with a new baseline. The migration guide at /docs/migrate-from-pokemontcg-io explains the protocol.

Value a binder without lying about it

Collection value is the feature users open the app for and the one most likely to make them angry. Two rules keep it honest. Show the date: a total is a snapshot of a specific day, and labelling it as such prevents the assumption that it is live. Show the method: if the number comes from a derived index, say so, and let a user tap through to see the sample size behind it.

Handle unpriced cards explicitly. Some printings have no servable observation — too obscure, too new, or a source that was withdrawn. Silently treating those as zero produces a total that is wrong in a direction users notice. Listing them as unpriced produces a total that is smaller and true, plus a list the user can act on.

What usually goes wrong

Not a disclaimer list: these are the failure modes that cost people time on this specific integration.

Re-importing everything on a schedule
A nightly full import is hundreds of requests to discover that a dozen rows changed. Import once, then follow the change feed. It is cheaper for you and it is the difference between a sync that takes seconds and one that takes an hour.
Rebuilding the next-page URL by hand
Follow links.next verbatim. The cursor encodes the sort order, so a hand-assembled URL with a different orderBy is rejected rather than quietly returning a page from a different ordering — which is the failure you want, but only if you were not expecting it.
Caching prices as long as cards
Card data is stable for months; price points publish daily, sometimes carrying the latest known figure. One cache policy for both means either stale valuations or pointless catalogue refetches. Keep them in separate tables with separate refresh rules.

Endpoints used

  • GET/v1/cardsImport all cards with limit=250&orderBy=id; add q=set.region:JP or q=set.region:CN for one print region.
  • GET/v1/setsRead set metadata when the app needs totals, release dates and print region.
  • GET/v1/sets/{code}/cardsImport a selected set when the app only needs that set.
  • GET/v1/changesAppend-only mutation feed: resume from the cursor you stored.
  • GET/v1/cards/batchHydrate up to 100 owned card ids per request.
  • GET/v1/cards/{id}/pricesThe composite index and every quote behind it, for the valuation screen.

Related