# Batch cards

> Resolve up to 100 card ids and report missing ids with verified historical alternatives.

- **Endpoint**: `GET /v1/cards/batch`
- **Cost**: 1 per 25 requested ids; include=index: 1 per 50; include=prices: 4 per 25. Rounded up, including unresolved and repeated ids.
- **Minimum plan**: free
- **Cache-Control**: `public, max-age=60, s-maxage=300, stale-while-revalidate=600`

Source: https://pokemontcgapi.com/docs/api/cards/batch

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

For hydrating a known list: a deck list, a collection, or ids from your own database. One request, one round trip.

Unresolved ids do not make the request an error. `data` contains distinct resolved cards, `requested` counts the ids sent, including repetitions, and `found` counts returned cards. Every unresolved id appears once in `missing`, as `{ "id": "..." }`. The optional `suggested_id` appears only when an existing card has that legacy alias in a different canonical set. It is a suggestion, never an automatic substitution. `missing` is absent when all ids resolve, matching the convention for `withheld`. Comparisons ignore case; `missing.id` keeps the spelling and order of the first occurrence, after trimming whitespace. Two valid ids for the same card yield one row in `data` and no missing entry.

## Query parameters

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `ids` **required** | string | — | Comma-separated ids, at most 100; a 101st is `LIMIT_EXCEEDED`. A canonical set code binds the lookup to that set, using the longest matching code and preserving the full number, including zeros. Historical aliases resolve only when their prefix is not a canonical set code. |
| `select` | string | — | Comma-separated card fields to return. `id` is always included. camelCase and snake_case both resolve, so `nationalPokedexNumbers` works and comes back as `national_pokedex_numbers`. An unknown name is `INVALID_SELECT_FIELD`, with the full list in `details.valid_fields`. |
| `include` | string | — | Comma-separated includes: `index`, `prices`, `translations`, `images`, `set`, `artist`. `include=index` exposes index_eur and last_price_at at 1 credit per 50 requested ids. Selecting those fields without index or prices leaves them out and reports top-level `withheld: ["index"]`. `include=prices` includes the index and plan-filtered quotes at 4 credits per 25 requested ids; top-level `withheld` names price restrictions and is absent when none apply. `legalities` is rejected with 400 `INVALID_INCLUDE`. |
| `lang` | string | — | Locale for the card name: `en`, `fr`, `de`, `ja`, `it`, `es`, `pt`, `zh` — the eight with translation rows in the catalogue. A card with no translation in the requested locale falls back to English rather than to null. |

## 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/cards/batch" \
  --data-urlencode "ids=sv8-116,sv8-100,inventato-xyz" \
  --data-urlencode "select=id,set_code,print_region" \
  -H "X-Api-Key: $PTCG_API_KEY"
```

**TypeScript**

`request.ts`

```ts
const url = new URL("https://api.pokemontcgapi.com/v1/cards/batch");
url.searchParams.set("ids", "sv8-116,sv8-100,inventato-xyz");
url.searchParams.set("select", "id,set_code,print_region");

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, requested, found, missing, withheld } = await res.json();
```

**Python**

`request.py`

```python
import os, httpx

res = httpx.get(
    "https://api.pokemontcgapi.com/v1/cards/batch",
    params={"ids": "sv8-116,sv8-100,inventato-xyz", "select": "id,set_code,print_region"},
    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": "sv8-100", "set_code": "sv8", "print_region": "JP" }
  ],
  "requested": 3,
  "found": 1,
  "missing": [
    { "id": "sv8-116", "suggested_id": "ssp-116" },
    { "id": "inventato-xyz" }
  ]
}
```

## 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 | `LIMIT_EXCEEDED` | `limit` is above 250, or a batch request carries more than 100 ids. |
| 400 | `INVALID_SELECT_FIELD` | A name in `select` is not a card field. |
| 400 | `INVALID_INCLUDE` | A name in `include` is not a known relation. |
| 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. |
| 403 | `CARD_ALLOWANCE_EXCEEDED` | The account has retrieved as many distinct cards as its plan covers in the period — 1,000 for the whole trial, 30,000 a month on Developer; Growth and above have no limit — and this request would add new ones. `details.cards_used`, `details.card_allowance` and `details.new_cards` give the numbers, `details.resets_at` the next period on paid plans, and `details.next_step` the owner handoff. Only the card routes count; the page that crossed the limit was served in full, and cards already retrieved keep answering. |

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

There is no pagination or cursor. `withheld`, when present, remains a top-level field for index or price restrictions. Missing ids remain billable at the requested-id tariff. Requesting `sv8-116,ssp-116` returns `ssp-116` once and still reports `sv8-116` as missing with that suggestion. An alias such as `base1-4` continues to resolve to `bs-4` because `base1` is not a canonical set code.
