# Errors

> One error shape, 21 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.

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.

## 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. Nothing recovers a spent quota except the period rolling over. |
| `500` / `503` | Yes, with exponential backoff. Then send us the request id. |

## The complete taxonomy

21 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`: prices, legalities, translations, images, set, artist. |
| `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. | 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. |

### 413 / 415

Request-shape problems that deserve their own status.

| Code | Status | Meaning | What to do |
| --- | --- | --- | --- |
| `PLAN_REQUIRED` | 403 | The route exists and the key is valid, but the plan does not include this feature. Today that is `/v1/prices/movers` on the trial. | Not a bug to retry. Upgrade at `/billing`, or drop the call on the trial. |
| `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. | Shorten the window to what `details` reports, or upgrade. Retrying the same request returns the same answer. |
| `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:

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