Skip to content
pokemontcgapi.com
Documentation

Errors

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

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
{
  "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"
  }
}
FieldContract
error.codeStable. Published codes never change meaning. This is the thing to switch on.
error.messageEnglish, for a human. Reworded whenever a rewording helps. Never parse it.
error.detailsStructured context. Contents vary by code and are documented alongside it.
error.request_idAlso 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
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

StatusRetry?
400No. The same request produces the same body forever.
401 / 403No. Fix the key or the plan.
404No. It is a fact about the catalogue — cache it, do not poll it.
409 / 413 / 415 / 422No. Change the request.
429 RATE_LIMITED / CONCURRENCY_LIMITYes, after Retry-After, with jitter.
429 QUOTA_EXCEEDEDNo. Nothing recovers a spent quota except the period rolling over.
500 / 503Yes, 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.

400 — bad request
CodeStatusMeaningWhat to do
INVALID_QUERY400The 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_COMPLEX400A 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_UNSUPPORTED400The 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_CURSOR400The 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_FIELD400A name in select is not a card field.Use details.valid_fields. Both national_pokedex_numbers and nationalPokedexNumbers are accepted.
INVALID_INCLUDE400A name in include is not a known relation.Valid values are in details.valid_includes: prices, legalities, translations, images, set, artist.
INVALID_PARAMETER400A 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_EXCEEDED400limit 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.

401 — unauthenticated
CodeStatusMeaningWhat to do
MISSING_API_KEY401No 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_KEY401The 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.

404 — not found
CodeStatusMeaningWhat to do
CARD_NOT_FOUND404No 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_FOUND404No 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_FOUND404No artist with that slug.Artist slugs come from /v1/artists; they are not free text.
SEALED_NOT_FOUND404No 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_FOUND404The 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.

413 / 415
CodeStatusMeaningWhat to do
PLAN_REQUIRED403The 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_REQUIRED403A 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_LARGE413The 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_TYPE415The 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.

500 / 503 — our fault
CodeStatusMeaningWhat to do
INTERNAL_ERROR500Our 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_CONFIGURED503The 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:

KeyAppears onUse
positionQuery and parse errors0-based offset into the string you sent. Point a caret at it.
fieldUnknown field errorsThe token we could not resolve.
valid_fieldsINVALID_QUERY, INVALID_SELECT_FIELD, INVALID_PARAMETER on orderByThe complete accepted list — no need to hardcode it.
valid_valuesINVALID_QUERY on a constrained fieldThe accepted values for that field.
valid_includesINVALID_INCLUDEAccepted relation names.
max / requestedLIMIT_EXCEEDEDThe ceiling and what you asked for.
max_clauses / max_depth / max_lengthQUERY_TOO_COMPLEXWhich 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.

view this page as markdown