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": {
"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:
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.