Errors
One error shape, 27 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.
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": {
"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.
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. 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.
{
"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": ""
}
}{
"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": ""
}
}{
"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.
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.