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 (including chat gate rejections, which also keep their structured payload under error.provider_specific_fields), top-level message on the flat shape, 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:
When a chat request is turned away by one of our pre-flight gates — unknown model, insufficient balance, region not served — the envelope carries the gate’s sentence in error.message and its code in error.code (unknown_model, insufficient_balance, region_not_served), with type and param as proper JSON values. The gate’s structured payload (did_you_mean, suggested_model, max_usage_cost_usd, key_remaining_usd, region, …) stays under error.provider_specific_fields, where earlier releases put everything, so code written against that keeps working. A gate with no sentence (the balance gate) repeats its code as error.message; render its payload fields instead. On the model-router endpoints (chat, completions, embeddings, responses and videos), error messages never carry our internals: no exception class names, file paths, engine names or hostnames. When a model backend’s own message can’t be shown without them, you get a generic message for the status instead. A 5xx, or an error inside a stream, carries its own message only when its code is one we document on this page; any other server-side failure gets the generic message. This check fails closed: text that merely looks internal is treated as internal. A message that mentions a name ending in .local, .internal or a similar private suffix (for example file.local), or a private, loopback, link-local or unique-local IP address (for example 10.0.0.5, fd00::2 or a bare ::), is replaced by the generic message too, even when it is harmless. 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/*; portal = the dashboard’s /api/* management routes, rather than the Inference API. 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.
The chat 402 and 403 rows share their envelope builder with the chat 404, which is verified live; provoking them needs an org that is out of credit or pinned to an unserved region.

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. Every such refusal is a 400 with code: "context_length_exceeded", so you can branch on the code rather than on the wording. param names the field that carries the prompt: "messages" on chat completions, "input" on /v1/responses and /v1/embeddings, and "prompt" on /v1/completions:
The refusal can come from two places, and both use this shape. When the model’s serving engine refuses the prompt, the message usually names the model’s maximum context length, as above; on the few engines that do not report it, the message names no token counts. When a model is served on more than one kind of hardware, the gateway can refuse an oversized request itself, before it reaches an engine; that message names no token counts, because the gateway sizes the request with an estimate rather than the engine’s tokenizer. Treat both the same way: shorten the prompt or lower the output cap, which is max_tokens or max_completion_tokens on chat completions and max_output_tokens on /v1/responses. A non-streamed request that sets no output cap is sized with its default of 2048 output tokens (none on reasoning models), so setting a smaller cap explicitly can also bring it under the limit. A streamed request is refused the same way, with the same 400 before any event is sent. 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”.

Image sent to a model that doesn’t accept images

Only some models can read an image. Send an image_url part (or input_image on /v1/responses) to a model whose input_modalities on GET /v1/models doesn’t list image, and the gateway refuses the request before it reaches the model, so you aren’t billed:
param names the field that carries the conversation: "messages" on chat completions, "input" on /v1/responses (an image inside a function_call_output counts too). The answer is deterministic and the model is the thing to change, so don’t retry: pick a model from the model discovery list whose input_modalities includes image.

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. The same rule holds when you stream. A stream has already started with a 200 by the time the model’s answer is known, so the rejection arrives as the stream’s last event instead of a status: the reasoning deltas you have already received stand, the prose the model produced instead of a call is withheld (up to a few thousand characters; a model that keeps writing past that is streamed from there on, and the verdict still ends the stream), and the stream ends with one error event carrying the same type, param and code as above, followed by data: [DONE]. When stream_options.include_usage is set, the usage chunk arrives ahead of the error event: the tokens the model generated are billed, as the answer was produced before it could be judged. On /v1/responses it is the flat error event ("type": "error" with code, message and param beside it, and a sequence_number continuing the stream’s). A named function is honoured only by calls to that function; a reply that calls a different tool, instead of or as well as the one you named, is rejected with the same code and a message naming the function you asked for. Treat that event exactly as you would the 400: change the request rather than retry it as-is. Here is how it looks on chat completions:
Non-streamed only, the same code also covers a forced reply that stopped at the output token limit before its tool call was complete (finish_reason: "length" on chat completions; status: "incomplete" with incomplete_details.reason: "max_output_tokens" on /v1/responses): the calls it carries are not one complete turn, so the reply is rejected rather than handed to your agent. Raise max_tokens (max_output_tokens on /v1/responses), or use tool_choice="auto". The exception is "required" on the models the gateway bounds to one call (GLM-5.2, MiniMax-M2.7, NVIDIA-Nemotron-3.5-Lightning-30B-A3B; see the tool use guide): there, a reply with several calls at the limit means the model kept repeating its call, and a larger budget would only let it run longer. That reply is rejected as an unsafe required-tool response instead, and the message does not mention the limit: use tool_choice="auto" or a named tool choice. On those models this check applies to streams too. A stream releases tool-call deltas as they arrive and cannot take them back, so a streamed reply cut off at the limit ends with its finish_reason and no error event; check finish_reason yourself before dispatching the calls.

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: error.code is 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 answers with error.code region_not_served, and its payload (including region) under error.provider_specific_fields. This is not the error you get for calling the wrong host. The two regions keep separate key stores, so a key presented to the endpoint it does not belong to fails authentication first and comes back 401 — see Regions & Data Residency. This 403 means the workspace and the stack serving it disagree, which should not arise in normal use: report it to support@flex.ai rather than retrying against the other host. A region is permanent once set, and that is enforced in the database — it cannot be changed from the dashboard, and we cannot change it for you either. If a workspace ended up in the wrong region, support will help you set a new one up in the right region instead.

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". For models, use the canonical id from GET /v1/models — we require the bare model name, not a provider-prefixed path.

Model in the catalog but not currently running

A model can be in our catalog and still not be running right now — we may have stopped it deliberately, it may have scaled down to zero after a quiet period, or the cluster hosting it may be having a problem. Requests for it come back 404 with code: "model_deactivated":
It’s a 404 rather than a 503 because we don’t spin a stopped model up to serve the request that found it stopped. Retrying the same call won’t get you a different answer on any timescale you’d want to wait for, so treat this as “pick a different model”, not “back off and try again”. GET /v1/models is the list to pick from. It’s built from the same signal this check uses, so the two tell the same story — though they’re each cached for a few seconds, so right after a model stops or starts you may briefly see one before the other catches up. If you get this error for a model the listing still shows, wait a few seconds and re-read the listing.

Model we can’t price

Rare, and a bug on our side rather than yours: pricing_unavailable means we couldn’t resolve a price for a media request, so we declined to run it rather than serve work we can’t bill. You were not charged.
It’s a 404 for the same reason as the case above: it takes a configuration fix on our side, so no amount of retrying clears it. Use a different model and tell us at support@flex.ai — we’d like to know.
Both of these moved on 2026-09-20: model_deactivated from 503 to 404, and pricing_unavailable from 500 to 404. If you special-cased either as a retryable 5xx, that branch is now dead — the error codes are unchanged, so matching on those still works.

410 — Gone

Video results have a one-hour, one-download pickup window:
Download a completed video promptly and persist it on your side if you need it later. A job belonging to another key uses the same unavailable response as an expired job, so one key cannot probe for another key’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. You don’t have to wait for a 429 to see your headroom. A successful non-streaming response from a key with an RPM limit carries two more headers, with a different prefix from the ones above: x-ratelimit-api_key-limit-requests (the key’s RPM ceiling) and x-ratelimit-api_key-remaining-requests (what’s left in the current window, after this request). Streaming responses don’t carry them. A 429 with code: "gateway_capacity_exhausted" (type: "capacity_error") is a different thing: not your key’s ceiling, but no capacity to take the request at that moment. It also carries Retry-After; retry after it. 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. A failed request that produced no output is not billed, even though its prompt was read. If the model had already produced output before the failure, the output we recorded for the request is billed along with the prompt. 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.

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 gateway unavailable, upstream key verification unavailable, and upstream gateway returned an unusable 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: backend_unavailable for a media backend we can’t reach, transcode_overloaded when audio processing is saturated, service_unavailable / auth_service_unavailable when key verification itself is down (we fail closed rather than serve an unverified request), balance_unavailable on chat when your balance couldn’t be checked (we refuse rather than run an unbilled request; it says nothing about your credit), and global_plane_unavailable when one of our own internal control-plane services was momentarily unreachable — typically a restart during a deploy, lasting seconds. stripe_tenant_not_configured means billing for your account’s region is not fully set up on our side. We refuse the operation rather than complete it against the wrong payment processor account, so nothing is charged. It needs an operator’s attention rather than a retry — contact support if you see it. spend_rollup_unavailable means a key’s or member’s spend over its window could not be read. We fail rather than show a zeroed figure that could be mistaken for unused allowance; retry shortly, and contact support if it persists. Both of these arrive nested, under detail, rather than flat:
Retry with backoff. Every 503 here is a dependency we expect back.

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.