# List sealed products

> Booster boxes, elite trainer boxes, tins, blisters and collections, with names in six locales and images.

- **Endpoint**: `GET /v1/sealed`
- **Cost**: 1 credit per 50 products requested (the limit), rounded up; include=index costs the same
- **Minimum plan**: free
- **Cache-Control**: `public, max-age=60, s-maxage=300, stale-while-revalidate=600`

Source: https://pokemontcgapi.com/docs/api/sealed/list

No key yet? One call gets you one: `POST /v1/accounts/free` with an `Idempotency-Key` header and your email address returns a working key in the response. Keep the original Idempotency-Key and body for response replay for up to 24 hours, under the conditions in the quickstart. The trial is 800 credits over 30 days once the address is confirmed, no card; paid plans start at 29 € a month. See the [quickstart](https://pokemontcgapi.com/docs/quickstart) and the [pricing](https://pokemontcgapi.com/pricing).

**Current quotes are at `GET /v1/sealed/{id}/prices` (2 credits).** Sealed lists accept `include=index` for `index_eur` and `last_price_at` only; `include=prices` returns `400 INVALID_INCLUDE` with `details.prices_route` pointing to the quote endpoint.

`id` on a sealed product is its `sku` — a readable, stable string such as `evolving-skies-booster-box` — and the same value opens `/v1/sealed/{id}` and `/v1/sealed/{id}/prices`. `languages` lists the languages the product physically exists in, which is a different thing from the locales of its name.

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

> **Unknown parameters return 400**
>
> An unknown query parameter returns `400 INVALID_PARAMETER` before credits are reserved. `details.param` names the rejected parameter, `details.valid_params` lists the accepted names, and `details.did_you_mean` suggests a likely correction when one exists. Use `cursor`, not `page` or `offset`.

## Query parameters

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `include` | string | — | The only relation here is `index`: with `include=index` every row carries `index_eur` and `last_price_at`, at the same 1 credit per 50 products requested as the bare list. `include=prices` returns `400 INVALID_INCLUDE` with `details.prices_route`: quotes are at `GET /v1/sealed/{id}/prices` (2 credits). Without an include the rows are catalogue only, and `orderBy` on those two fields is a 400. A single product on `/v1/sealed/{id}` always carries them. |
| `q` | string | — | Free text on the product name — in the locale given by `lang`, if any — or an exact `sku`. Not the card query grammar: there are no fields or ranges here, because "evolving skies booster box" is the whole question. |
| `set` | string | — | Set code, slug or alternate id, as on `/v1/sets/{code}`. Products of a set that is not in the catalogue carry `set_code: null` and cannot be reached this way. |
| `kind` | string | — | Product kind, as printed in `kind`: `BOOSTER_BOX`, `ETB`, `TIN`, `BLISTER`, `COLLECTION`, `BOOSTER_PACK`, `BOOSTER_BUNDLE`, `STARTER_DECK`, `BUILD_AND_BATTLE`, `PREMIUM_COLLECTION`, `SLEEVED_BOOSTER` and twelve more. Read the values off the list rather than hardcoding them; an unknown kind returns an empty page, not an error. |
| `lang` | string | — | Locale for `name`: `en`, `ja`, `fr`, `de`, `es`, `it`. A product without a name in that locale falls back to English. |
| `orderBy` | string | -release_date | `release_date`, `name`, `sku`, `updated_at` or `set`, and with `include=index` also `index_eur` and `last_price_at`, with a leading `-` for descending. Every order ends on `sku` as the tiebreaker. |
| `order_by` | string | — | Accepted snake_case spelling of `orderBy`; the same sort keys and cursor rules apply. |
| `limit` | integer | 50 | Rows per page, 1 to 250. Card and sealed lists cost 1 credit per 50 rows requested, rounded up: 50 costs 1, 51 costs 2, 250 costs 5, so ask for multiples of 50. Above 250 you get `LIMIT_EXCEEDED` — follow `links.next` instead of raising it. |
| `cursor` | string | — | Opaque keyset cursor from `links.next`. Never construct one; it carries the sort order it was issued for and is rejected if `orderBy` changes mid-run. |

## Headers

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `X-Api-Key` **required** | string | — | Your API key. `Authorization: Bearer <key>` is accepted as an alias. |
| `If-None-Match` | string | — | Send back the `ETag` you stored. The API returns a strong ETag on every catalogue response, and a 304 is a header exchange: no body, no database row read. |

## Example request

**curl**

```bash
curl -s -G "https://api.pokemontcgapi.com/v1/sealed" \
  --data-urlencode "q=evolving skies" \
  --data-urlencode "kind=BOOSTER_BOX" \
  --data-urlencode "limit=2" \
  -H "X-Api-Key: $PTCG_API_KEY"
```

**TypeScript**

`request.ts`

```ts
const url = new URL("https://api.pokemontcgapi.com/v1/sealed");
url.searchParams.set("q", "evolving skies");
url.searchParams.set("kind", "BOOSTER_BOX");
url.searchParams.set("limit", "2");

const res = await fetch(url, {
  headers: { "X-Api-Key": process.env.PTCG_API_KEY ?? "" },
});

if (!res.ok) {
  const { error } = await res.json();
  throw new Error(`${error.code}: ${error.message} (${error.request_id})`);
}

const { data, meta } = await res.json();
```

**Python**

`request.py`

```python
import os, httpx

res = httpx.get(
    "https://api.pokemontcgapi.com/v1/sealed",
    params={"q": "evolving skies", "kind": "BOOSTER_BOX", "limit": "2"},
    headers={"X-Api-Key": os.environ["PTCG_API_KEY"]},
)
res.raise_for_status()
payload = res.json()
```

## Example response

`200 OK` · `Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=600`

`response.json`

```json
{
  "data": [
    {
      "id": "evolving-skies-6-booster-box-case",
      "sku": "evolving-skies-6-booster-box-case",
      "slug": "evolving-skies-6-booster-box-case",
      "name": "Evolving Skies 6 Booster Box Case",
      "kind": "BOOSTER_BOX",
      "set_code": "evs",
      "set_name": "Evolving Skies",
      "image_url": "https://img.pokemontcgapi.com/media/boxes/evolving-skies-6-booster-box-case-normal.webp?e=1791763200&s=Zp2fL8qKw3xVn1tRc7bYdM",
      "release_date": null,
      "pack_count": 216,
      "languages": [],
      "index_eur": 15495,
      "last_price_at": "2026-09-02T00:38:55.286Z",
      "created_at": "2026-05-23T12:43:56.128Z",
      "updated_at": "2026-09-02T15:56:54.473Z"
    },
    {
      "id": "evolving-skies-booster-box",
      "sku": "evolving-skies-booster-box",
      "slug": "evolving-skies-booster-box",
      "name": "Evolving Skies Booster Box",
      "kind": "BOOSTER_BOX",
      "set_code": "evs",
      "set_name": "Evolving Skies",
      "image_url": "https://img.pokemontcgapi.com/media/boxes/evolving-skies-booster-box-normal.webp?e=1791763200&s=Hq6dS1nXt9wBk4yPm2vLcJ",
      "release_date": null,
      "pack_count": 36,
      "languages": [],
      "index_eur": 1750,
      "last_price_at": "2026-09-02T00:36:37.317Z",
      "created_at": "2026-05-23T12:43:56.069Z",
      "updated_at": "2026-09-02T15:56:54.473Z"
    }
  ],
  "meta": {
    "limit": 2,
    "count": 2,
    "total_count": 3,
    "has_more": true
  },
  "links": {
    "next": "https://api.pokemontcgapi.com/v1/sealed?cursor=eyJrIjpbbnVsbF0sImlkIjoiZXZvbHZpbmctc2tpZXMtYm9vc3Rlci1ib3giLCJzIjoicmVsZWFzZV9kYXRlOmRlc2Msc2t1OmFzYyJ9&kind=BOOSTER_BOX&limit=2&q=evolving+skies"
  }
}
```

## Errors

Every error body carries `error.code`, `error.message` and `error.request_id`. Switch on the code, never on the message.

| Status | Code | When |
| --- | --- | --- |
| 401 | `MISSING_API_KEY` | No `X-Api-Key` header and no bearer token on a route that requires one. |
| 401 | `INVALID_API_KEY` | The key does not match any account. |
| 400 | `INVALID_PARAMETER` | A query parameter has the wrong type or an unsupported value — a non-integer `limit`, an unknown `lang`, an unknown `region`, a sort key that is not sortable. |
| 400 | `INVALID_CURSOR` | The cursor is malformed, or was issued for a different `orderBy` than the one on this request. |
| 400 | `INVALID_INCLUDE` | A name in `include` is not a known relation. |
| 400 | `LIMIT_EXCEEDED` | `limit` is above 250, or a batch request carries more than 100 ids. |
| 429 | `QUOTA_EXCEEDED` | Trial credits, monthly credits or the daily cap are exhausted, and `details` says which: `daily_cap` with `retry_after_s` is the trial daily cap, which resets at midnight UTC; `renews: true` with `resets_at` is the monthly quota of a paid plan; `renews: false` is the trial allowance, which never comes back. `details.next_step` carries the plan, prices and owner action, or email verification when it unlocks the full trial. |
| 403 | `PLAN_REQUIRED` | The feature is outside the plan (movers require Growth; photo recognition has 5 calls after Free email confirmation, then requires Growth), or the account subscription is canceled or suspended. Centering requires Growth or higher; Developer is excluded. After email confirmation the trial includes 5 measure and 5 confirm calls independently; grade and standards use trial credits without separate call counters. Before email confirmation, the message starts with “Confirm your email…”. Follow details.next_step when present. |
| 403 | `TRIAL_EXPIRED` | The trial has ended: the account is on the trial and its 30 days from signup are over, and this route costs credits. `details.trial_expired_at` is the date, `details.trial_days` the length, and `details.next_step` the owner handoff. Zero-cost routes — `/v1/me`, `/v1/me/usage`, the key routes and `/v1/billing/checkout` — still answer. |

The full taxonomy, with what to do about each code, is on the [errors](https://pokemontcgapi.com/docs/errors) page.
