# Errors

> One error shape, 27 codes, and what to do about each one.

Source: https://pokemontcgapi.com/docs/errors

Every failing request returns the same body, whatever the status and whatever it was you asked for. There is no second error format to handle.

The `X-Error-Code` response header repeats `error.code` exactly on every API error in this JSON contract. It is exposed to browser JavaScript through CORS and is absent on successful responses. Read it from response headers when an access log has no body, or use the JSON body for the full error details. The `/v1/health` operational probe has its own status body rather than an `error.code`.

This is a real response, copied from `q=set_code:obf` on 2026-08-27 — a query written in response spelling instead of query spelling:

`error.json`

```json
{
  "error": {
    "code": "INVALID_QUERY",
    "message": "Unknown search field \"set_code\" at position 0. The full list of searchable fields is in details.valid_fields.",
    "details": {
      "field": "set_code",
      "position": 0,
      "valid_fields": ["abilities.name", "abilities.text", "abilities.type", "artist", "…"]
    },
    "request_id": "20132829-00df-41c8-95bc-db9305fb54ed"
  }
}
```

| Field | Contract |
| --- | --- |
| `error.code` | Stable. Published codes never change meaning. **This is the thing to switch on.** |
| `error.message` | English, for a human. Reworded whenever a rewording helps. Never parse it. |
| `error.details` | Structured context. Contents vary by code and are documented alongside it. |
| `error.request_id` | Also in the `X-Request-Id` header. Log it on every failure. |

> **A client mistake is never a 5xx**
>
> A malformed query, an unknown field, a limit over the ceiling — all `400`. If a request you sent produces a `500`, that is our bug and we want the request id. On resale marketplaces a 5xx sinks the listed service level for thirty days and is excluded from quota counting, so mislabelling is expensive for us in a way that keeps us honest.

## Recommended plans

Commercial refusals may add `details.upgrade`: `plan_code`, `plan_name`, `monthly_cents`, `currency`, `reason` and either a monthly `checkout_url` for a new subscription or `manage_url` for an existing subscriber to change plan from the billing portal. The recommendation uses the cached billing catalogue and chooses the lowest monthly price that resolves the refusal while preserving current plan capabilities; sort order and code break ties. The message names the plan and repeats its link. Status codes, `error.code` and existing details remain stable.

If an active subscription already exists, `upgrade.manage_url` points to the account page: change plan there through the billing portal. The subscribe link is for starting a subscription and does not open a second one. When email confirmation unlocks the trial allowance or trial recognition calls, verification comes first and no paid upgrade is added. Unknown, negotiated or inconsistent limits, unavailable catalogue data and limits with no purchasable higher plan produce no upgrade object. Existing `details.next_step` remains available.

## Centering and PLAN_REQUIRED

