Skip to content

Error reference

Every problem+json type we emit — and the four failures that are not problem+json.

How to read an error

Every failure the API decides on comes back as application/problem+json (RFC 9457) with a type URI that links to the section below, a code, a title and a detail. Branch on code, never on the wording.

Two members are on every problem body without exception. retryable answers the only question that matters at 3am — does repeating this byte-identical request have any chance of a different answer — and it is the answer, not the status code: rate_limited is normally true and false when reason is version_cap_reached. requestId is the same string as the X-Request-Id response header, always — quote it to support and it finds the request.

Where a code covers more than one situation, reason splits it, and each reason has its own anchor below.

We never put an identifier in the body. A 404 is deliberately indistinguishable from “not yours” — telling you which would let a key enumerate other customers' decks.

Unauthorized

401 · unauthorized · do not retry

The credential is missing, malformed, expired, or was revoked. For MCP this includes an access token that died mid-conversation — tokens last 15 minutes and are re-checked on every spend (plain reads may ride a short cache; see Authentication). Every 401 carries a `WWW-Authenticate` challenge: `Bearer error="invalid_token"` when you presented a credential we rejected, or a bare `Bearer realm="askdeck-public-api"` (no error code, per RFC 6750 §3) when the request carried no Authorization header at all. The challenge and body are deliberately identical across every rejection cause — which of them fired is not disclosed.

What to do: Re-authenticate: mint a new key in Settings → API & MCP, or sign in again in your MCP client — `invalid_token` is the RFC 6750 code whose standard remedy is exactly that. Never retry the same credential in a loop: a 401 is a verdict, not a hiccup.

Forbidden

403 · forbidden · do not retry

The credential is valid, and one of three different things is wrong. Branch on the `reason` member, never on the wording: `not_enrolled` — the public API is switched on for everyone, but a brand-new account can briefly see this while access rolls out in waves; `insufficient_scope` — the key is real but narrower than this operation (the body carries `requiredAction`, `grantedActions` and `requiredResource`); `unrecognized_scope` — the key's `scopes` array asked for a restriction we could not parse a single token of, so it was refused rather than granted more access than its owner intended (the body carries `unrecognizedScopes`, plus `recognizedActions` and `recognizedResources`, which are the whole vocabulary).

What to do: `not_enrolled`: nothing about your key is wrong and no retry helps — your keys start working the moment your account is switched on, and you will not need to redo anything. `insufficient_scope`: widen the key's scopes to cover the action the body names. `unrecognized_scope`: re-issue the key with scopes spelled `<resource>:<action>` — for example `decks:read` or `decks:spend` — using only the words in `recognizedResources` and `recognizedActions`. The two SCOPE refusals send `WWW-Authenticate: Bearer error="insufficient_scope"` (RFC 6750 §3.1); `not_enrolled` sends no `WWW-Authenticate` at all, because there is no credential change that would fix it. None of the three is fixed by re-authenticating: scopes live on the key, not on the token.

reason: not_enrolled · do not retry

The public API is switched on for everyone; access rolls out in waves, so a brand-new account can briefly see this. Nothing about your key is wrong.

What to do: Nothing, and no retry helps. Your keys keep working the moment your account is switched on — you do not need to re-mint them.

reason: insufficient_scope · do not retry

The key is real but narrower than this operation. The body names `requiredAction`, `grantedActions` and `requiredResource`.

What to do: Widen the key's scopes to cover the action the body names, or use a different key.

reason: unrecognized_scope · do not retry

The key's `scopes` array contains words we could not parse a single token of, so it was refused rather than silently granted more access than its owner intended. The body carries `unrecognizedScopes`, plus `recognizedActions` and `recognizedResources` — the whole vocabulary.

What to do: Re-issue the key with scopes spelled `<resource>:<action>` — for example `decks:read` or `decks:spend` — using only the words in `recognizedResources` and `recognizedActions`.

Not found

404 · not_found · do not retry

There is no such resource, or it is not visible to this credential. The two are deliberately indistinguishable: a 403 would confirm that an id you guessed belongs to somebody else.

What to do: Check the taskId. If you are using an organisation key, confirm the deck was created by that organisation.

Insufficient credits

402 · insufficient_credits · do not retry

