Errors
One envelope, eight codes, and the request id that identifies the call.
Every 4xx and 5xx arrives in the same envelope: a machine-readable code, a message written for a person, and the request_id of the call that failed. Eight codes are published, each with exactly one HTTP status. Branch on the code and the status, never on the message, which is written to be read and changes when a better wording is found.
The envelope
{
"error": {
"code": "insufficient_credits",
"message": "Balance is 0.02 USD; this request reserves 0.42 USD.",
"request_id": "req_7c41d9be"
}
}
The codes
| STATUS | CODE | MEANING |
|---|---|---|
| 400 | invalid_request | Malformed body, or a parameter out of bounds |
| 401 | invalid_api_key | Key missing, malformed or revoked |
| 402 | insufficient_credits | Add credits before retrying |
| 404 | not_found | No such id. The media routes only, see Errors |
| 404 | model_unavailable | No such id, on every other lookup. Job, request, model slug |
| 409 | model_unavailable | No live endpoint can serve that model |
| 410 | gone | It existed and has expired. Do not retry, produce it again |
| 429 | rate_limited | Back off using the Retry-After header |
| 503 | upstream_unavailable | Upstream outage. Safe to retry |
400 invalid_request
The body did not parse, or a value is out of bounds. Malformed JSON, n: 99, a negative duration, an input that is not a string or an array of them. The message names what was wrong. Nothing was routed and nothing was charged, so the fix is always in your request; sending it again unchanged produces the same 400.
401 invalid_api_key
The key is missing, malformed, revoked or expired. Retrying will not help. Check the header first (Authorization: Bearer sk_live_…, or the x-api-key header every /v1 route also accepts), then mint a replacement key, deploy it, and revoke the old one in that order. A revoked key can keep working for a few seconds while a cached verification expires; it does not keep working longer than that. If the key service itself is unreachable the answer is a 503 rather than a 401, so a valid key is never reported as a bad one.
402 insufficient_credits
The key is fine; the money is not. The most a request could cost is reserved before it runs, so this fires when the balance minus what is already held cannot cover the worst case of this call. A request can be refused while balance still looks positive. Top up, or wait for the requests in flight to settle and release the difference. A suspended account answers 402 as well, and the message says which of the two it is.
404 not_found
The id names nothing you can read. A file id that was never minted and one belonging to another account answer identically and deliberately. Telling the two apart would confirm that somebody else's asset exists. Check the id you were handed; do not retry, because nothing about the answer changes with time.
Branch on the status as well as the code here. not_found is raised by name only on the media routes: GET /v1/files/{id} and GET /v1/videos/{job_id}/content. Every other id lookup that misses, including a job id, a request id on GET /v1/generation and an unknown model slug, answers 404 with the code model_unavailable. The status is the reliable half on this one; the reference block for each route names the code it actually sends.
409 model_unavailable
The model is listed but nothing can serve it at this moment: it is still coming_soon, or nothing behind it is healthy right now. A slug that does not exist at all answers 409 too. GET /v1/models shows which models are available right now. On a chat completion you can also send models: ["first", "second"]: each is tried in turn and the request only fails when none of them is servable.
410 gone
It was real, it was yours, and it is not coming back. Generated media is proxied rather than stored, so a file URL stops working once the reference expires, or sooner, if the origin drops its own copy first. Do not retry this one. Unlike a 503 it will never begin working again: produce the generation afresh, and download media promptly rather than treating a file URL as durable storage.
429 rate_limited
You are over a ceiling. Read Retry-After, wait that many seconds, and send the request again. Do not retry immediately, and never in a tight loop. X-Routehook-Limit-Type says which ceiling you hit: requests refills on a clock, concurrency does not, and the difference decides whether waiting is the right response at all. See rate limits for the whole meter.
503 upstream_unavailable
Every attempt at serving the request failed, or something on our side did. It is safe to retry: nothing was delivered, so the reservation was released and the balance was not touched. Back off before retrying rather than firing again at once, by the time you see this, every target in the chain has already been tried and failed, and an immediate retry mostly repeats that work.
Which errors are worth retrying
- 400, 401, 402, 404, 409, 410. An identical request fails identically. Change something first, and for 410 that means producing the media again.
- 429. Retry after
Retry-Afterseconds, then back off exponentially if it repeats. - 503. Retry with backoff and jitter. Nothing was billed, so a retry does not pay twice.
- Any 2xx that arrived is final. A response you received is a response you were charged for.
Every error carries a request id
request_id is in every error body, and the same id is on the X-Routehook-Request-Id header of a successful call. It is the one thing that identifies a single call out of a log of millions: quote it in a support report and it is found in seconds, describe the call in prose and it may not be found at all. Pass it to GET /v1/generation?id=req_… to see what that call cost and how many attempts it took. Failures raised before the request ever reaches a controller (a bad key, for instance) are given an id as well, so there is never an error without one.
Errors mid-stream
A streamed response commits at its first byte. Once a token is on the wire the status is already 200 and the headers are long gone, so a failure after that point cannot be an HTTP error. It arrives as a frame inside the stream carrying the same envelope, followed immediately by data: [DONE]: the terminator every OpenAI-compatible client already stops on. A failure before the first byte is an ordinary JSON error with a real status; the split is at the headers, not at the request.
data: {"id":"req_7c41d9be","object":"chat.completion.chunk","model":"openai/gpt-4o-mini","choices":[{"index":0,"delta":{"content":"The"}}]}
data: {"error":{"code":"upstream_unavailable","message":"The upstream closed the connection.","request_id":"req_7c41d9be"}}
data: [DONE]
What a broken stream costs
The tokens that arrived are charged and the rest of the reservation is released. A stream that failed before any usage was reported settles at nothing. The error frame carries the request id, so GET /v1/generation?id=req_… gives the exact charge without guessing from the text you received.
Handling both failure paths
async function stream(body) {
const res = await fetch("https://api.routehook.ai/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ ...body, stream: true }),
});
// Before the first byte: an ordinary status and a JSON envelope.
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${error.code}: ${error.message} (${error.request_id})`);
}
const decoder = new TextDecoder();
let buffer = "";
for await (const chunk of res.body) {
buffer += decoder.decode(chunk, { stream: true });
const frames = buffer.split("\n\n");
buffer = frames.pop() ?? "";
for (const frame of frames) {
const data = frame.replace(/^data: /, "");
if (data === "[DONE]") return;
// After the first byte: the same envelope, arriving as a frame.
const event = JSON.parse(data);
if (event.error) {
throw new Error(`${event.error.code} (${event.error.request_id})`);
}
process.stdout.write(event.choices[0]?.delta?.content ?? "");
}
}
}