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.

error.ts
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
    }
  }
}
response.json
{
  "error": {
    "code": 503,
    "message": "no provider is available for this model right now",
    "metadata": {
      "error_type": "provider_overloaded",
      "reason": "no_provider_available"
    }
  }
}
response.json
{
  "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.

main.py
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"]
main.ts
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.url

Response codes

Status codes and what to do about them
HTTPerror_typeWhen it happensWhat to do
400invalid_requestThe body or a parameter is wrongFix the request
401authenticationKey missing, unknown or revokedCheck the header
402payment_requiredBalance or a ceiling will not cover itTop up, then retry
403permission_deniedAccount suspended, or the wrong credentialWrite to us
404not_foundNo such model or no such pathCheck the identifier
405invalid_requestWrong HTTP method for the pathUse POST or GET
429rate_limit_exceededText models: refused upstream without a return time, or extra usage asked for. Image models: the renderer rate-limited usRetry-After is set. On text models a scheduled repeat will not change it; for image models, wait it out and retry
500serverOur fault, message is genericRetry, then write to us
502provider_unavailableAn answer came back unusableRetry when you choose
503provider_overloadedNobody is free to run it, or every account is paused after a limitWait Retry-After, same request
504timeoutNo answer within the deadlineRetry, 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
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"}}}
main.py
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 r
main.ts
async 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
}
What each schedule is worth

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.

Typed error codes
error_typeHTTPWhat it meansRetry
invalid_request400The request itself is wrongNo
invalid_prompt400A message or the prompt is unusableNo
invalid_image400An attached data URL is not an imageNo
image_download_failed400A referenced image could not be fetchedAfter fixing the URL
authentication401The key is not usableNo
payment_required402The balance will not cover itAfter topping up
permission_denied403This key is not for this routeNo
not_found404No such model or pathNo
rate_limit_exceeded429Text models: refused upstream without a return time, or extra usage asked for. Image models: the renderer rate-limited usImage models: after Retry-After; text models: on your own schedule
server500An unexpected failure on our sideYes
provider_unavailable502An answer arrived but was unusableYour call
provider_overloaded503Nobody is free to run the request, or every account is paused after a limitAfter Retry-After
timeout504No answer within the deadlineYes

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 as coming_soon, or it is a text model sent to /api/v1/images, the one route that only draws. On chat/completions a 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 in Retry-After and send the same request again.
invalid_credentials
401. Covers a missing Authorization header, 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. The Allow header 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.

What an image request is refused for
SituationHTTPerror_typeChecked
Over 3840 px a side or 8.29 megapixels (8,294,400 px)400invalid_requestBefore the hold
Under 16 px on a side400invalid_requestBefore the hold
Pixels contradict the ratio or the tier400invalid_requestBefore the hold
prompt missing or empty400invalid_promptBefore the hold
A data URL that is not an image400invalid_imageBefore the hold
A reference image that will not download400image_download_failedBefore the hold
More than 16 reference images400invalid_requestBefore the hold
Transparent background the model cannot honour400invalid_requestBefore the hold
The renderer is busy or unavailable503provider_overloadedHold released
The renderer answered with something unusable502provider_unavailableHold released
The renderer did not answer in time504timeoutHold 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.

event: error
{
  "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" }
}
finish_reason: error
{
  "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

What the body never contains

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.

Message support