Skip to content
pokemontcgapi.com

prices

Build a Pokémon card price tracker

Watch a list of cards, store a daily price series with its provenance, and alert when a card moves more than a threshold.

Who this is for

You are building a tracker, a portfolio dashboard, or an alerting bot, and you need numbers you can defend when a user asks where they came from.

Plan you need

The free tier covers a watchlist of a few hundred cards polled once a day. Historical series beyond seven days needs a paid plan.

A price tracker is three jobs wearing one name: resolve a card the user typed into a stable identifier, record what that card was worth on a given day, and decide when a change is worth interrupting someone about. The first job is a search problem, the second is a storage problem, and only the third is about prices. Most trackers fail on the first two and then blame the data.

The shape below solves all three against real endpoints. Cards resolve to ids of the form set code, dash, collector number — base1-4 is the Base Set Charizard — so once a user has picked a card you never have to match on names again. Prices arrive as objects, not floats: each one names its source, whether it reflects a completed sale or an open listing, how many observations are behind it, and the day it was captured. That is what lets you write "based on 37 sales, as of yesterday" under a number instead of hoping nobody asks.

The code

A single pass over a watchlist: batch-resolve the cards, pull current prices, compare against what you stored yesterday, and emit alerts. It runs in one process and costs one request per card per day.

track.js
const KEY = process.env.PTCG_API_KEY;
const BASE = "https://api.pokemontcgapi.com";
const WATCHLIST = ["base1-4", "sv3-223", "swsh45-74"];
const ALERT_THRESHOLD = 0.05; // 5%

async function api(path) {
  const res = await fetch(BASE + path, { headers: { "X-Api-Key": KEY } });
  if (!res.ok) {
    const { error } = await res.json();
    throw new Error(res.status + " " + error.code + ": " + error.message);
  }
  return res.json();
}

// One request for the whole watchlist. select trims the payload to what we store.
async function names(ids) {
  const q = "ids=" + ids.join(",") + "&select=id,name,set_name,rarity";
  const { data } = await api("/v1/cards/batch?" + q);
  return new Map(data.map((card) => [card.id, card]));
}

// One request per card. Prefer the derived index: it is one series with a
// stated method, instead of a different vendor's number every other day.
async function currentPrice(id) {
  const { data } = await api("/v1/cards/" + id + "/prices?currency=EUR");
  return data.find((p) => p.variant === "INDEX") ?? data[0] ?? null;
}

export async function runOnce(store) {
  const cards = await names(WATCHLIST);

  for (const id of WATCHLIST) {
    const price = await currentPrice(id);
    if (!price) continue;

    // Never overwrite a day. as_of is the observation date, not "now".
    await store.upsert({
      card_id: id,
      as_of: price.as_of,
      price: price.price,
      currency: price.currency,
      basis: price.basis,
      sample_n: price.sample_n,
      provenance: price.provenance,
    });

    const previous = await store.previousBefore(id, price.as_of);
    if (!previous) continue;

    const change = (price.price - previous.price) / previous.price;
    if (Math.abs(change) >= ALERT_THRESHOLD) {
      await alert({
        card: cards.get(id),
        from: previous.price,
        to: price.price,
        pct: Math.round(change * 1000) / 10,
        basis: price.basis,
        sample_n: price.sample_n,
        as_of: price.as_of,
      });
    }
  }
}

Resolve once, store the id forever

Users type "charizard base set", not an identifier. Resolve that with a search — a query grammar with fielded terms, so name:charizard set.id:base1 narrows to one printing — and then store the id you got back. Names are not unique across 25 years of printings: there are dozens of Charizards, several within the same set, and a tracker that re-matches on names every night will silently start tracking a different card after a reprint.

The id is guessable from the card in your hand, which makes it a good primary key for your own tables. There is also an alternate string id in the same shape for catalogues that already stored one, and both resolve on the lookup endpoint, so importing an existing watchlist does not require a matching pass.

Store the observation, not the number

The temptation is a table of card_id, date, price. Resist it. Store source, basis, sample_n and provenance alongside, because those are what make a number defensible later. Six months from now, when a user disputes a valuation, the difference between "we said 412" and "412 was the index value on 16 August, derived from 64 observations" is the difference between an apology and an answer.

Key your table on card, source, variant and as_of, and upsert rather than insert. The observation date is the day the data was captured, not the day you polled, so a job that runs twice writes one row. That also makes backfilling safe: you can replay months of history into the same table without producing duplicates.

Alert on movement, not on noise

A naive tracker alerts whenever the number changes, which is every day, which trains users to ignore it. Two filters fix most of that. First, compare like with like: never diff a completed-sale figure against a published guide value, because the gap between them is a property of the sources, not of the market. Filtering to one source and one variant gives you a series where a change means something.

Second, respect the sample size. A five percent move backed by three observations is noise; the same move backed by sixty is a signal. Because sample_n is on every price object, that filter is one condition rather than a research project. For the alert copy itself, quote the basis and the sample: "up 7% on 41 recorded sales" reads as a fact, and "up 7%" reads as a guess.

What usually goes wrong

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

Polling faster than the data changes
Observations refresh daily. Polling every five minutes spends your quota to receive the same numbers 288 times. Poll once a day, send the ETag back as If-None-Match, and an unchanged response answers 304 without consuming quota at all.
Mixing currencies in one series
Ask for one currency and store it. A series that silently switches between EUR and USD produces a chart with a step in it that no market event explains, and it is always noticed by the one user who screenshots it.
Interpolating gaps
Days with no observation are omitted rather than filled in. Draw the gap as a gap. A straight line through it is a claim about a day nobody measured, and it will be wrong exactly when it matters.

Endpoints used

  • GET/v1/cardsResolve what a user typed into a card id, with a fielded query.
  • GET/v1/cards/batchHydrate up to 100 watchlist ids in a single request.
  • GET/v1/cards/{id}/pricesCurrent prices for one card, one object per source and variant.
  • GET/v1/cards/{id}/prices/historyBackfill a daily series instead of waiting months to accumulate one.

Related