Centering is available on Growth, Professional, Enterprise; Developer is excluded. After email confirmation, the Free trial includes 5 measure and 5 confirm calls, with independent counters from Vision. Grade and standards spend trial credits without separate call counters. Before email confirmation, all four routes return 403 PLAN_REQUIRED with “Confirm your email…”; confirm the email before trying again. Exhausting either measure or confirm counter requires a plan for that operation. See the [centering guide](https://pokemontcgapi.com/docs/centering).

## Handling errors

The shape is uniform enough that one helper covers the whole API:

`client.ts`

```ts
class ApiError extends Error {
  constructor(
    readonly code: string,
    readonly status: number,
    readonly requestId: string,
    message: string,
  ) {
    super(message);
  }
}

export async function json<T>(res: Response): Promise<T> {
  if (res.ok) return res.json() as Promise<T>;

  const body = await res.json().catch(() => null);
  const err = body?.error;

  throw new ApiError(
    err?.code ?? "UNKNOWN",
    res.status,
    err?.request_id ?? res.headers.get("X-Request-Id") ?? "",
    err?.message ?? res.statusText,
  );
}
```

## Retry rules

| Status | Retry? |
| --- | --- |
| `400` | No. The same request produces the same body forever. |
| `401` / `403` | No. Fix the key or the plan. |
| `404` | No. It is a fact about the catalogue — cache it, do not poll it. |
| `409` / `413` / `415` / `422` | No. Change the request. |
| `429 RATE_LIMITED` / `CONCURRENCY_LIMIT` | Yes, after `Retry-After`, with jitter. |
| `429 QUOTA_EXCEEDED` | No. Deliver `details.next_step` to the owner; the action depends on trial, verification and subscription state. |
| `403 TRIAL_EXPIRED` | No. Nothing reopens an ended trial except a plan; `/v1/me` and checkout still answer. |
| `403 CARD_ALLOWANCE_EXCEEDED` | No. The period has retrieved its distinct-card allowance; cards already retrieved keep answering, new ones wait for the next period or a higher plan. Uncharged. |
| `500` / `503` | Yes, with exponential backoff. Then send us the request id. |

## When the API refuses for a commercial reason

Commercial refusals carry `details.next_step`: trial or monthly quota exhaustion, a daily cap, the distinct-card allowance, a missing plan feature, a history window or API key limit, a canceled subscription, a suspended account, or an expired trial. Existing error codes, HTTP statuses and details are preserved. The block is optional: it is omitted for public keys, RapidAPI, custom limits or uncertain billing paths. It appears only when the available account and plan information identifies a suitable next step.

Show `next_step.handoff` and its URL to the account owner verbatim.

The account owner opens `checkout_url`, `manage_url`, `verify_url` or `contact_url`; the agent cannot pay or verify an email.

Do not retry the request until the owner completes the indicated action.

Subscribe and upgrade include the exact monthly and yearly prices excluding VAT. Subscribe offers `checkout_url_yearly` as the annual alternative. Upgrade uses `manage_url` to open the account page and change plan from the billing portal. Contract plans use `contact_sales` without prices. These examples are generated by HTTP response tests.

`trial-exhausted.json`

```json
{
  "error": {
    "code": "QUOTA_EXCEEDED",
    "message": "Your free trial of 800 credits is used up (800 spent). Trial credits do not renew. Recommended plan: Developer (29 EUR/month). https://pokemontcgapi.com/subscribe?plan=developer&interval=monthly",
    "details": {
      "credits_required": 1,
      "next_step": {
        "action": "subscribe",
        "actor": "account_owner",
        "checkout_url": "https://pokemontcgapi.com/subscribe?plan=developer&interval=monthly",
        "checkout_url_yearly": "https://pokemontcgapi.com/subscribe?plan=developer&interval=yearly",
        "handoff": "The account owner needs to open https://pokemontcgapi.com/subscribe?plan=developer&interval=monthly to continue on Developer (29 €/month or 290 €/year, prices exclude VAT); this request is not retried until then.",
        "plan": {
          "code": "DEVELOPER",
          "credits_per_month": 50000,
          "monthly_eur": "29",
          "name": "Developer",
          "yearly_eur": "290"
        },
        "plans_url": "https://pokemontcgapi.com/pricing"
      },
      "plan": "FREE",
      "quota_limit": 800,
      "quota_used": 800,
      "renews": false,
      "upgrade": {
        "checkout_url": "https://pokemontcgapi.com/subscribe?plan=developer&interval=monthly",
        "currency": "EUR",
        "monthly_cents": 2900,
        "plan_code": "DEVELOPER",
        "plan_name": "Developer",
        "reason": "credits"
      }
    },
    "request_id": ""
  }
}
```

`photo-plan-required.json`

```json
{
  "error": {
    "code": "PLAN_REQUIRED",
    "message": "Card recognition is included from the Growth plan up. Your current plan does not include it. Recommended plan: Growth (79 EUR/month). https://pokemontcgapi.com/subscribe?plan=growth&interval=monthly",
    "details": {
      "feature": "vision",
      "min_plan": "GROWTH",
      "next_step": {
        "action": "subscribe",
        "actor": "account_owner",
        "checkout_url": "https://pokemontcgapi.com/subscribe?plan=growth&interval=monthly",
        "checkout_url_yearly": "https://pokemontcgapi.com/subscribe?plan=growth&interval=yearly",
        "handoff": "The account owner needs to open https://pokemontcgapi.com/subscribe?plan=growth&interval=monthly to continue on Growth (79 €/month or 790 €/year, prices exclude VAT); this request is not retried until then.",
        "plan": {
          "code": "GROWTH",
          "credits_per_month": 200000,
          "monthly_eur": "79",
          "name": "Growth",
          "yearly_eur": "790"
        },
        "plans_url": "https://pokemontcgapi.com/pricing"
      },
      "plan": "FREE",
      "upgrade": {
        "checkout_url": "https://pokemontcgapi.com/subscribe?plan=growth&interval=monthly",
        "currency": "EUR",
        "monthly_cents": 7900,
        "plan_code": "GROWTH",
        "plan_name": "Growth",
        "reason": "vision"
      }
    },
    "request_id": ""
  }
}
```

`card-allowance-exceeded.json`

```json
{
  "error": {
    "code": "CARD_ALLOWANCE_EXCEEDED",
    "message": "Your trial covers up to 1000 distinct cards and this account has already retrieved 1000; this request would add 250 new card(s). Cards you have already retrieved keep answering. Recommended plan: Developer (29 EUR/month). https://pokemontcgapi.com/subscribe?plan=developer&interval=monthly",
    "details": {
      "card_allowance": 1000,
      "cards_used": 1000,
      "new_cards": 250,
      "next_step": {
        "action": "subscribe",
        "actor": "account_owner",
        "checkout_url": "https://pokemontcgapi.com/subscribe?plan=developer&interval=monthly",
        "checkout_url_yearly": "https://pokemontcgapi.com/subscribe?plan=developer&interval=yearly",
        "handoff": "The account owner needs to open https://pokemontcgapi.com/subscribe?plan=developer&interval=monthly to continue on Developer (29 €/month or 290 €/year, prices exclude VAT); this request is not retried until then.",
        "plan": {
          "code": "DEVELOPER",
          "credits_per_month": 50000,
          "monthly_eur": "29",
          "name": "Developer",
          "yearly_eur": "290"
        },
        "plans_url": "https://pokemontcgapi.com/pricing"
      },
      "plan": "FREE",
      "renews": false,
      "upgrade": {
        "checkout_url": "https://pokemontcgapi.com/subscribe?plan=developer&interval=monthly",
        "currency": "EUR",
        "monthly_cents": 2900,
        "plan_code": "DEVELOPER",
        "plan_name": "Developer",
        "reason": "card_allowance"
      }
    },
    "request_id": ""
  }
}
```

## The complete taxonomy

27 codes, grouped by status family. Codes are a published contract: once shipped, they do not change meaning, and new ones are added only when a failure mode becomes distinguishable.

### 400 — bad request

The request never reached the database. Fix the caller; every retry returns the same body.

| Code | Status | Meaning | What to do |
| --- | --- | --- | --- |
| `INVALID_QUERY` | 400 | The `q` parameter does not parse, or names a field that does not exist. | Read `details.position` for the 0-based offset in your query string, and `details.valid_fields` for the full field list. |
| `QUERY_TOO_COMPLEX` | 400 | A structural limit was exceeded — the query has too many clauses or nests too deep, or `orderBy` carries more than 4 keys. | Split the query into several requests, or look up known ids with `/v1/cards/batch` instead. |
| `QUERY_UNSUPPORTED` | 400 | The syntax is valid but cannot run: a leading wildcard, or a range on a field that is neither numeric nor a date. | The message names the construct and the field. Leading wildcards have no supported equivalent — drop the `*`. |
| `INVALID_CURSOR` | 400 | The cursor is malformed, or was issued for a different `orderBy` than the one on this request. | Restart pagination without `cursor`, and keep `orderBy` identical for every page of a run. |
| `INVALID_SELECT_FIELD` | 400 | A name in `select` is not a card field. | Use `details.valid_fields`. Both `national_pokedex_numbers` and `nationalPokedexNumbers` are accepted. |
| `INVALID_INCLUDE` | 400 | A name in `include` is not a known relation. | Valid values are in `details.valid_includes`: index, prices, translations, images, set, artist. When the name is close to a real one (`image`, `price`), `details.did_you_mean` holds the right spelling. |
| `INVALID_PARAMETER` | 400 | 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. | The message names the parameter and the value received, and `details` carries the accepted set. Fix the caller; retrying is pointless. |
| `LIMIT_EXCEEDED` | 400 | `limit` is above 250, or a batch request carries more than 100 ids. | Page with `cursor`, or split the id list across several batch calls. |

### 401 — unauthenticated

No usable key was presented on a route that requires one.

| Code | Status | Meaning | What to do |
| --- | --- | --- | --- |
| `MISSING_API_KEY` | 401 | No `X-Api-Key` header and no bearer token on a route that requires one. | Send the key. Keys are free and issued instantly. |
| `INVALID_API_KEY` | 401 | The key does not match any account. | Check for a truncated copy-paste. Keys are prefixed `ptcg_live_` or `ptcg_pub_`. |

### 404 — not found

A statement about the catalogue, or about the path. Cache it; do not poll it.

| Code | Status | Meaning | What to do |
| --- | --- | --- | --- |
| `CARD_NOT_FOUND` | 404 | No card with that id. Both our id (`bs-4`) and the alternate `{set}-{number}` id (`base1-4`) resolve here, and so does an id written with the set slug in place of the set code (`30th-celebration-001` for `30c-001`); each answers with the canonical id. A 404 means none of the three forms matched. | A 404 is a fact about the catalogue, not a transient failure. Do not retry it in a loop. |
| `SET_NOT_FOUND` | 404 | No set with that code, slug or alternate id. | List `/v1/sets` to see the valid codes. `/v1/sets/{code}/cards` returns this rather than an empty page. |
| `ARTIST_NOT_FOUND` | 404 | No artist with that slug. | Artist slugs come from `/v1/artists`; they are not free text. |
| `SEALED_NOT_FOUND` | 404 | No sealed product with that id. | The id is the `sku` from `/v1/sealed`; the `slug` also resolves. Search with `/v1/sealed?q=` when you only have a name. |
| `ROUTE_NOT_FOUND` | 404 | The path itself does not exist on this API. | Check the version prefix and the spelling. `/cards` without `/v1` lands here. When the path looks like a known route under another name (`/v1/usage`, `/v1/account`, `/v1/cards/{id}/image`), `details.did_you_mean` names the real one; `details.docs` and `details.llms` point at the full list. |

### 403 / 409 / 413 / 415

Access restrictions, key conflicts and request-shape problems. Follow the remedy before sending another request.

| Code | Status | Meaning | What to do |
| --- | --- | --- | --- |
| `PLAN_REQUIRED` | 403 | 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. | Show `details.next_step.handoff` and its URL to the account owner verbatim; do not retry. |
| `UPGRADE_REQUIRED` | 403 | A parameter asks for more than the plan allows: a `window` longer than the plan’s history, for example. `details.plan_window_days` says how much the plan gives; `details.next_step` carries the owner action and handoff. | Show `details.next_step.handoff` and its URL to the account owner verbatim; do not retry. A shorter history window or revoking an unused key may also satisfy the current plan. |
| `QUOTA_EXCEEDED` | 429 | 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. | Show `details.next_step.handoff` and its URL to the account owner verbatim, and do not retry with a backoff. The same request works again after `details.retry_after_s` seconds for the daily cap, or after `details.resets_at` for a monthly quota: schedule it for then, or stop. With `renews: false` only a plan reopens it. |
| `CARD_ALLOWANCE_EXCEEDED` | 403 | 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. | Not a bug to retry, and no `Retry-After` will come. Keep serving the cards you already have; for new ones, wait for `resets_at` (paid plans) or move to Growth with `POST /v1/billing/checkout`. Watch `X-Cards-Used` against `X-Cards-Limit` on every answer, or `quota.cards_used` on `GET /v1/me`, to see it coming. |
| `TRIAL_EXPIRED` | 403 | 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. | Not a bug to retry, and no `Retry-After` will come: nothing reopens a trial except a plan. Subscribe with `POST /v1/billing/checkout` or at `/pricing`; the same key starts answering again within a minute. Read the end date ahead of time from the `X-Trial-Expires-At` header or `quota.trial_expires_at` on `GET /v1/me`. |
| `IMAGE_URL_INVALID` | 403 | An image URL on `img.pokemontcgapi.com` without its signature, with a wrong one, or past its expiry. Image URLs come signed from the API (`?e=` expiry, `?s=` signature) and stay valid for at least a week; they cannot be built by hand. | Request the card again with `include=images` (or the set, or the sealed product) and use the URL as returned. Store the card id, not the URL: a stored URL goes stale after a week or two. |
| `KEY_ALREADY_ROTATED` | 409 | This key has already been rotated. `details.replaced_by` identifies its replacement. | Read `GET /v1/me/keys` again and rotate the replacement if you need a new secret. |
| `KEY_ROTATION_EXPIRED` | 409 | This key has expired and can no longer be rotated. | Create a new key with `POST /v1/me/keys`. If all slots are in use, revoke the expired key first to free a slot. |
| `PAYLOAD_TOO_LARGE` | 413 | The request body is larger than the server accepts. | Send less. Every documented route is a `GET`, so a body large enough to trip this is usually a client bug. |
| `UNSUPPORTED_MEDIA_TYPE` | 415 | The `Content-Type` is not one this route accepts. | Drop the `Content-Type` header on `GET` requests, or send `application/json`. |

### 500 / 503 — our fault

A client error never lands here. If you see a 5xx caused by a request you sent, that is a bug and we want the request id.

| Code | Status | Meaning | What to do |
| --- | --- | --- | --- |
| `INTERNAL_ERROR` | 500 | Our fault. Nothing you sent can cause this. | Retry with backoff, then send us `error.request_id` — it is the key into our logs. |
| `FEATURE_NOT_CONFIGURED` | 503 | The endpoint exists but its optional backend is not configured in this deployment. | Not something a client can fix. Report it with the request id. |

## Reading `details`

The useful part of a `400` is usually in `details`, not in the message:

On catalogue and price routes, an unknown query parameter returns `400 INVALID_PARAMETER` before any credits are reserved. Existing direct accounts may temporarily receive a normal response with one `Warning: 299` header per unknown parameter and the same messages in `meta.warnings`; the warning states when the 400 begins. New accounts, public keys and gateway requests are strict immediately. For `GET /v1/cards?page_size=200`, `details.param` is `page_size`, `details.did_you_mean` is `limit`, and `details.valid_params` lists the names this route accepts. `page` and `offset` point to `cursor` and the [pagination guide](https://pokemontcgapi.com/docs/pagination).

The pagination hint says: "This API paginates with a cursor: follow links.next of the previous page (see /docs/pagination)." On card routes, `region` and `country` have no direct filter: use `q=set.region:JP` (or `CN`, `WEST`). `region` belongs to `GET /v1/sets`; these errors carry `details.hint` without `details.did_you_mean`.

| Key | Appears on | Use |
| --- | --- | --- |
| `position` | Query and parse errors | 0-based offset into the string you sent. Point a caret at it. |
| `field` | Unknown field errors | The token we could not resolve. |
| `param` | `INVALID_PARAMETER` for an unknown query parameter | The exact parameter name you sent. |
| `valid_params` | `INVALID_PARAMETER` for an unknown query parameter | The full query parameter whitelist for this route. |
| `valid_fields` | `INVALID_QUERY`, `INVALID_SELECT_FIELD`, `INVALID_PARAMETER` on `orderBy` | The complete accepted list — no need to hardcode it. |
| `valid_values` | `INVALID_QUERY` on a constrained field | The accepted values for that field. |
| `valid_includes` | `INVALID_INCLUDE` | Accepted relation names. |
| `did_you_mean` | `INVALID_PARAMETER`, `INVALID_INCLUDE`, `ROUTE_NOT_FOUND` | Our best guess at an accepted parameter, relation or route. Absent when there is no good guess. |
| `prices_route` | `INVALID_INCLUDE` on sealed `include=prices` | The dedicated quote endpoint, `GET /v1/sealed/{id}/prices`. |
| `hint` | `INVALID_PARAMETER` for a related concept on another route | Where to filter by print region or quote source when this route has no direct parameter. |
| `docs` / `llms` | `ROUTE_NOT_FOUND` | Where the complete route list lives, for people and for agents. |
| `max` / `requested` | `LIMIT_EXCEEDED` | The ceiling and what you asked for. |
| `max_clauses` / `max_depth` / `max_length` | `QUERY_TOO_COMPLEX` | Which structural limit you crossed. |

> **Unknown codes are possible**
>
> New codes get added when a new failure mode becomes distinguishable. Handle the codes you care about explicitly and fall back on the HTTP status class for the rest — a `default:` branch that treats 4xx as fatal and 5xx as retryable is correct for every code we will ever add.