The wallet funding this call does not hold the credits the request reserves — or a ceiling above the wallet says no. Branch on `reason`. `api_free_tier_unavailable`: the free preview tier is available in the web app, not on the API, so an account that has never bought credits is refused on its first programmatic spend — the balance you can see is the free ladder, and the API does not draw from it. `monthly_credit_cap_reached`: this specific key hit the monthly cap its owner set on it (`monthlyCreditCap`, `monthlyCreditsSpent`, `resetAt`), and the account is not out of credits. The org-level refusals arrive here too: an organisation with no subscription, one that has switched org-key spend off, or a per-key cap in the org console.

What to do: A 402 is always `retryable: false` — every reason it carries. Repeating the call spends nothing and changes nothing; something outside the request has to move first. The body carries `options`, an ordered list of what you can do next (top up, subscribe, unlock this deck), and sometimes a single `cta` — present them, do not guess a price. For `api_free_tier_unavailable`, buy credits: the first purchased credit unlocks this call and every call after it. For `monthly_credit_cap_reached`, nothing is wrong with the account: raise the cap on that key, use a different credential, or wait for `resetAt`. No credits were charged in any case.

reason: monthly_credit_cap_reached · do not retry

This specific key hit the monthly cap its owner set on it (`monthlyCreditCap`, `monthlyCreditsSpent`, `resetAt`) — the account is not out of credits.

What to do: Raise the cap on that key, use a different credential, or wait for `resetAt`.

reason: api_free_tier_unavailable · do not retry

The free preview tier is a web-app thing, not an API thing. This account has never bought credits, so its first API spend is refused before anything is reserved — nothing was charged, and the balance you can see is the free ladder, which the API does not draw from.

What to do: Buy credits. The body carries `cta: "buy_credits"`. Your first purchased credit unlocks this call, and every call after it; no retry, plan change or key change does anything before that.

Price changed since you quoted

409 · price_drift · do not retry

You sent `expected_credits` and the live price no longer matches — typically an admin retune between your quote and your submit. Nothing was written and nothing was charged. It arrives with `conflictKind: "price_drift"`, and both numbers travel: `expectedCredits` is what you sent, `credits` is what it costs now.

What to do: Re-quote and resubmit with the new number. `retryable` is FALSE and that is not a contradiction: the recovery is a different request, with a different `expected_credits`, and resending this exact body returns this exact 409. Never strip `expected_credits` to make the 409 go away; it is the only thing standing between a retune and an unexpected charge.

Conflict

409 · conflict · do not retry

