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 and to an assistant you signed in over MCP, not to an API key, so a key from an account that has never bought credits is refused on its first spend — the balance you can see is the free ladder, and a key 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 (or sign in through an assistant instead of using a key): 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 reaches the web app and an assistant you signed in over MCP, not an API key. This key's account has never bought credits, so its first spend is refused before anything is reserved — nothing was charged, and the balance you can see is the free ladder, which a key does not draw from.

What to do: Buy credits, or sign in through an assistant instead of using a key. The body carries `cta: "buy_credits"`. Your first purchased credit unlocks this call, and every call after it; no retry or plan 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.