Skip to main content
Branch on the HTTP status code, not the body. The status is what the OpenAI SDKs map to exception classes, and it’s stable across every layer of our stack. Where a human-readable explanation exists it’s safe to surface in your UI, but don’t assume it’s there. It’s error.message on the envelope, top-level message on the flat shape, error.provider_specific_fields.message on a chat gate rejection, detail on the bare shape and on one flat body — and a few carry no explanation at all. Fall back to the status plus the error string code. Which shape you get depends on which layer turned you away, so don’t assume error is an object — or that it’s present at all. Endpoints we serve directly (/v1/images, /v1/videos, /v1/audio, and parts of /v1/flex/*) answer in either the OpenAI envelope or a flatter shape where error is a string code with the context beside it. Chat and completions run through our model router, which always answers in the envelope. And the 502s on GET /v1/flex/key and GET /v1/models return a bare detail string with no error key:
The gate-rejection shape is the one that catches people out. When a chat request is turned away by one of our pre-flight gates — unknown model, insufficient balance, region not served — the model router wraps our structured payload instead of replacing the envelope with it. So error.message is just the bare code, error.type and error.param come back as the string "None" rather than JSON null, error.code is the HTTP status as a string, and everything you’d actually want to show a user sits under error.provider_specific_fields. Reach for error.message there and you get the word unknown_model, not a sentence. We’re converging these shapes. Until then, treat the status as the contract and every field in the body as optional — read error defensively (object, string, or absent), and when it’s an object, check provider_specific_fields before concluding there’s no detail.

What we return

The Where column matters: code is not consistent across endpoint families, so switch on the status and treat code as a debugging aid, not a contract. Where reads as: chat = /v1/chat/completions, /v1/completions; media = /v1/images, /v1/videos, /v1/audio, with audio and video called out where a code belongs to only one of them; flex = /v1/flex/*. A code of null matches OpenAI, which reserves non-null codes for a narrow set. The 500 row is the exception: we don’t author those bodies, so treat its type and code as unstable.
‡ These two rows are derived from the gate they share with the chat 404 rather than captured from a live response, because provoking them needs an org that is out of credit or pinned to an unserved region. The status and the provider_specific_fields payload are certain; treat the exact type and code strings as very likely rather than verified.

400 — Bad request

Malformed body, or a field value we can’t serve. param names the offending field when we can identify one.
A 400 is deterministic — retrying the same request fails the same way. Change it or fail the call.

Prompt longer than the context window

Refused before any tokens are generated, so you aren’t billed. The message comes from the serving engine:
Chat-plane 400s come back from the serving engine through our model router, which prefixes message with its own exception class and leaves type and param as null. Read past the prefix — the sentence after it is the engine’s. Read the limit from context_length on GET /v1/models (model discovery) rather than hard-coding it — a model’s window changes when we move it to different hardware.
The window covers the prompt and the completion. A max_tokens on top of a prompt that nearly fills the window can still be refused.

Unfetchable or undecodable image_url

A URL we can’t fetch, or bytes we can’t decode, is your fault rather than an outage — so it’s a 400, not a 500:
Check the URL is publicly reachable, returns image bytes, and uses an http(s) or data scheme. Genuine gateway failures on an image request still return 500, so treat this 400 as “fix the URL”.

Forced tool_choice the model didn’t honor

Pin tool_choice to required or a named function and get no tool call back, and we fail the request rather than return a toolless answer your agent loop would mistake for a result:
Retry with tool_choice="auto", or use a model that supports forced tool selection. It’s a 400 rather than a 5xx so a retrying agent changes the request instead of looping. See tool use.

401 — Unauthorized

Check the Authorization: Bearer sk-… header. If the key looks right it may be revoked or expired — issue a new one from the dashboard.
Don’t branch on type or code for a 401. Three endpoint families report the same rejection differently today — the media plane uses missing_api_key / invalid_api_key, /v1/flex/* uses missing_authorization / malformed_authorization and switches type to authentication_error for a bad key, and /v1/models returns type: "auth" with no code field at all. Branch on the 401 status. We’re unifying these onto one shape, so anything you match on the body today is liable to change.
The OpenAI SDKs are unaffected — they map 401 to their authentication-error class by status code, not by body — so this only matters if you’re calling the API over raw HTTP. Note the /v1/models case in particular: reading error.code there gets you None, not a string. Some 401s also carry a doc_url pointing at the dashboard page that fixes them.

402 — Payment required

Four causes share this status; message tells them apart — credit exhausted, monthly spend cap reached, account on hold, or not provisioned for billing.
Chat and completions have one more: before generating, we check the request’s worst-case cost against your remaining balance. That one is a gate rejection, so it arrives in the wrapped form — error.message is the bare string insufficient_balance, and the actionable content is under error.provider_specific_fields, as max_usage_cost_usd (what the request could cost) and key_remaining_usd (what’s left). Render those rather than looking for a sentence. Top up on the billing page, or ask an org admin to raise the cap. The same key resumes working — nothing needs reissuing. See billing & quotas.

403 — Forbidden

Two causes, told apart by code.

Region not served

Your org is pinned to a data region this endpoint doesn’t serve, so we decline rather than move your data out of it. On the media endpoints that’s a plain envelope:
On chat and completions the same gate is wrapped, so look for region_not_served under error.provider_specific_fields rather than at error.code. Call the endpoint for your region. A region is immutable once set, so if the pinning is wrong contact support@flex.ai — it can’t be changed from the dashboard.

Blocked key

On /v1/models, a key an administrator has blocked returns 403 with type: "auth" and no code. Contact support@flex.ai — a blocked key can’t be unblocked from the dashboard.

404 — Not found

On chat and completions, an unrecognized model id is a gate rejection, so the did_you_mean list arrives nested under error.provider_specific_fields:
When a checkpoint we no longer serve has a declared replacement, that inner object also carries suggested_model — a deterministic pointer, not a spelling guess. A media model that exists but isn’t currently available uses the envelope with code: "model_not_found". Async jobs report job_not_found:
For models, use the canonical id from GET /v1/models — we require the bare model name, not a provider-prefixed path. For jobs, note that retention is 24 hours after completion, and a job belonging to another key reads as not-found by design so one key can’t probe for another’s work.

413 — Payload too large

A speech-to-text upload over the limit. max_size_mb always reports the limit, so read it from there rather than hard-coding a number:
We check twice — once against Content-Length before reading the upload, and once after, in case the header understated it. The second check omits message, so this is one of the bodies where you need the error code and max_size_mb rather than a sentence. Split or downsample the audio. See audio.

422 — Unprocessable content

The body parsed but a field is wrong. param names it, and code is invalid_request_body:
Speech-to-text has its own 422 for the wrong content type, and it’s the one body on the whole API that puts its explanation in detail rather than message:
A 400 on the same endpoints means the body didn’t parse at all — wrong content type or malformed JSON — whereas a 422 means it parsed and a field failed validation.

429 — Too many requests

Over the per-key requests-per-minute ceiling.
x-ratelimit-reset-requests is a duration, not a Unix timestamp. Parse it as seconds-with-a-trailing-s, or use Retry-After, which carries the same number as a bare integer.
Back off until Retry-After. If you hit the ceiling consistently, request a tier upgrade from support@flex.ai. See concurrency & rate limits. GET /v1/models throttles separately, on its own per-IP and global counters rather than your key’s RPM, and reports it as type: "rate_limit" with no code. Cache the catalog instead of polling it — it changes rarely.

The other 429: a flood limit at the edge

The shape above is the per-key limit, and it is the one you will meet in normal use. There is a second, much higher ceiling applied per source IP address at our edge proxy, before a request reaches the API at all. It exists to absorb floods and is set far above any legitimate client’s traffic — you should never see it. Because the proxy enforces it rather than the API, its 429 carries different headers, and the names are close enough to the ones above to be mistaken for them: An edge 429 looks like this in full:
Match rate-limit headers exactly, not by prefix. x-ratelimit-reset and x-ratelimit-reset-requests are different headers with different value formats — one a bare integer, the other a duration string — so a loose match will parse one as the other. And because the edge 429 has no Retry-After, treat that header’s absence as “back off using x-ratelimit-reset, or your own exponential backoff” — never as “retry now”.
If you are seeing edge 429s, retrying harder will not help and backing off will: either you are sending genuinely extreme volume from one address, or many of your clients share a single egress IP (an office NAT or VPN gateway) and are counted as one caller. Get in touch at support@flex.ai rather than tuning your retry loop.

500 — Internal server error

An unexpected failure in our gateway or a model backend. We don’t shape these bodies, so read message and treat type/code as unstable. Retry with backoff. We don’t support idempotency keys, so only retry requests you’re willing to have execute twice. Persistent 500s mean an incident — check status.flex.ai. One 500 is ours and deliberate: pricing_unavailable on a media request means we couldn’t resolve a price for it, so we declined to run it rather than serve work we can’t bill. It’s safe to retry, and you were not charged.

502 — Bad gateway

Only GET /v1/flex/key and GET /v1/models. A component inside our gateway that these two endpoints depend on was unreachable, rejected our own credentials, or answered with a body we couldn’t use. We fail closed on all three rather than serve a result we can’t stand behind — an unverified key, or a credit figure we’re not sure of. This is the one status that returns a bare detail string with no error key, so a client that reaches for error.message gets undefined here:
The detail values are upstream LiteLLM unavailable, upstream LiteLLM auth unavailable, and upstream LiteLLM malformed response. They’re diagnostic, not a contract — don’t branch on them. Retry with backoff. Both endpoints that return a 502 are read-only lookups, so retrying is always safe. Persistent 502s mean an incident — check status.flex.ai.

503 — Service unavailable

code says which dependency: model_deactivated for a model an administrator disabled, backend_unavailable for a media backend we can’t reach, transcode_overloaded when audio processing is saturated, and service_unavailable / auth_service_unavailable when key verification itself is down (we fail closed rather than serve an unverified request).
Retry with backoff — except for model_deactivated, which won’t clear on a retry timescale. Fail over to another model from GET /v1/models instead.

504 — Gateway timeout

Only /v1/flex/*. A usage query ran longer than the gateway allows, reported as type: "gateway_timeout" with code: "usage_timeout".
Narrow the time range and retry. This is a read-only query, so retrying is always safe.