Skip to content
pokemontcgapi.com

Blog

Tracking sealed product prices with the API

Booster boxes, ETBs and tins as objects with their own price rows: how to list them, read the rows, build a daily value series with no history route.

Published sealedprices

A lot of the money in this hobby sits in unopened product, and card databases usually treat it as an afterthought: a string in a notes field, if anything. Someone tracking a shelf of booster boxes ends up scraping a marketplace by hand once a week. This article is how the API models sealed product, how its price rows differ from a card's, and how to keep a daily value series even though there is no history route for it yet. The last part is the one people ask about, so it gets the code.

A product is an object

A sealed product has an id, which is its sku: a readable, stable string such as evolving-skies-booster-box, the same value on the list, on the single product and on its price route. It has a kind (BOOSTER_BOX, ETB, TIN, BLISTER, COLLECTION and a couple of dozen more; read the values off the list rather than hardcoding them), the set_code of the set it belongs to, a pack_count where that makes sense, an image, and languages, which lists the languages the product physically exists in. That last field is not the locale of its name; a French Evolving Skies box is the same product printed in French, and the distinction comes back when we get to prices.

The list takes q (free text on the name, or an exact sku), set, kind and lang, and meta.total_count is always present: the sealed catalogue is small enough that counting it costs an index scan, so you get the number instead of guessing from has_more. A list costs 1 credit.

# Every booster box of one set, catalogue fields only (1 credit).
curl -s -H "X-Api-Key: $PTCG_API_KEY" \
  "https://api.pokemontcgapi.com/v1/sealed?set=evs&kind=BOOSTER_BOX"

# The same list with one comparable EUR figure per row (+1 credit per 50 products).
curl -s -H "X-Api-Key: $PTCG_API_KEY" \
  "https://api.pokemontcgapi.com/v1/sealed?set=evs&kind=BOOSTER_BOX&include=index"

# The current price rows of one product (2 credits).
curl -s -H "X-Api-Key: $PTCG_API_KEY" \
  "https://api.pokemontcgapi.com/v1/sealed/evolving-skies-booster-box/prices"

Reading the price rows

GET /v1/sealed/{id}/prices answers in the same envelope as the card route: index, quotes, meta.plan_notes, so one client handles both. Each quote is a row with source, variant, basis, currency, date and provenance, the rule the whole API follows. What changes is what a box does not have: condition, printing and grading are always null, and locale is the language the box was printed in. The call costs 2 credits.

That locale is the field to read first. On Cardmarket an English box and a French box of the same set are two listings with two prices, often far apart, and the API serves them as two rows rather than averaging them into a number that matches neither. Match the row to what you actually hold. Cardmarket rows carry the lowest listing and the one, seven and thirty-day averages per printing language; TCGplayer and community submissions add rows where they cover the product; all of it under the same allow-list and the same delay as cards.

index is null on every sealed product today. The composite index is computed for cards only, and the euro figure on the product itself, index_eur, is the catalogue price rather than a composite. Rows whose locale is not English are served from the Developer plan up; on the trial the response lists them under meta.withheld as non_english_locales, so a shorter table is the plan speaking, not missing data.

A value series without a history route

There is no /prices/history on sealed products. What there is: include=index on the list, which puts index_eur and last_price_at on every row for one extra credit per fifty products requested. Ask for it once a day and append what comes back, and you have a value series per sku, at a cost that stays flat however many products you follow. With the TypeScript SDK the whole job is a short script; the page iterator follows links.next for you.

sealed-snapshot.ts
import { appendFile } from 'node:fs/promises';
import { PokemonTcgApi } from '@pokemontcgapi/sdk';

// One line per product per day: sku, catalogue EUR figure, when it was last
// observed. Run it from cron; the file is your history.
const api = new PokemonTcgApi({ apiKey: process.env.PTCG_API_KEY });
const today = new Date().toISOString().slice(0, 10);

const page = await api.sealed.list({ kind: 'BOOSTER_BOX', include: ['index'], limit: 250 });
for await (const box of page) {
  if (box.index_eur == null) continue;
  const row = { day: today, sku: box.sku, set: box.set_code, eur: box.index_eur, seen: box.last_price_at };
  await appendFile('sealed-index.jsonl', JSON.stringify(row) + '\n');
}

Two details make the series honest. Keep last_price_at next to the figure: a product whose observation date stops moving is a product no source is quoting any more, and its line should say so rather than draw a flat segment. And write the run date, not the observation date, as the row key, so a day with no change is still a row and gaps in the file mean the script did not run.

Keeping a mirror current

The change feed reports sealed products too. GET /v1/changes?kind=SEALED returns every product row inserted, updated or deleted since a position you keep, written by database triggers, so a mirror of the sealed catalogue is one small call a day after the first import. kind=PRICE covers the observations, and a set page in your app can show the cards, the products that contain them and what each is worth from the same set_code.

What is not there

The five things a client will look for on sealed product, and the answer today
ExpectationWhat the API does
Price history on a productNo route. Build the series from the daily list with include=index, as above.
A composite index on a boxindex is null; index_eur is the catalogue price. The composite is cards only.
release_date on every productNull on many. Order by name or by value, or fall back to the release date of the set.
The same sources as cardsFewer. Read the rows for what exists rather than assuming the card sources apply.
Other gamesNot served. The sealed catalogue is filtered to Pokémon TCG products.

The reason for the missing dates is worth saying. The sealed catalogue comes from a collection app we run, where it was assembled by hand for people cataloguing their shelves; that is why the skus read like names and why a case is listed beside the box it contains, and it is also why nobody typed a release date for every tin. We would rather serve a null than a date copied from the set and presented as the product's own.

Practical rules

  1. Key everything on sku. It is stable and readable, and it opens the product, the price route and the change feed alike.
  2. Read locale before the amount, and show the row that matches the language of the box you hold.
  3. For a series, snapshot index_eur from the list once a day with include=index; do not call the price route per product.
  4. Keep last_price_at in the series, and treat a date that stops moving as a gap in coverage, not a stable price.
  5. After the first import, follow /v1/changes?kind=SEALED,PRICE instead of re-reading the list.

Where to go next

The sealed product page has the list and price shapes with responses taken from production. The price methodology says what each basis means and how the delay works. The live count of products is on the coverage page.

Questions

Is there an API for booster box prices?
Yes. GET /v1/sealed lists sealed products, filterable by set and kind, and GET /v1/sealed/{id}/prices returns the current price rows for one product, each with its source, basis, currency, printing language and date. Sealed product price API
How do I get the price history of a sealed product?
There is no history route on sealed products today. Ask the list for include=index once a day and store index_eur and last_price_at per sku; the series is yours to keep, at one extra credit per fifty products per run.
Is sealed product on the free trial?
Yes, both the catalogue and the current prices. Rows in languages other than English are withheld on the trial and listed under meta.withheld, and served from the Developer plan up. Get a trial key

Keep reading

view this article as markdown