# 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**: 2026-09-28
- **Updated**: 2026-09-28
- **Tags**: sealed, prices

Source: https://pokemontcgapi.com/blog/tracking-sealed-product-prices-with-the-api

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.

```bash
# 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](https://pokemontcgapi.com/blog/how-we-compute-eur-and-usd-card-prices). 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`

```typescript
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

| Expectation | What the API does |
| --- | --- |
| Price history on a product | No route. Build the series from the daily list with `include=index`, as above. |
| A composite index on a box | `index` is null; `index_eur` is the catalogue price. The composite is cards only. |
| `release_date` on every product | Null on many. Order by `name` or by value, or fall back to the release date of the set. |
| The same sources as cards | Fewer. Read the rows for what exists rather than assuming the card sources apply. |
| Other games | Not 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](https://pokemontcgapi.com/sealed-pokemon-product-price-api) has the list and price shapes with responses taken from production. The [price methodology](https://pokemontcgapi.com/price-methodology) says what each basis means and how the delay works. The live count of products is on the [coverage page](https://pokemontcgapi.com/coverage).
