API Reference
Errors
Every refusal arrives in the same body, with a typed code to branch on. The HTTP status is the same number as the one inside.
One body covers every kind of failure: a wrong key, a wrong model, a body that is not JSON, a path that does not exist. There is no detail field anywhere in this API — the refusal always sits under error, and it always carries a typed code. On the OpenAI-shaped routes the status and error.code are the same number, so either one can drive your branching; the Messages API routes wrap the same information in the native envelope their own client library expects, which is the only difference and is shown below.
What a status cannot tell you on its own is whether a 200 means success. Once a response has started, the status is already sent and cannot be taken back, so a later failure on a streamed text answer arrives inside the stream while the status still reads 200 — see errors in a stream.
The error body
metadata.error_type is always present. It is the field to switch on: the status alone collapses distinct causes (a coming-soon model and a malformed body are both 400), and the message is written for a human, not for a parser.
type ErrorResponse = {
error: {
code: number // the same number as the HTTP status
message: string // one English sentence, safe to show a user
metadata: {
error_type: string // always present: the code to branch on
reason?: string // present when we have a narrower cause
[key: string]: unknown
}
}
}{
"error": {
"code": 503,
"message": "no provider is available for this model right now",
"metadata": {
"error_type": "provider_overloaded",
"reason": "no_provider_available"
}
}
}{
"type": "error",
"error": {
"type": "authentication_error",
"message": "invalid api key",
"error_type": "authentication"
},
"request_id": null,
"metadata": { "reason": "invalid_credentials" }
}The Messages API routes are the one exception to the outer shape, and only to the outer shape. There the body is the native error envelope that the matching client library expects, so that library reports our refusal instead of failing to parse it. The typed code rides along inside error.error_type, because the native error.type is coarser — several distinct causes collapse into api_error there. request_id is always present, and it is null for failures that happened before the request was accepted for execution.
Reading an error
Check the status, then read the three fields that matter. Do not match on the text of message: it is meant to be shown or logged, and it changes when we can word it better.
import requests
r = requests.post(
"https://api.gtokens.io/api/v1/chat/completions",
headers={"Authorization": "Bearer sk-sl-your-key"},
json={
"model": "openai/gpt-5.4-image-2",
"messages": [{"role": "user", "content": "A lighthouse at dawn"}],
},
timeout=300,
)
if r.status_code >= 400:
# Always "error", never "detail" — including 404 and a malformed body.
err = r.json()["error"]
raise SystemExit(
f'{err["code"]} {err["metadata"]["error_type"]}: {err["message"]}'
)
frame = r.json()["choices"][0]["message"]["images"][0]["image_url"]["url"]const r = await fetch('https://api.gtokens.io/api/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: 'Bearer sk-sl-your-key',
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'openai/gpt-5.4-image-2',
messages: [{ role: 'user', content: 'A lighthouse at dawn' }],
}),
})
const body = await r.json()
if (!r.ok) {
// Always "error", never "detail" — including 404 and a malformed body.
const { code, message, metadata } = body.error
throw new Error(code + ' ' + metadata.error_type + ': ' + message)
}
const frame = body.choices[0].message.images[0].image_url.urlResponse codes
| HTTP | error_type | When it happens | What to do |
|---|---|---|---|
| 400 | invalid_request | The body or a parameter is wrong | Fix the request |
| 401 | authentication | Key missing, unknown or revoked | Check the header |
| 402 | payment_required | Balance or a ceiling will not cover it | Top up, then retry |
| 403 | permission_denied | Account suspended, or the wrong credential | Write to us |
| 404 | not_found | No such model or no such path | Check the identifier |
| 405 | invalid_request | Wrong HTTP method for the path | Use POST or GET |
| 429 | rate_limit_exceeded | Text models: refused upstream without a return time, or extra usage asked for. Image models: the renderer rate-limited us | Retry-After is set. On text models a scheduled repeat will not change it; for image models, wait it out and retry |
| 500 | server | Our fault, message is generic | Retry, then write to us |
| 502 | provider_unavailable | An answer came back unusable | Retry when you choose |
| 503 | provider_overloaded | Nobody is free to run it, or every account is paused after a limit | Wait Retry-After, same request |
| 504 | timeout | No answer within the deadline | Retry, ask for less |
Scroll the table horizontally for the last two columns.
Nothing above is charged. Money is only taken for a request that produced an answer: refusals that happen before execution never touch the balance, and a failure during execution releases the amount held for it.
The Retry-After header
Every 429 and every 503 carries Retry-After, always as a whole number of seconds and never as a date. It is never zero and never negative: when the wait cannot be computed, the header says 30 rather than inviting you to guess. Most client libraries honour it on their own; a raw fetch does not.
HTTP/1.1 503 Service Unavailable
Retry-After: 30
Content-Type: application/json
{"error":{"code":503,"message":"no provider is available for this model right now","metadata":{"error_type":"provider_overloaded","reason":"no_provider_available"}}}import time
import requests
def call(body, tries=3, image=False):
for attempt in range(tries):
r = requests.post(
"https://api.gtokens.io/api/v1/chat/completions",
headers={"Authorization": "Bearer sk-sl-your-key"},
json=body,
timeout=300,
)
# 503 is worth waiting out everywhere; 429 only on image models —
# on a text model the same request comes back refused (see the note).
if r.status_code != 503 and not (r.status_code == 429 and image):
return r
# Whole seconds, never a date. Absent only if you are not on 429/503.
wait = int(r.headers.get("Retry-After", 30))
time.sleep(wait)
return rasync function call(body: unknown, tries = 3, image = false): Promise<Response> {
let last: Response | undefined
for (let attempt = 0; attempt < tries; attempt++) {
last = await fetch('https://api.gtokens.io/api/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: 'Bearer sk-sl-your-key',
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
// 503 is worth waiting out everywhere; 429 only on image models —
// on a text model the same request comes back refused (see the note).
if (last.status !== 503 && !(last.status === 429 && image)) return last
// Whole seconds, never a date. Absent only if you are not on 429/503.
const wait = Number(last.headers.get('Retry-After')) || 30
await new Promise((done) => setTimeout(done, wait * 1000))
}
return last as Response
}Only 429 and 503 carry a moment to come back, and the two are not equal. A 503 is worth waiting out on every model. A 429 depends on the model: on image models it is a real rate limit — wait it out and retry, as above; on text models it means the request was refused without a return time, or extra usage was asked for, and sending the same request again will not change the answer, header or not. A 500, 502 or 504 has no known duration, so there is no honest number to put in a header — retry those on your own backoff, with a cap on attempts. The rest of the 4xx range should not be retried at all: the same request will be refused the same way.
Error types
The vocabulary is closed: these are all the values error_type can take. An unfamiliar status can never produce an unfamiliar code either — a 4xx we did not map arrives as invalid_request and anything else as server.
| error_type | HTTP | What it means | Retry |
|---|---|---|---|
| invalid_request | 400 | The request itself is wrong | No |
| invalid_prompt | 400 | A message or the prompt is unusable | No |
| invalid_image | 400 | An attached data URL is not an image | No |
| image_download_failed | 400 | A referenced image could not be fetched | After fixing the URL |
| authentication | 401 | The key is not usable | No |
| payment_required | 402 | The balance will not cover it | After topping up |
| permission_denied | 403 | This key is not for this route | No |
| not_found | 404 | No such model or path | No |
| rate_limit_exceeded | 429 | Text models: refused upstream without a return time, or extra usage asked for. Image models: the renderer rate-limited us | Image models: after Retry-After; text models: on your own schedule |
| server | 500 | An unexpected failure on our side | Yes |
| provider_unavailable | 502 | An answer arrived but was unusable | Your call |
| provider_overloaded | 503 | Nobody is free to run the request, or every account is paused after a limit | After Retry-After |
| timeout | 504 | No answer within the deadline | Yes |
Scroll the table horizontally for the last two columns.
The reason field
error_type names the category, and categories are coarse on purpose: a client that knows only the standard vocabulary must still land in the right branch. When our own cause is narrower than the category, it travels alongside in metadata.reason. Two refusals can be 400 invalid_request and still need completely different actions from completely different people, and reason is where that difference lives.
- model_not_available
400. The model is in the catalogue but is not being served — either it is listed ascoming_soon, or it is a text model sent to/api/v1/images, the one route that only draws. Onchat/completionsa text model answers. Pick another model, or the other route.- model_misconfigured
400. The model is listed as available but has no executor behind it. This one is ours to fix, not yours — the request is worth reporting rather than rewriting.- no_provider_available
503. Everything that can run this model is busy. Wait the number of seconds inRetry-Afterand send the same request again.- invalid_credentials
401. Covers a missingAuthorizationheader, an unknown key and a revoked one — they are deliberately not told apart in the response.- forbidden
403. The credential is real but the request is not allowed with it: an account that has been suspended, or an account-area token sent to a generation route and the reverse.- method_not_allowed
405. The path exists but not with this HTTP method. TheAllowheader lists the ones it has.
Do not branch on reason alone. It is absent whenever the canonical code already says everything there is to say, so code that keys off it without a fallback breaks on the most ordinary errors we produce.
Why a request is refused
The balance will not cover it
A request that the balance cannot pay for is refused with 402 payment_required before anything is generated, so it costs nothing. The check happens twice: a cheap look at the account when the request arrives, and then an atomic hold for the price of what was asked. The hold is sized by the request, not by the result — a large frame is held at a large frame's price, or an account with a dollar on it could order one on credit. When execution then fails, the hold is released in full; when it succeeds, the difference between the hold and the real charge comes back.
A spending ceiling comes back with the same status, and message is what tells the two apart: insufficient balance against daily limit reached or monthly limit reached. A suspended account is a different case and is refused earlier, with 403 permission_denied and the message tenant suspended. Balance, ceilings and holds
The model cannot answer
A name we do not know is 404 not_found. A name we do know but do not serve is 400 with reason: model_not_available — that covers a catalogue entry marked coming_soon and a text model sent to /api/v1/images, the edit route; on chat/completions a text model answers rather than being refused. A model that is listed as available but has nothing behind it is 400 with reason: model_misconfigured.
None of these is ever answered by something else. Substituting a similar model would be invisible in the response — same shape, same status, plausible output — and you would discover it in a benchmark weeks later, if at all. A refusal you can read is worth more than an answer you cannot trust. See what is served today
There is nobody free to run it
When everything that can run the model is busy, the request comes back 503 provider_overloaded with reason: no_provider_available and a Retry-After you can act on. This is a wait, not a dead end: send the identical request again after the header's delay. What it is not is a redirection — a busy model is refused rather than answered by a different one, so the thing you have to handle is a wait you can see, not an answer you did not order.
On text models this is also how an exhausted upstream limit arrives. The account that hit its limit is taken out of rotation until the moment the provider named, and the request moves to the next account of the same channel; when none is left, the answer is 503 provider_overloaded with Retry-After set to when the nearest one comes back — not 429. A 429 rate_limit_exceeded is what you see only when the provider refused without naming a time to come back, or asked for extra usage: those are not waits, and a repeat on a schedule will not change them. Both text routes behave the same — /v1/messages and text on chat/completions share one core.
Refusals on image requests
Everything an image request can be refused for is checked before any money is held. The order matters more than the codes: a request rejected in this table has cost nothing, and a request that got past it is paid for even if the delivery step then goes sideways.
| Situation | HTTP | error_type | Checked |
|---|---|---|---|
| Over 3840 px a side or 8.29 megapixels (8,294,400 px) | 400 | invalid_request | Before the hold |
| Under 16 px on a side | 400 | invalid_request | Before the hold |
| Pixels contradict the ratio or the tier | 400 | invalid_request | Before the hold |
| prompt missing or empty | 400 | invalid_prompt | Before the hold |
| A data URL that is not an image | 400 | invalid_image | Before the hold |
| A reference image that will not download | 400 | image_download_failed | Before the hold |
| More than 16 reference images | 400 | invalid_request | Before the hold |
| Transparent background the model cannot honour | 400 | invalid_request | Before the hold |
| The renderer is busy or unavailable | 503 | provider_overloaded | Hold released |
| The renderer answered with something unusable | 502 | provider_unavailable | Hold released |
| The renderer did not answer in time | 504 | timeout | Hold released |
Scroll the table horizontally for the last two columns.
The ceiling is on what you ask for, not on what comes back, and it is deliberately checked before the hold: a single oversized number would otherwise be paid for and then spend real time being resized. Reference images are fetched before the hold for the same reason — a link that does not open is your typo, not a purchase.
After a batch has been paid for, nothing about delivery can take it away. A frame that will not resize to the requested size, or will not convert to the requested format, arrives as it came instead of failing the request, and the rest of the batch is untouched. The media_type in the response always describes the bytes actually sent. Sizes, tiers and what a frame costs
Errors in a stream
Streamed image requests are finished before the first byte leaves, so everything that can go wrong still arrives as a real status with the body described above. Text is different, on both routes that stream it — the Messages API and chat/completions: the status is committed as soon as the request is accepted upstream, which is before the first token exists. A failure before that point is a normal HTTP error in the envelope of that route. A failure after it cannot be — the status is spent.
So a mid-stream failure arrives inside the stream, closes it, and the status stays 200. The shape follows the route. On the Messages API it is the data: payload of an event: error frame in the native envelope, and it is the last frame. On chat/completions it is a chunk with finish_reason: "error", an empty delta and the usual error body of the OpenAI-shaped routes beside choices, followed by data: [DONE] — and there the stream ends. Watch for those; a stream that simply stops and a stream that failed are otherwise the same thing from the outside.
{
"type": "error",
"error": {
"type": "overloaded_error",
"message": "provider is temporarily unavailable, retry later",
"error_type": "provider_overloaded"
},
"request_id": "gen-8f2c1d0a4b6e7f31",
"metadata": { "reason": "no_provider_available" }
}{
"id": "gen-8f2c1d0a4b6e7f31",
"object": "chat.completion.chunk",
"created": 1772668800,
"model": "anthropic/claude-sonnet-5",
"choices": [{ "index": 0, "delta": {}, "finish_reason": "error" }],
"error": {
"code": 503,
"message": "provider is temporarily unavailable, retry later",
"metadata": {
"error_type": "provider_overloaded",
"reason": "no_provider_available"
}
}
}Either one is the same envelope as a non-streaming refusal on that route, with one addition: the request identifier is filled in — request_id on the Messages API, the chunk's id on chat/completions — because by this point the request exists in our records under it. Quote it if you report the failure. There is no Retry-After here: the headers are long gone, so for a retryable code back off on your own schedule. What the model produced before the failure is billed, in both shapes. How streams start and end
No trace of how or where your request was run: no upstream address, no file path, no account identifier, no verbatim text of an exception we caught. Those go to our log, which is where they are useful, and a filter strips anything address-shaped or path-shaped from a message before it is written. On a 500 the message is generic by design — the specifics are always about our own plumbing, and they are ours to act on.
If it does not add up
A charge you cannot explain, a refusal that keeps coming back, a 500 that survives a retry — write to us. Include the time in UTC, the route, the model identifier, the status and the error_type you got, and request_id if the error carried one. That is enough to find the request; a description without a time rarely is.
Before writing, it is often faster to look at the numbers yourself: credits shows what has been added and what has been spent, and key shows the same for the key that is making the calls. A successful answer on the image routes also carries its own price in usage.cost, so a surprising total can usually be traced to a specific request rather than guessed at.
