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.
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`.
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.
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.
| Failure | What arrives instead |
|---|---|
| 422 from request validation | `{"detail": [{"loc": …, "msg": …}]}` — FastAPI's own shape. |
| 503 when the database is unreachable | A plain JSON body from the shared handler, not problem+json. |
| 429 raised before authentication | The edge bucket answers before any `/v1` code runs; treat any 429 as rate limiting regardless of body. |
| 413 from the body-size middleware | Pure-ASGI and outside the sub-app, so it cannot be reshaped by a handler. |