The request collides with the current state. `code` is `conflict` for every one of them, so the member that matters is `conflictKind`: `already_in_progress` (the same work is running), `terminal_state` (this resource's state refuses the request), `idempotency_key_reused` (this key already named a different body) or `price_drift`. `reason` carries the same string, plus the shared service's own more specific token where there is one.

What to do: Branch on `conflictKind`, and use `retryable` as the decision: `already_in_progress` is the ONLY 409 where waiting works — poll, and do not mint a fresh Idempotency-Key for the retry, which is a second purchase. For the others, read `detail` and take the different action it names; repeating the call returns the same 409 forever.

Validation failed

400 / 422 · validation_failed · do not retry

A field is missing or outside its allowed set — or, on a mutating verb, the `Idempotency-Key` header is absent, which is refused before the body is looked at.

What to do: Read `detail`. For the missing header: send `Idempotency-Key: <any value unique to this operation>` — a UUID is ideal — and send that same value verbatim on every retry of it. Note that FastAPI's own 422 body is NOT problem+json — see “Four failures that are not problem+json” below.

Payload too large

413 · payload_too_large · do not retry

The request body exceeded the cap for that path.

What to do: Upload materials through the materials endpoint, which carries the large-body cap; other paths do not.

Too many requests

429 · rate_limited · retryable

A brake tripped, and `bucket` says which: `edge` (the per-IP ceiling, charged before authentication), `principal` (your own credential's bucket, and the usual one), `route` (a per-route ceiling on top of it), or `spend_velocity` — which meters money rather than traffic. That last one arrives as `reason: "spend_velocity_exceeded"`: this credential reached its hourly limit on *credit-spending* calls (`spendVelocityLimit`, `spendVelocitySpent`, `spendVelocityWindowSeconds`, `resetAt`), and reads and uploads never count against it.

What to do: Honour `Retry-After` and `retryAfterMs` — they are the same number and never disagree — plus the `X-RateLimit-*` headers, which describe the per-credential bucket. Tune against the `bucket` you were actually given, not against the one you assume. ⚠️ One 429 is NOT a cooldown: `reason: "version_cap_reached"` is a hard per-deck ceiling and arrives with `retryable: false`. Read `retryable`, not the status code.

reason: spend_velocity_exceeded · retryable

The hourly ceiling on credit-spending calls for this credential. No credits were charged.

What to do: Wait for the window to roll, or ask the account owner to raise the limit.

reason: version_cap_reached · do not retry

A 429 that is not a cooldown: this deck reached its hard ceiling on versions (`max_versions`).

What to do: Start a new deck. This is the one 429 where backing off and retrying never works, which is why `retryable` is false here and true for every other 429.

Method not allowed

405 · method_not_allowed · do not retry

The path exists but does not accept this verb — `PUT /v1/decks` rather than `POST /v1/decks`, most often from a client that infers the method from the operation name.

What to do: Read the `Allow` response header: it lists every verb this exact path accepts. Creates are `POST`, reads are `GET`, and nothing on `/v1` is a `PUT`, `PATCH` or `DELETE` today.

Unsupported media type

415 · unsupported_media_type · do not retry

The gate reads three things — the filename's extension, the `Content-Type` you declare, and the file's first bytes — and every one of them has to agree with the other two. The extension must be on our allow-list (PDF, Office document, `csv`/`txt`/`md`, or `png`/`jpg`/`jpeg`/`webp`/`gif`/`heic` — `svg` and `html` are not). A `Content-Type` must be one we accept for that extension, unless you send `application/octet-stream` or none at all, which we treat as untrustworthy and ignore. And the bytes are sniffed, so the real format must match the extension, and must not contradict a `Content-Type` you did declare. Because the name counts as much as the content, renaming a file genuinely changes the answer in both directions: a real PNG sent as `report.pdf` is refused and the same bytes sent as `report.png` are accepted — and the reverse is equally true. What renaming cannot do is smuggle dangerous content in: a native executable or a shebang script is refused under every extension there is, and markup (HTML/SVG) is refused under every extension except `txt`, `md` and `csv`, where it is ordinary text rather than a disguise.

What to do: Send a supported document or image, named with the extension that matches its real bytes. If the three disagree, converting the file is the fix; renaming it only moves which check refuses it, unless the name was the thing that was wrong. `detail` names the disagreement — “unsupported file type .zip”, “content type … does not match a .pptx file”, “.png file content does not match its type”, “declared … contradicts the file's content” — which is the fastest way to spot a pipeline uploading the wrong artefact. Do not retry the identical bytes under the identical name: this is a verdict on the file, not on the request.

Not implemented yet

501 · not_implemented · do not retry

The operation is real and documented — and advertised by the MCP server — but this deployment has not shipped it. Some of the tools listed in the API reference land after the ones you already have.

What to do: Nothing is wrong with your request, your credential or your resource, so do not re-create anything and do not retry on a short loop. Treat the capability as absent for this run, fall back to a path that exists, and check the changelog: a 501 becomes a 2xx with no change on your side. If your agent surfaces failures to a human, say “not available yet”, never “not found”.

Public API disabled

503 · surface_disabled · retryable

The public surface kill switch is off. This affects reads too.

What to do: Retry after `retryAfterMs`, with backoff. This is an operator action, not a problem with your request; it is announced on the changelog feed when planned.

Temporarily unavailable

503 · service_unavailable · retryable

A dependency we need — the database, or the credential verifier — did not answer.

What to do: Retry after `retryAfterMs`, with backoff. A 503 is never a verdict on your credential; do not treat it as a sign-in failure.

Internal error

500 · internal_error · retryable

We broke. It is on our side.

What to do: Retry once, then tell us — and quote the `requestId` from the body (it is also the `X-Request-Id` header). That one string finds the request in our logs; a timestamp and an endpoint usually do not. Never send us your key.

FailureWhat arrives instead
422 from request validation`{"detail": [{"loc": …, "msg": …}]}` — FastAPI's own shape.
503 when the database is unreachableA plain JSON body from the shared handler, not problem+json.
429 raised before authenticationThe edge bucket answers before any `/v1` code runs; treat any 429 as rate limiting regardless of body.
413 from the body-size middlewarePure-ASGI and outside the sub-app, so it cannot be reshaped by a handler.