# Routehook — full documentation > Every documentation and policy page on routehook.ai, as one Markdown file. The index, with a link per page, is at https://routehook.ai/llms.txt. Each page is also served on its own at its canonical path with `.md` appended. Generated 2026-10-16. Prices and model availability change; `GET /v1/models` and `GET /v1/credits` are the authoritative answers for those. --- # Routehook. Integration brief You are wiring an application to Routehook, an OpenAI-compatible gateway that reaches many model vendors through one key and one balance. Follow this document exactly. Do not guess at URLs, header names, model ids or error codes, every one you need is written down here. ## The whole integration Routehook speaks the OpenAI wire format. If the project already uses an OpenAI-compatible client, change two values and stop: - **Base URL:** `https://api.routehook.ai/v1` - **API key:** the value of the `ROUTEHOOK_API_KEY` environment variable Do not rewrite request or response handling. Do not add an SDK. The request and response shapes are the ones the OpenAI client already produces and parses. ```ts import OpenAI from "openai"; const client = new OpenAI({ baseURL: "https://api.routehook.ai/v1", apiKey: process.env.ROUTEHOOK_API_KEY, }); ``` Raw HTTP, if no client is in use: ```bash curl https://api.routehook.ai/v1/chat/completions \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"","messages":[{"role":"user","content":"Hello"}]}' ``` ## Authentication Send `Authorization: Bearer ` on every request. The one exception is `POST /v1/messages` (the Anthropic-compatible route), which takes `x-api-key: ` instead. Never put the key in source, in a client bundle, or in a URL. Read it from the environment. Keys are shown once at creation and cannot be retrieved again. ## Choosing a model `model` takes a slug. **Do not invent one and do not copy one from another provider's documentation.** Fetch the live list and pick from it: ```bash curl https://api.routehook.ai/v1/models -H "Authorization: Bearer $ROUTEHOOK_API_KEY" ``` Slugs look like `vendor/model`. A slug that is not live answers `409 model_unavailable` rather than falling back silently. ## Endpoints | Purpose | Route | | --- | --- | | Chat completions | `POST /v1/chat/completions` | | Anthropic-style messages | `POST /v1/messages` | | Text completions | `POST /v1/completions` | | Responses API | `POST /v1/responses` | | Embeddings | `POST /v1/embeddings` | | Image generation | `POST /v1/images/generations` | | Speech synthesis | `POST /v1/audio/speech` | | Transcription | `POST /v1/audio/transcriptions` | | Document reranking | `POST /v1/rerank` | | Video generation (async) | `POST /v1/videos/generations` | | Any queued model (async) | `POST /v1/queue/` | | Poll a job | `GET /v1/queue/requests//status` | | Fetch a job's result | `GET /v1/queue/requests/` | | Cancel a job | `PUT /v1/queue/requests//cancel` | | List this account's jobs | `GET /v1/jobs` | | Fetch generated media | `GET /v1/files/` | | Live model list | `GET /v1/models` | | Remaining balance | `GET /v1/credits` | | What one call cost | `GET /v1/generation?id=` | ## Video and other long jobs are asynchronous. Do not wait on them This is the part that is not OpenAI-shaped, and the part you will get wrong if you assume it is. **Video generation does not return a video.** It returns a job. A model in the `video` category — check with `GET /v1/videos/models` — cannot be called through `POST /v1/chat/completions` or any other synchronous route, and no connection is held open for the minutes it runs. Submit, and read the status code: ```bash curl -X POST https://api.routehook.ai/v1/videos/generations \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "google/veo-3.1", "prompt": "drone shot over a harbour at dawn", "duration": 5 }' ``` That answers **202**, not 200, with the job and the URLs that follow it: ```json { "request_id": "job_ab12cd34", "status": "IN_QUEUE", "queue_position": 3, "status_url": "https://api.routehook.ai/v1/queue/requests/job_ab12cd34/status", "response_url": "https://api.routehook.ai/v1/queue/requests/job_ab12cd34", "cancel_url": "https://api.routehook.ai/v1/queue/requests/job_ab12cd34/cancel" } ``` Then do exactly one of these three things, not two of them: 1. **Poll** `response_url` (or `GET /v1/videos/`, the same job under a shorter path). It answers `202` with the status body while the job runs and `200` with the result once it finishes. **Branch on the status code, not on the body** — a running job parsed as a result looks like a success with no output. Poll every few seconds; these take minutes, not milliseconds. 2. **Stream** `GET /v1/queue/requests//stream`, a `text/event-stream` of the same status transitions. Use this *instead of* polling, never alongside it. 3. **Give a webhook.** Put `"webhook_url": "https://your.app/hook"` in the submit body and the terminal state is POSTed to it. Then do not poll at all. `status` carries three values only — `IN_QUEUE`, `IN_PROGRESS`, `COMPLETED` — so a loop that waits for `COMPLETED` terminates. A job that failed or was cancelled is also `COMPLETED`; which one it was is in `outcome`. Money is held when the job is accepted and settled when it reaches a terminal state, so a cancelled job releases its hold. Any queued model, not only video, is submitted at `POST /v1/queue/` with the slug in the path. `POST /v1/run/` is the same submit with the waiting done for you, subject to a ceiling — past it, it returns the job to poll rather than failing, and the work is not thrown away. Generated media is served from `GET /v1/files/` and those references expire. Download the bytes if you need to keep them; a stale reference answers `410 gone`, which is permanent — see the retry rules below. ## Billing. Read this before writing retry logic There is no free tier. A key with no balance behind it answers `402 insufficient_credits` on its **first** call. The minimum top-up is $5. If you are testing and get a 402, the integration is correct and the account is empty; do not "fix" the code. Every response carries what it cost: | Header | Meaning | | --- | --- | | `X-Routehook-Cost` | What this call charged, in USD | | `X-Routehook-Request-Id` | Pass to `GET /v1/generation` for the full record | | `X-Routehook-Attempts` | How many upstreams were tried | | `X-Routehook-Fallback` | `true` when a second provider served it | | `X-Routehook-Upstream-Latency-Ms` | Time spent at the upstream | Log `X-Routehook-Cost` and `X-Routehook-Request-Id` if the application tracks spend. They are the only per-call cost record that does not need a second API call to obtain. ## Errors Every 4xx and 5xx uses one envelope: ```json { "error": { "code": "insufficient_credits", "message": "Balance is 0.02 USD; this request reserves 0.42 USD.", "request_id": "req_7c41d9be" } } ``` | Status | Code | What to do | | --- | --- | --- | | 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 | | 409 | `canceled` | The job was cancelled. Its result is never coming; submit a new one | | 409 | `duplicate_request` | Another request with this Idempotency-Key is still running. Wait for it rather than retrying | | 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 | Retry rules. Implement exactly these: - **429**. Back off using the `Retry-After` header. Do not retry sooner. - **503**. Safe to retry with exponential backoff. - **400, 401, 402, 404, 409, 410**. Do **not** retry. Retrying produces the identical error. Surface the `message` and stop. ## Checklist before you report done 1. The key is read from the environment, not hardcoded. 2. `model` is a slug that appeared in `GET /v1/models`. 3. Retry logic retries only 429 and 503. 4. A 402 is surfaced as "add credits", not as a bug in the request. 5. One real call has been made and its `X-Routehook-Cost` observed. 6. If anything generates video, it submits a job and polls or is given a webhook. Nothing waits on a synchronous route for it, and the polling code branches on the status code rather than on the body. --- # Quickstart > From an empty account to a billed response, in four steps. Section: Getting Started. Canonical page: https://routehook.ai/docs/quickstart Every call goes to `https://api.routehook.ai/v1`, carries an API key in the `Authorization` header and draws against a prepaid balance. If your code already talks to an OpenAI-compatible endpoint, the integration is two lines: the base URL and the key. ## Four steps 1. **Create an account and add credits** There is no free tier and no trial balance. A key with nothing behind it answers `402 insufficient_credits` on its first call. The smallest top-up is $5, and credits spend across every live model. 2. **Mint a key** Keys are created in the dashboard and shown once. Copy it straight into a secret manager or an environment variable. Choose live or test at creation; a key's environment cannot be changed afterwards. 3. **Make the call** Point an OpenAI client at the base URL, or send the JSON yourself. `model` takes a slug from `GET /v1/models`. 4. **Read what it cost** The response headers report what the call cost and how it went. `GET /v1/credits` shows the balance after it. ## Set the key **shell** ```shell export ROUTEHOOK_API_KEY="sk_live_..." ``` ## The one-line switch **JavaScript** ```javascript import OpenAI from "openai"; const client = new OpenAI({ baseURL: "https://api.routehook.ai/v1", apiKey: process.env.ROUTEHOOK_API_KEY, }); const completion = await client.chat.completions.create({ model: "openai/gpt-5.4-mini", messages: [{ role: "user", content: "Name three uses for a gateway." }], }); console.log(completion.choices[0].message.content); ``` **Python** ```python import os from openai import OpenAI client = OpenAI( base_url="https://api.routehook.ai/v1", api_key=os.environ["ROUTEHOOK_API_KEY"], ) completion = client.chat.completions.create( model="openai/gpt-5.4-mini", messages=[{"role": "user", "content": "Name three uses for a gateway."}], ) print(completion.choices[0].message.content) ``` ## The model field is a slug `model` is a catalogue slug (`openai/gpt-5.4-mini`, `anthropic/claude-fable-5`) not a bare vendor model name. `GET /v1/models` lists every slug with its category, status and rate, and needs no key, so a model picker can read it straight from a browser. A slug nothing can serve right now answers `409 model_unavailable` rather than quietly routing somewhere else. ## The same call, without an SDK **cURL** ```bash curl https://api.routehook.ai/v1/chat/completions \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "openai/gpt-5.4-mini", "messages": [{ "role": "user", "content": "Name three uses for a gateway." }] }' ``` ## What the call cost, and how it went The body is OpenAI's, so the charge is not in it. That goes in the response headers, and `-i` on the request above shows them. They matter because a retry is otherwise invisible: a request that failed over reads as an ordinary 200, and these headers are the only place that shows. None of them names the host that answered, which upstream serves a call is not something this API reports. | HEADER | EXAMPLE | WHAT IT REPORTS | | --- | --- | --- | | X-Routehook-Request-Id | req_7c41d9be | The id of this call. Quote it in a support report; pass it to GET /v1/generation. | | X-Routehook-Attempts | 1 | How many attempts it took before one answered. | | X-Routehook-Fallback | false | true when the first choice could not serve it and the chain fell through. | | X-Routehook-Cost | 0.000103 | USD charged for this call. Absent on a stream. The charge is not known when the headers are written. | | X-Routehook-Upstream-Latency-Ms | 812 | How long the upstream leg took, excluding our own routing. | **Response headers** ```http HTTP/1.1 200 OK Content-Type: application/json X-Routehook-Request-Id: req_7c41d9be X-Routehook-Attempts: 1 X-Routehook-Fallback: false X-Routehook-Cost: 0.000103 X-Routehook-Upstream-Latency-Ms: 812 ``` > **Note: A stream reports its cost inside the stream** > > Headers are written before the first token, so a streamed response carries no cost header. Send `stream_options: { include_usage: true }` and the final frame carries `usage.cost`, or keep the request id and read the charge from `GET /v1/generation` afterwards. ## Watch the balance move One route reports the money side of the account. `balance` is settled cash, `held` is what requests in flight have reserved against it, and `available` is what the next request may spend, which is why a call can be refused while the balance still looks healthy. ## GET /v1/credits Read the account balance, the amount held by requests in flight and what is left to spend. Status: Available. Authentication: any live API key on the account. **cURL** ```bash curl https://api.routehook.ai/v1/credits \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" ``` **200 OK** ```json { "data": { "total_credits": 250.0, "total_usage": 128.44, "balance": 121.56, "held": 0.42, "available": 121.14, "credit_limit": 0, "currency": "USD" } } ``` > **Warning: There is no sandbox** > > Every key is a live key, and the call you make while reading this costs what it would cost in production. There is no free tier and no test mode to fall back on, so put a spend cap on any key you are experimenting with (`GET /v1/key` reports it as `limit` and what is left as `limit_remaining`), and watch the balance move on `GET /v1/credits`. ## Let an agent wire it up Working in Claude Code, Codex or Gemini CLI? Paste the command below. It points the agent at `/llms.txt`: the whole integration written for a machine: base URL, auth header, where model slugs come from, every error code and which of them are worth retrying. **paste into your agent** ```bash Read https://routehook.ai/llms-full.txt and wire this project up to Routehook exactly as it specifies. Use my ROUTEHOOK_API_KEY from the environment, pick a model slug from GET /v1/models rather than guessing one, and only add retry logic for 429 and 503. ``` > **Note: Why point at the file rather than describe the API** > > An agent that has to infer an integration will produce one that compiles. The brief removes the inference: it names the base URL, the one route that takes `x-api-key` instead of `Authorization`, and the rule that only 429 and 503 are worth retrying. The three things agents most reliably get wrong here. It is served as plain text, so `curl`, a browser and a fetch tool all read it the same way. ## Where to next - [Agent brief (/llms.txt)](/llms.txt): The whole integration as one plain-text file, written for a coding agent to read. - [Chat Completions](/docs/chat): The full request shape, including fallback model lists. - [Streaming](/docs/streaming): Server-sent events frame by frame, including the failure case. - [Errors](/docs/errors): Six codes, one envelope, and what to do about each. - [Cost Tracking](/docs/cost-tracking): Balance, holds, and what a single request cost. --- # Authentication > One key, sent as a bearer token on every request that spends money. Section: Getting Started. Canonical page: https://routehook.ai/docs/authentication The API key is the credential. There is no session, no cookie and no token exchange: send the key in the Authorization header and the request is authenticated. Keys belong to an account, not to a route. There are no per-endpoint scopes to grant. ## The header **cURL** ```bash curl https://api.routehook.ai/v1/chat/completions -H "Authorization: Bearer $ROUTEHOOK_API_KEY" -H "Content-Type: application/json" -d '{ "model": "openai/gpt-5.4-mini", "messages": [{ "role": "user", "content": "Hello" }] }' ``` ## x-api-key, for Anthropic clients Every /v1 route also accepts the key in an x-api-key header, not just POST /v1/messages. That is the header an Anthropic SDK sends, and refusing it on the routes next door would buy nothing but support tickets. It is the same key and the same account; nothing else about the request changes. A bare Authorization header carrying the key with no Bearer prefix is accepted too, because that is what a curl example produces when someone drops the word. Authorization: Bearer is the spelling to write against; the other two exist so a request that was obviously meant to work does. ## One kind of key Every key carries the sk_live_ prefix and spends the account's balance. There is no test or sandbox key: a call made while you are trying something out costs exactly what the same call costs in production, because it is the same call. Isolate risk with separate keys instead. Issue one per service, give it a spend cap, and revoke it on its own. GET /v1/key reports the cap as limit and what is left as limit_remaining. ## GET /v1/key Describe the key making the call: its label, spend cap, lifetime usage and rate limit. Status: Available. Authentication: any live API key on the account. ## Routes that need no key The catalogue is public. GET /v1/models, GET /v1/catalog and GET /v1/status answer anonymously and are cached, so a pricing page or a model picker can read them from a browser. Everything that spends money needs a key. > **Warning: Never ship a key to a browser** > > A key in a browser or a mobile binary is a key in public. Call your own server, hold the key there, and forward the request. A key seen in a client-side request is treated as compromised. ## Rotation Create the replacement key first, deploy it, then revoke the old one. Revocation takes effect immediately, so doing it in the other order takes the integration down for the length of a deploy. ## Practices worth keeping - One key per project, so revoking one never takes down another. - Keep keys in environment variables or a secret manager, never in the repository. - Log the key id from GET /v1/key, never the key itself. - Rotate on any suspicion. Rotation is cheap, forensics is not. ## Authentication failures | STATUS | CODE | MEANING | | --- | --- | --- | | 401 | invalid_api_key | Key missing, malformed or revoked | | 402 | insufficient_credits | The key is valid; the balance is not | --- # Errors > One envelope, eight codes, and the request id that identifies the call. Section: Getting Started. Canonical page: https://routehook.ai/docs/errors 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 **402 Payment Required** ```json { "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 | | 409 | canceled | The job was cancelled. Its result is never coming; submit a new one | | 409 | duplicate_request | Another request with this Idempotency-Key is still running. Wait for it rather than retrying | | 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-After` seconds, 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. **text/event-stream** ```text data: {"id":"req_7c41d9be","object":"chat.completion.chunk","model":"openai/gpt-5.4-mini","choices":[{"index":0,"delta":{"content":"The"}}]} data: {"error":{"code":"upstream_unavailable","message":"The upstream closed the connection.","request_id":"req_7c41d9be"}} data: [DONE] ``` > **Warning: Check every frame for an error key** > > A client that reads only `choices[0].delta.content` sees a failed stream as a short one: the error frame carries no `choices`, and `[DONE]` follows exactly as it would after a clean finish. Treat any frame with an `error` key as the end of the stream and surface it, or truncated answers will be indistinguishable from complete ones. ## 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 **stream.js** ```javascript 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 ?? ""); } } } ``` > **Note: Retrying something that spends money** > > `POST /v1/queue/{model}` accepts an `Idempotency-Key` header: a repeat submit carrying the same key returns the job that already exists instead of opening a second one. The synchronous routes honour the same header for the reservation, so a retry never holds the balance twice — and while the first attempt is still running a repeat is answered `409 duplicate_request` rather than being sent upstream a second time. Wait for the original to answer; retry once it has finished, and the key reserves afresh. ## Related - [Rate Limits](/docs/rate-limits): The meter behind 429, and how to back off against it. - [Authentication](/docs/authentication): Keys, headers, rotation and what 401 means. - [Cost Tracking](/docs/cost-tracking): What one request cost, by request id. - [Support](/docs/support): Platform status, and what to put in a report. --- # Rate Limits > The per-key request rate, the headers that report it, and the separate ceiling on queued work. Section: Getting Started. Canonical page: https://routehook.ai/docs/rate-limits Requests are metered per minute: 600 a minute unless your account is set otherwise. Every `/v1` response to a call carrying your key reports where the meter stands, not only the ones that fail, so a client can slow down before it is refused instead of after. Going over answers `429 rate_limited` with a `Retry-After` header. ## The headers | HEADER | EXAMPLE | WHAT IT REPORTS | | --- | --- | --- | | X-RateLimit-Limit | 600 | Requests allowed in the current window. | | X-RateLimit-Remaining | 594 | How many of them are left. Watch this one and you never see a 429. | | X-RateLimit-Reset | 1755946980 | Unix time, in seconds, at which the window refills. | | Retry-After | 12 | Seconds to wait before retrying. Sent on a 429 only. | | X-Routehook-Limit-Type | requests | Which ceiling was hit: requests or concurrency. Sent on a 429 only. | ## Which ceiling you hit `rate_limited` covers two different limits, and the fix is not the same for both. `X-Routehook-Limit-Type: requests` means too many calls in a minute: the meter refills on its own, so waiting out `Retry-After` is the whole remedy. `X-Routehook-Limit-Type: concurrency` means too much long-running work is in flight at once, ten jobs by default. No amount of waiting refills that one; a job has to finish first, and a retry loop against it will keep failing until one does. Wait for a job to reach a terminal state, then submit the next. > **Note: Queue position is not a rate limit** > > A submitted job that sits in `IN_QUEUE` has not been throttled. The platform runs a bounded number of jobs at a time and yours is waiting its turn. It costs nothing extra, no header reports it as a limit, and cancelling and resubmitting only puts you at the back of the same queue. ## Whose limit it is The meter belongs to the account, not to the key. Every key on an account draws on the same ceiling, so minting a second key buys no extra throughput. It buys separate revocation, which is a different and worthwhile thing. A project may carry its own limit, and where it does that limit replaces the account default for calls made under it. `GET /v1/key` publishes the ceiling in force for the key you are holding, as `rate_limit: { requests, interval }`, so a client can read its own budget rather than assume one. ## GET /v1/key Describe the key making the call: its label, spend cap, lifetime usage and rate limit. Status: Available. Authentication: any live API key on the account. ## Backing off properly - **Honour `Retry-After` when it is there.** It is the server's own estimate of when the window reopens, not a floor to improve on. - **Otherwise back off exponentially, with full jitter.** A fixed delay synchronises every client that failed in the same second, and they all come back in the same second. - **Cap the delay and the attempt count.** Nothing is gained by a fourteenth retry of a request nobody is waiting for any more. - **Do not retry 400, 401, 402 or 409.** The same request fails the same way; only 429 and 503 are worth sending again. - **Treat `concurrency` as a queue, not a clock.** Wait for work to finish rather than for time to pass. ## Retry with jitter **JavaScript** ```javascript const RETRYABLE = new Set([429, 503]); async function callWithRetry(body, attempts = 5) { for (let attempt = 0; attempt < attempts; attempt++) { 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), }); if (res.ok) return res.json(); const { error } = await res.json(); if (!RETRYABLE.has(res.status) || attempt === attempts - 1) { throw new Error(`${error.code}: ${error.message} (${error.request_id})`); } // The server's estimate first; full jitter under a growing cap otherwise. const retryAfter = Number(res.headers.get("Retry-After")) || 0; const ceiling = Math.min(2 ** attempt, 30) * 1000; const wait = retryAfter > 0 ? retryAfter * 1000 : Math.random() * ceiling; await new Promise((resolve) => setTimeout(resolve, wait)); } } ``` **Python** ```python import os, random, time import requests RETRYABLE = {429, 503} URL = "https://api.routehook.ai/v1/chat/completions" def call_with_retry(body, attempts=5): for attempt in range(attempts): res = requests.post( URL, headers={ "Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}" }, json=body, ) if res.ok: return res.json() error = res.json()["error"] if res.status_code not in RETRYABLE or attempt == attempts - 1: raise RuntimeError( f"{error['code']}: {error['message']} ({error['request_id']})" ) # The server's estimate first; full jitter under a growing cap otherwise. retry_after = float(res.headers.get("Retry-After", 0)) ceiling = min(2 ** attempt, 30) time.sleep(retry_after or random.uniform(0, ceiling)) ``` > **Warning: Retrying a 429 immediately makes it worse** > > A refused request still counts as a request. A loop that retries without waiting spends the whole of the next window being refused as well, and the client never recovers on its own. If you take one thing from this page, take the sleep. ## Streaming and long jobs A streamed completion is one request against the meter however long it runs and however much it costs. Rate limits count calls, not tokens. Work that takes minutes belongs on the queue, where the ceiling is on how much runs at once rather than on how often you ask, and where a 429 tells you to wait for a job rather than for a window. ## Asking for more Ceilings are set per account and can be raised. Read the current one from `GET /v1/key` first, then ask through support with the request ids of the calls that were refused. The ids make the pattern legible in a way a description of it cannot. ## Related - [Errors](/docs/errors): The envelope 429 arrives in, and the other seven codes. - [Queue](/docs/queue): Async jobs, their statuses, and the concurrency ceiling. - [Authentication](/docs/authentication): How keys authenticate, and how to cap what one can spend. - [Support](/docs/support): Platform status, and what to put in a report. --- # Chat > Chat completions in OpenAI's shape, with automatic failover behind every model. Section: Core API. Canonical page: https://routehook.ai/docs/chat `POST /v1/chat/completions` takes OpenAI's request and returns OpenAI's response. Point an OpenAI client at this base URL, change the model to a slug from `GET /v1/models`, and the rest of the code is unchanged. The gateway picks a route that can serve that model, falls back to the next when one fails, and says in the response headers which one answered. ## A first request **cURL** ```bash curl https://api.routehook.ai/v1/chat/completions \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "openai/gpt-5.4-mini", "messages": [ { "role": "user", "content": "Hello" } ] }' ``` **JavaScript** ```javascript import OpenAI from "openai"; const client = new OpenAI({ baseURL: "https://api.routehook.ai/v1", apiKey: process.env.ROUTEHOOK_API_KEY, }); const completion = await client.chat.completions.create({ model: "openai/gpt-5.4-mini", messages: [{ role: "user", content: "Hello" }], }); console.log(completion.choices[0].message.content); ``` **Python** ```python import os from openai import OpenAI client = OpenAI( base_url="https://api.routehook.ai/v1", api_key=os.environ["ROUTEHOOK_API_KEY"], ) completion = client.chat.completions.create( model="openai/gpt-5.4-mini", messages=[{"role": "user", "content": "Hello"}], ) print(completion.choices[0].message.content) ``` ## POST /v1/chat/completions Chat completions in OpenAI's shape, streamed or whole, with automatic failover. Status: Available. Authentication: any live API key on the account. ## Request parameters | PARAMETER | TYPE | REQUIRED | DESCRIPTION | | --- | --- | --- | --- | | model | string | required | Model slug from GET /v1/models — the full `vendor/model` form. A bare model name works only where it is published as that model's alias, which the row's `id` tells you. | | messages | array | required | At least one turn. Roles are system, developer, user, assistant and tool. | | stream | boolean | optional | Emit server-sent events instead of one body. Default false. | | stream_options | object | optional | Only include_usage is read. It adds a final frame carrying usage and cost. | | max_tokens | integer | optional | Ceiling on generated tokens. Also caps what is reserved before the call runs. | | max_completion_tokens | integer | optional | OpenAI's newer spelling of the same ceiling. | | temperature | number | optional | 0 to 2. Forwarded unchanged. | | top_p | number | optional | 0 to 1. Forwarded unchanged. | | top_k | integer | optional | 0 or greater. Models that do not offer it ignore it. | | frequency_penalty | number | optional | -2 to 2. | | presence_penalty | number | optional | -2 to 2. | | repetition_penalty | number | optional | 0 to 2. | | seed | integer | optional | Best-effort determinism, where the model supports it. | | stop | string \| string[] | optional | Up to eight sequences that end generation when produced. | | n | integer | optional | 1 to 8 completions. Multiplies the reservation and the bill. | | logprobs | boolean | optional | Return log probabilities for the tokens that came back. | | top_logprobs | integer | optional | 0 to 20. Needs logprobs. | | tools | array | optional | Tool definitions, in OpenAI's function schema. | | tool_choice | string \| object | optional | auto, none, required, or a named function. | | parallel_tool_calls | boolean | optional | Allow more than one tool call in a single turn. | | response_format | object | optional | json_object or json_schema, where the model supports it. | | reasoning_effort | string | optional | Forwarded to reasoning models that read it. | | user | string | optional | Up to 256 characters. An opaque end-user id, forwarded upstream. | | models | string[] | optional | Up to eight fallback model slugs, tried in order after model. | | provider | object | optional | Provider preferences: only, ignore, order, sort, allow_fallbacks, require_parameters, max_price. They narrow or reorder the chain, never extend it. | | route | fallback | optional | OpenRouter's spelling of what models already does. Accepted for compatibility; the models array is the field to reach for. | | logit_bias | object | optional | Token id to bias, forwarded to models that read it. | | min_p | number | optional | 0 to 1. Forwarded unchanged. | | top_a | number | optional | 0 to 1. Forwarded unchanged. | | prediction | object | optional | Predicted output, for models that support speculative decoding. | | transforms | string[] | optional | Parsed but refused: a non-empty array answers 400 invalid_request rather than silently leaving your prompt unchanged. | | plugins | object[] | optional | Same. A non-empty array answers 400 rather than dropping the plugin quietly. | > **Warning: transforms and plugins are refused, not ignored** > > Both are in the request schema for OpenRouter compatibility and neither has an execution layer behind it yet. Sending either with a non-empty value answers `400 invalid_request` naming the field. That is deliberate: a prompt this gateway said it would trim and did not, or a plugin it accepted and never ran, is a wrong answer the caller has no way to detect. Remove them before migrating. See Migrating from OpenRouter. > **Note: Unknown fields are forwarded, not refused** > > A field this gateway has never heard of is forwarded upstream rather than rejected. Vendors ship parameters faster than any schema tracks them, and turning that into a 400 from us would make every new upstream flag our outage. The upstream is the only party that can say whether it is valid, and it is free to ignore it silently, so check `supported_parameters` on the model before you rely on one. ## Messages Each turn carries a role and content. `content` is a string, or an array of parts for multimodal input: a `text` part, an `image_url` part taking a URL or a data URI, and an `input_audio` part. Part types newer than this gateway are forwarded as they arrive rather than refused. A `tool` turn carries the `tool_call_id` it answers; an assistant turn that asked for a tool carries `tool_calls` and may have `content: null`. ## Model fallback `models` is a list of slugs to try after the first, and it is the only fallback lever you hold: the whole chain behind `model` is exhausted before the second slug is considered at all. Use it where a second model is an acceptable answer to the same question (a cheaper sibling, or another vendor's equivalent), and price it accordingly. The second model has its own rate, so a request that falls through costs what the model that answered costs, not what you budgeted for the first. ## Falling back to another model **Request body** ```json { "model": "openai/gpt-5.4-mini", "models": ["anthropic/claude-fable-5"], "messages": [{ "role": "user", "content": "Summarise this in one line." }] } ``` > **Note: provider narrows the chain, it never names it** > > The OpenRouter-compatible `provider` block can narrow or order eligible providers with `only`, `ignore`, `order`, `sort`, `allow_fallbacks`, `require_parameters` and `max_price`. Preferences can only remove or reorder targets the operator already approved, never add one. It never exposes credentials or internal cost, and an empty eligible chain fails before any provider is called. See Routing. ## What the response headers say A fallback is otherwise invisible: you get an ordinary 200 with no sign that the first attempt failed. Every response carries headers describing what your own call did. How many attempts, whether it fell back, what it cost, how long the upstream leg took. None of them names the host that answered. | HEADER | MEANING | | --- | --- | | X-Routehook-Request-Id | The request id. Quote it in support, and pass it to GET /v1/generation. | | X-Routehook-Attempts | How many attempts the request took. | | X-Routehook-Fallback | true when the first choice did not answer and another did. | | X-Routehook-Cost | USD charged. Absent on a stream. The cost is not known when the headers go out. | | X-Routehook-Upstream-Latency-Ms | How long the upstream leg took, excluding our own overhead. | ## What it cost The body carries the charge too. `usage.cost` follows OpenRouter as a JSON number; `usage.cost_decimal` is Routehook's exact decimal-string extension for accounting code. `usage.prompt_tokens_details.cached_tokens` and `usage.completion_tokens_details.reasoning_tokens` are broken out where the upstream reports them, because both are billed differently from ordinary tokens. ## The response **200 OK** ```json { "id": "chatcmpl_5f81c0", "object": "chat.completion", "created": 1786312455, "model": "openai/gpt-5.4-mini", "choices": [ { "index": 0, "message": { "role": "assistant", "content": "Hello. How can I help?" }, "finish_reason": "stop" } ], "usage": { "prompt_tokens": 9, "completion_tokens": 8, "total_tokens": 17, "cost": "0.0000042" } } ``` > **Note: model is always our slug** > > The `model` field on the way back is the slug you asked for, never the id the upstream knows itself by. A request for `openai/gpt-5.4-mini` served by a fallback must not come back claiming a model you did not ask for. Your own logs and cost attribution key off that field. The upstream's own id is not published anywhere: it names the host that served the call as precisely as a provider slug would, so no header or body field carries it. See Routing. ## Tool calling Tools are OpenAI's shape and pass through unchanged. The model answers with `finish_reason: "tool_calls"` and one or more `tool_calls` on the assistant message; you run them, append one `tool` message per call carrying the matching `tool_call_id`, and send the whole conversation back. Each hop is a separate billed request. A three-hop tool conversation is three completions, not one, and every hop re-sends the growing message list as prompt tokens. ## A tool round trip **tools.js** ```javascript import OpenAI from "openai"; const client = new OpenAI({ baseURL: "https://api.routehook.ai/v1", apiKey: process.env.ROUTEHOOK_API_KEY, }); const tools = [ { type: "function", function: { name: "get_tide", description: "Tide height in metres for a port, right now.", parameters: { type: "object", properties: { port: { type: "string" } }, required: ["port"], }, }, }, ]; const messages = [{ role: "user", content: "What is the tide at Felixstowe?" }]; const first = await client.chat.completions.create({ model: "openai/gpt-5.4-mini", messages, tools, // Skip any endpoint that has not declared tool support, rather than // sending the tools and getting prose back from one that ignores them. }); const call = first.choices[0].message.tool_calls?.[0]; messages.push(first.choices[0].message); if (call) { const { port } = JSON.parse(call.function.arguments); messages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify({ port, metres: 2.4 }), }); const second = await client.chat.completions.create({ model: "openai/gpt-5.4-mini", messages, tools, }); console.log(second.choices[0].message.content); } ``` ## n multiplies the bill `n` asks for that many completions of one prompt, between 1 and 8, and is forwarded verbatim. The prompt is charged once; every completion is charged in full, so `n: 4` costs about four times the output side of `n: 1`. The reservation taken before the call is multiplied to match, which means a large `n` against a thin balance can be refused with `insufficient_credits` before anything runs. Not every model honours it: where one does not you get a single choice back, and pay for one. ## Failures worth handling `409 model_unavailable` means nothing could serve that model. A wrong slug, or a model with no live route right now. `402 insufficient_credits` is refused before any upstream call is made, so nothing was spent. `503 upstream_unavailable` means every endpoint in the chain failed; it is safe to retry, and sending an `Idempotency-Key` stops a retry reserving the money a second time. `409 duplicate_request` means a request under that key is still running — wait for it rather than retrying again. ## Related - [Streaming](/docs/streaming): The frames, the final usage frame, and dead streams. - [Routing](/docs/routing): How the chain is built, and what the operator decides. - [Cost Tracking](/docs/cost-tracking): What one request cost, after the fact. --- # Streaming > Server-sent events for chat, legacy completions and Anthropic messages. Section: Core API. Canonical page: https://routehook.ai/docs/streaming Set `stream: true` and the response becomes a server-sent event stream instead of one JSON body. Chat and legacy completions emit OpenAI's frames. A sequence of `data:` lines closed by `data: [DONE]`. `POST /v1/messages` emits Anthropic's named events instead, because an Anthropic client parses the event names rather than the payloads. ## Consuming a stream **cURL** ```bash curl -N https://api.routehook.ai/v1/chat/completions \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "openai/gpt-5.4-mini", "messages": [{ "role": "user", "content": "Describe harbour fog." }], "stream": true, "stream_options": { "include_usage": true } }' ``` **JavaScript** ```javascript import OpenAI from "openai"; const client = new OpenAI({ baseURL: "https://api.routehook.ai/v1", apiKey: process.env.ROUTEHOOK_API_KEY, }); const stream = await client.chat.completions.create({ model: "openai/gpt-5.4-mini", messages: [{ role: "user", content: "Describe harbour fog." }], stream: true, stream_options: { include_usage: true }, }); try { for await (const chunk of stream) { const delta = chunk.choices[0]?.delta?.content; if (delta) process.stdout.write(delta); // The final frame carries no choices at all. Only usage, and cost. if (chunk.usage) console.log("\ncost", chunk.usage.cost, "USD"); } } catch (error) { // An error frame is raised here, after real bytes have already arrived. // Keep what you have: it was generated, and it has been billed. console.error("\nstream failed:", error.message); } ``` **Python** ```python import os from openai import OpenAI client = OpenAI( base_url="https://api.routehook.ai/v1", api_key=os.environ["ROUTEHOOK_API_KEY"], ) stream = client.chat.completions.create( model="openai/gpt-5.4-mini", messages=[{"role": "user", "content": "Describe harbour fog."}], stream=True, stream_options={"include_usage": True}, ) try: for chunk in stream: if chunk.choices and chunk.choices[0].delta.content: print(chunk.choices[0].delta.content, end="", flush=True) # The final frame carries no choices at all. Only usage, and cost. if chunk.usage: print("\ncost", chunk.usage.cost, "USD") except Exception as error: # An error frame is raised here, after real bytes have already arrived. print("\nstream failed:", error) ``` > **Note: cost is additive, so typed clients do not declare it** > > `usage.cost` is our field, not OpenAI's, so an OpenAI SDK's own types have no property for it. It is on the object at runtime; in TypeScript, read it through a cast rather than expecting the SDK to type it. The Python client keeps unknown fields as attributes, so `chunk.usage.cost` works as written. ## The frames Every frame is one `data:` line and a blank line. Content chunks are OpenAI's `chat.completion.chunk` objects and carry the upstream's own completion id, which is stable for the whole response. The last content chunk carries a `finish_reason` and an empty delta. `data: [DONE]` always closes the stream, on success and on failure alike. ## On the wire **text/event-stream** ```text data: {"id":"chatcmpl_5f81c0","object":"chat.completion.chunk","created":1786312455,"model":"openai/gpt-5.4-mini","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]} data: {"id":"chatcmpl_5f81c0","object":"chat.completion.chunk","created":1786312455,"model":"openai/gpt-5.4-mini","choices":[{"index":0,"delta":{"content":"Fog"},"finish_reason":null}]} data: {"id":"chatcmpl_5f81c0","object":"chat.completion.chunk","created":1786312455,"model":"openai/gpt-5.4-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} data: {"id":"req_7c41d9be","object":"chat.completion.chunk","created":1786312461,"model":"openai/gpt-5.4-mini","choices":[],"usage":{"prompt_tokens":9,"completion_tokens":42,"total_tokens":51,"cost":"0.0000181"}} data: [DONE] ``` ## The model id is our slug Every chunk is rewritten so that `model` is the slug you asked for, never the id an upstream knows the model by. A stream served by a fallback would otherwise report a model you never requested, halfway through a response you are already rendering, and your logging, your caching keys and your cost attribution all key off that field. Nothing on the response names the host that answered. ## Usage and cost Send `stream_options: { include_usage: true }` and one extra frame arrives before `[DONE]`: no choices, a full `usage` object, `usage.cost` as a number, and exact `usage.cost_decimal`. Without the opt-in nothing extra is sent. The alternative is `GET /v1/generation?id=req_…` afterwards. > **Note: The usage frame carries a different id** > > Content chunks carry the upstream's completion id. The usage frame carries **our** request id, the `req_…` value also returned in `X-Routehook-Request-Id`: because that is the id `GET /v1/generation` and every support lookup are made on. Do not assume the two match. ## When a stream dies halfway A stream commits at its first byte. Once the headers are out the status is already 200 and there is no failing over to another target, so an upstream that dies mid-response cannot be retried behind your back. What arrives instead is an error frame, then `[DONE]`, on the same 200 response. Both OpenAI SDKs raise on that frame rather than yielding it, so put the loop in a try. The bytes you already wrote are still yours. ## A stream that failed after the first byte **text/event-stream** ```text data: {"id":"chatcmpl_5f81c0","object":"chat.completion.chunk","created":1786312455,"model":"openai/gpt-5.4-mini","choices":[{"index":0,"delta":{"content":"Fog rolled"},"finish_reason":null}]} data: {"error":{"code":"upstream_unavailable","message":"Connection reset by upstream after 1.8s.","request_id":"req_7c41d9be"}} data: [DONE] ``` > **Warning: A half-delivered stream is still billed** > > You are holding real bytes: the tokens in them were generated, and we were charged for them upstream. So a stream that fails halfway settles for what was metered up to the failure, and is recorded as a failed request. No usage frame is emitted on that path (the error frame is the last thing before `[DONE]`), so read the charge from `GET /v1/generation?id=req_…` using the request id in the error. The same applies when **you** hang up: closing the connection aborts the generation upstream so nothing further is produced, and what was already metered is charged. ## Before the first byte, it is an ordinary error The failure path splits at the headers. A bad model, an empty routing chain, a missing key or an insufficient balance are all decided before anything is written, so they arrive as a normal JSON error with the right status (`400`, `401`, `402`, `409`), and no stream is opened. Only failures after the first byte become error frames. A client that only handles error frames will miss every pre-flight refusal, and one that only handles status codes will treat a dead stream as a success. ## Headers on a streamed response | HEADER | VALUE | | --- | --- | | Content-Type | text/event-stream; charset=utf-8 | | Cache-Control | no-cache, no-transform | | X-Accel-Buffering | no. Stops an nginx in front of us collecting the stream and delivering it in one lump | | X-Routehook-Request-Id | The request id, before the first frame | | X-Routehook-Cost | Not sent on a stream. The cost is unknown when the headers go out. | ## Things that catch people out - A proxy of your own can buffer a stream back into one lump. If output arrives all at once, the gateway is rarely the layer to blame. Check `proxy_buffering` first. - Failover happens before the first byte only. The chain still runs for the pre-flight attempt; nothing can retry once a byte has shipped. - `[DONE]` is not JSON. Parsing every frame's payload without checking for it is the most common integration bug on this route. - Frames can be split across TCP reads. Buffer until a blank line before parsing, or use a client that does. - A stream reserves credit exactly as a completion does, and an unaffordable stream is refused up front rather than cut off midway. ## Legacy completions `POST /v1/completions` streams in the same framing with a different payload: the object is `text_completion` and each chunk carries `choices[].text` rather than `choices[].delta`. Everything else on this page (`[DONE]`, the error frame, the usage opt-in) is identical. ## Anthropic messages `POST /v1/messages` does not use this framing at all. It emits Anthropic's named events (`message_start`, `content_block_start`, `content_block_delta`, `content_block_stop`, `message_delta` and `message_stop`) each written as an `event:` line followed by its `data:` line, with no `[DONE]` sentinel. See the messages page for the full sequence. ## Related - [Chat](/docs/chat): The request this page streams, and its parameters. - [Messages](/docs/messages): Anthropic's event framing, emitted by /v1/messages. - [Errors](/docs/errors): The eight codes, and what each one means you should do. --- # Images > Synchronous image generation: the connection is held until the images exist. Section: Core API. Canonical page: https://routehook.ai/docs/images POST /v1/images/generations takes a prompt and answers with URLs. It is OpenAI's Images API shape, so an existing images.generate call works against this base URL unchanged, and it holds the connection open until the image is ready, which suits a call of tens of seconds and nothing longer. Video and anything else measured in minutes goes through the queue instead. ## POST /v1/images/generations Generate images and return their URLs. The connection is held until the images exist. Status: Available. Authentication: any live API key on the account. ## The same route, OpenRouter's path `POST /v1/images` reaches the identical handler under OpenRouter's spelling. One implementation, one bill; which path you post to is a matter of whichever client you already have. ## POST /v1/images The same image generation under OpenRouter's path. One handler, one bill. Status: Available. Authentication: any live API key on the account. ## Collecting the bytes Every URL these routes return points at `GET /v1/files/{id}` on this domain, never at an upstream host. Present your API key on that route the way you would on any other. The URL is scoped to the account that generated it, so it is not a public link. Generated media is short-lived either way: read `expires_at` on the response rather than assuming a window, because how long a URL keeps working depends on how this deployment is configured. Past it the route answers `410 gone`, a permanent code chosen deliberately: labelling it with a retryable one would have well-behaved clients retrying for ever against something that cannot succeed. Download anything you mean to keep. ## GET /v1/files/{id} Fetch generated media by reference. Proxied at request time, never redirected upstream. Status: Available. Authentication: any live API key on the account. ## Generating an image **cURL** ```bash curl https://api.routehook.ai/v1/images/generations \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "google/nano-banana-2", "prompt": "isometric factory floor, technical illustration", "size": "1024x1024" }' ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/images/generations", { method: "POST", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ model: "google/nano-banana-2", prompt: "isometric factory floor, technical illustration", size: "1024x1024", }), }); const result = await res.json(); console.log(result.data[0].url); ``` **Python** ```python import os, requests res = requests.post( "https://api.routehook.ai/v1/images/generations", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, json={ "model": "google/nano-banana-2", "prompt": "isometric factory floor, technical illustration", "size": "1024x1024", }, ) print(res.json()["data"][0]["url"]) ``` ## The response **200 OK** ```json { "created": 1786312455, "model": "google/nano-banana-2", "data": [ { "url": "https://cdn.routehook.ai/img/9d21f4-0.png" }, { "url": "https://cdn.routehook.ai/img/9d21f4-1.png" } ] } ``` created is unix seconds. data is one entry per image, in the order they were generated, and an entry may also carry revised_prompt where the model rewrote what you asked for. The response header x-request-id holds the req_… id of the call, which is what GET /v1/generation takes if you want to know what it cost. > **Note: This route names the id header x-request-id** > > Every other gateway route returns the request id as X-Routehook-Request-Id. This one returns the same req_… value under x-request-id. Code that reads the id for both should read either name rather than assuming one. HTTP header names are case-insensitive, but these are two different names, not two spellings of one. ## Parameters | PARAMETER | TYPE | REQUIRED | DESCRIPTION | | --- | --- | --- | --- | | model | string | required | A slug from GET /v1/models. It must be an image model. Anything else is refused before money is reserved. | | prompt | string | required | What to generate. Up to 32,000 characters. | | n | integer | optional | How many images, 1 to 10. Defaults to 1. Each one bills at the model's rate. | | size | string | optional | 1024x1024, a ratio like 16:9, or a tier like 1K. Advisory, and only the half it states. See below. | | aspect_ratio | string | optional | The shape on its own: 16:9, 9:16, 1:1. Wins over anything derived from size. | | resolution | string | optional | The magnitude on its own: 1K, 2K, 4K, or a pixel size. Wins over size, and is the field media rates are keyed on. | | quality | string | optional | Effort tier (low, medium, high), honoured by models that have one and ignored by the rest. Priced alongside resolution where a model sells both. | | style | string | optional | Style hint, honoured by models that have one and ignored by the rest. | | reference_urls | string[] | optional | Up to 8 publicly reachable input images, for models that take references. Ignored by models that do not, rather than refused. | | user | string | optional | Opaque end-user identifier, forwarded upstream. Up to 128 characters here. Chat and completions allow 256, so a value that fits there can still be refused on this route. | ## The model id names a product model is a gateway slug (google/nano-banana-2) not one of OpenAI's four image model names. The slug names the product you are buying and we pick who serves it, which is the whole point: restricting the field to one vendor's names would defeat it. The model that comes back in the response is always the slug you asked for, never an upstream's own id, and no route reports which host ran it. ## size is advisory, and states one thing or two Every upstream behind this expresses shape differently (an aspect ratio, an orientation, a resolution tier), and several reject values the others accept. So size is translated for whatever runs it, and a value a route refuses is snapped to the nearest it allows rather than 400-ing after you have already been quoted. What travels is the half the value actually states: 1024x1024 states a shape and a magnitude and sets both, 16:9 states only a shape, 1K states only a magnitude, and the half it does not state is left to the model's own default. Do not build a layout that assumes exact pixel dimensions. Read them off the image you were handed. ## Or name the two directly aspect_ratio and resolution are the same two knobs under their own names, and either wins over what size would have implied. Reach for them when you want one without the other (aspect_ratio: "16:9" with no opinion on size) or when you want the rate to be unambiguous: resolution is what a media model's price is keyed on, so naming it names the rate you are billed at. A model that has no such knob ignores what you send, as it ignores anything else it does not declare. > **Note: There is no b64_json** > > response_format is not accepted and images never come back inline. Everything behind this route writes to an object store and returns a URL, and base64-ing a multi-megabyte image back through the gateway would triple the memory cost of a request to change nothing about what you receive. Fetch the URL. ## n multiplies the bill The reservation taken before the upstream call is exact here: n at the model's per-image rate, plus the model's flat per-request price where it carries one. An image is priced per image and you have already said how many you want, so there is no estimate in it and nobody is refused for headroom they did not need. You are then charged for what arrived, capped at what you asked for, three of four delivered is three paid for, and an upstream that returns more than n cannot bill past the ceiling you were gated on. Batching with n saves a round trip, not money. > **Warning: This call takes 20–60 seconds** > > The connection stays open for the whole generation, so set your client timeout above a minute and do not make a browser wait on it directly. Hanging up aborts the upstream call rather than orphaning it, and the reservation is released. Anything longer than this (video, above all) is submitted rather than awaited: POST /v1/videos/generations answers 202 with a job id and the URLs that follow it, POST /v1/videos is OpenRouter's spelling of the same submit, and POST /v1/queue/{model} is the general form for every other model. All three put the work on the queue; none of them holds the connection. ## When it fails A failed generation releases the reservation and charges nothing. Failover is not free on this route (where an upstream has probably already started work, its timeout is treated as non-retryable so the same prompt is not paid for twice), so a 503 upstream_unavailable can arrive without every target in the chain having been tried. Retry it; the hold is already gone. A model that is listed but has no per-image and no per-request rate is refused with 409 model_unavailable rather than served unbilled, which is an operator's job to fix and not something a retry will clear. ## Related - [Queue](/docs/queue): Long work: submit, poll, fetch. Where every video job runs. - [Pricing](/docs/pricing): What an image costs, and how the reservation becomes a charge. - [Routing](/docs/routing): What happens when the first attempt at serving a model fails. --- # Queue > Submit a job, poll it or stream it, fetch the result. The asynchronous half of the API. Section: Core API. Canonical page: https://routehook.ai/docs/queue A job is submitted, not awaited. POST /v1/queue/{model} accepts the work, reserves what it could cost and answers at once with a request_id and the three URLs that follow it (status, result and cancel), so nothing has to hold a connection open for minutes. Any model can be run this way, and video is always run this way, no browser, proxy or load balancer will keep a connection alive for a generation that takes that long. POST /v1/videos/generations and POST /v1/videos are the video-shaped spellings of the same submit; they put the job on this queue and answer with the same request_id. ## The routes | METHOD | PATH | WHAT IT DOES | | --- | --- | --- | | POST | /v1/queue/{model} | Submit a job. Returns immediately. | | GET | /v1/queue/requests/{job_id}/status | Poll: status, outcome, queue position, timings, optional logs. | | GET | /v1/queue/{model}/requests/{job_id}/status | fal's own path shape. Same handler, same answer. | | GET | /v1/queue/requests/{job_id} | The result. | | GET | /v1/queue/{model}/requests/{job_id} | Same. | | PUT | /v1/queue/requests/{job_id}/cancel | Cancel, and release the reservation. | | GET | /v1/queue/requests/{job_id}/stream | Server-sent status transitions, instead of polling. | | POST | /v1/run/{model} | Submit and wait, for callers that would rather block than poll. | | GET | /v1/jobs | List this account's jobs. Ours. Fal has no equivalent. | > **Warning: COMPLETED does not mean succeeded** > > status carries fal's three values and nothing else: IN_QUEUE, IN_PROGRESS, COMPLETED. A job that failed and a job that was cancelled both report COMPLETED. That is deliberate. The polling loop every fal client already runs stops when status reaches COMPLETED, so inventing a fourth value such as FAILED would leave that loop spinning for ever on a job that is never coming back. An unbounded spend of requests, on code that was working before the migration. What actually happened travels additively in outcome, one of succeeded, failed or canceled, and fetching the result of a failed job answers the published error envelope under the matching status code. Read status to learn that it is over; read outcome to learn how it went. ## The model is a path segment A model slug contains a slash, so it travels as a wildcard rather than one segment: POST /v1/queue/google/veo-3.1 submits to google/veo-3.1, and everything between /v1/queue and the query string is the slug. A caller with no path to spend (the MCP submit_job tool is the one that matters) may send model in the body instead. The path wins when both are present, so a body field can never redirect a request to a model the URL was not authorised against. ## The whole lifecycle **cURL** ```bash BASE="https://api.routehook.ai/v1" AUTH="Authorization: Bearer $ROUTEHOOK_API_KEY" # 1. Submit. Answers as soon as the job is accepted and the money is held. JOB=$(curl -s -X POST $BASE/queue/google/veo-3.1 \ -H "$AUTH" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: harbour-dawn-001" \ -d '{ "prompt": "drone shot over a harbour at dawn", "duration": 5 }' \ | jq -r .request_id) # 2. Poll. COMPLETED is terminal whatever the outcome, so this loop ends. while [ "$(curl -s $BASE/queue/requests/$JOB/status -H "$AUTH" | jq -r .status)" \ != "COMPLETED" ]; do sleep 3 done # 3. Fetch. A failed job answers the error envelope here, not a result. curl -s $BASE/queue/requests/$JOB -H "$AUTH" | jq '{ outcome, payload, cost }' # 4. Or give up on it, any time before it finishes. curl -s -X PUT $BASE/queue/requests/$JOB/cancel -H "$AUTH" ``` **JavaScript** ```javascript const BASE = "https://api.routehook.ai/v1"; const auth = { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` }; // 1. Submit. Answers as soon as the job is accepted and the money is held. const accepted = await fetch(`${BASE}/queue/google/veo-3.1`, { method: "POST", headers: { ...auth, "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID(), }, body: JSON.stringify({ prompt: "drone shot over a harbour at dawn", duration: 5, }), }); // status_url, response_url and cancel_url are absolute. Follow them rather // than rebuilding the paths from a base you configured separately. const job = await accepted.json(); // 2. Poll. COMPLETED is terminal whatever the outcome, so this loop ends. let state; do { await new Promise((resolve) => setTimeout(resolve, 3_000)); const polled = await fetch(job.status_url, { headers: auth }); state = await polled.json(); } while (state.status !== "COMPLETED"); // 3. Fetch. A failed job answers the error envelope here, not a result. const finished = await fetch(job.response_url, { headers: auth }); if (!finished.ok) { const { error } = await finished.json(); throw new Error(`${error.code}: ${error.message}`); } const { payload, cost } = await finished.json(); console.log(payload, cost); // 4. Or give up on it, any time before it finishes. await fetch(job.cancel_url, { method: "PUT", headers: auth }); ``` ## Submit The body is the model's own input object. fal takes a model's parameters at the top level (prompt, image_url, num_inference_steps, aspect_ratio, whatever that particular model accepts), and yours is forwarded upstream unchanged, because the sets differ per model and change whenever a vendor ships. Seven names are read by the gateway before the body is forwarded: model, prompt, n, size and duration, because the reservation is computed from them, and webhook_url, because it is an instruction to us rather than to the model. webhook_url is stripped from what is forwarded; nothing upstream ever receives it. ## POST /v1/queue/{model} Submit a job and return immediately with the id and the URLs that follow it. Status: Available. Authentication: any live API key on the account. ## The knobs a video model takes Because the body is passed through, the parameter names are the model's and not ours, and four of them are worth knowing before you send anything. `mode` selects quality (`standard` for 720p, `professional` for 1080p, and on Kling 2.6 `professional_audio` for a voiceover); `duration` is seconds; `resolution` is a tier (`480p`, `720p`, `1080p`); `aspect_ratio` is a shape (`16:9`, `9:16`, `1:1`). Not every model takes all four, and one it does not take is dropped rather than refused. Send nothing and you get the route's documented default, which for Kling is `standard` at five seconds. > **Note: These four decide what you pay** > > A media model is priced per configuration, not per model: Kling 2.6 is $0.039 a clip in standard and $0.091 in professional_audio, and Grok Video spans $0.0975 to $1.287 across resolution and duration. `GET /v1/models` publishes the cheapest reachable rate as `price` and the same figure in `pricing`; the configurations behind it are what a named `mode` or `resolution` selects. A submit that names none is held and billed at the route's default configuration, which is the one it will render. ## Reference images and video Image-to-video, motion control and edit models read their source from the body like everything else. `ref_images` is an array of publicly reachable image URLs and `ref_videos` an array of video URLs; `reference_urls` is the generic spelling and lands on whichever of the two that model takes first, which is images wherever it has them. The motion and edit models (kling-video-motion, kling-video-motion-3, kling-video-3-0-edit, kling-video-o1-edit) require at least one `ref_videos` entry and answer an error naming the field without one, and on those the source clip decides the length, so `duration` is ignored. Everything else takes references optionally, and a model that takes none ignores what you send. **Image-to-video, on Kling** ```bash curl -X POST https://api.routehook.ai/v1/queue/kling/kling-video-2-5 \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "prompt": "the camera pushes slowly through the doorway", "mode": "standard", "duration": 5, "aspect_ratio": "16:9", "ref_images": ["https://example.com/first-frame.jpg"] }' ``` ## What a submit answers **200 OK** ```json { "request_id": "job_ab12cd34", "status": "IN_QUEUE", "queue_position": 3, "status_url": "https://api.routehook.ai/v1/queue/requests/job_ab12cd34/status", "response_url": "https://api.routehook.ai/v1/queue/requests/job_ab12cd34", "cancel_url": "https://api.routehook.ai/v1/queue/requests/job_ab12cd34/cancel" } ``` ## Idempotency A submit that times out on the client is indistinguishable from one that never arrived, and retrying it blindly opens a second job against a second reservation. Send an Idempotency-Key header (any string unique to the logical request, reused across every attempt at it), and a repeat submit returns the job that already exists instead of starting another. It is a header rather than a body field because it describes the delivery of the request rather than the job, so it still matches when a client library has rebuilt the body. ## Poll the status The status route answers the same body whether you poll it or stream it. outcome is null until the job is terminal, and error is filled in on a failure so a poller learns why without a second round trip. ## GET /v1/queue/requests/{job_id}/status Poll a job: queue position, terminal outcome, timings and optional logs. Status: Available. Authentication: any live API key on the account. **GET /v1/queue/requests/job_ab12cd34/status?logs=1** ```json { "request_id": "job_ab12cd34", "status": "IN_PROGRESS", "outcome": null, "queue_position": null, "logs": [ { "message": "sampling frame 12/120", "level": "INFO", "timestamp": "2026-08-23T11:02:47Z" } ], "metrics": { "inference_time": null, "queue_time": 4.2 }, "error": null } ``` ## queue_position, and why null is not zero queue_position counts the jobs still ahead of this one, and goes null the moment this one leaves the queue. A running job that kept reporting 0 would read as about to start rather than already started, which is the wrong thing to put in front of someone watching a progress indicator. Treat a number as a position and null as no longer waiting. ## Logs Logs are gated behind a query parameter, exactly as fal gates them: add ?logs=1 and the job's own output comes back as an array of message, level and timestamp. level is whatever the upstream labelled the line (INFO, ERROR, STDOUT), and is not constrained to a fixed set on purpose, because one unrecognised label must never fail the response of a job that succeeded. Logs are diagnostic. Do not parse them for control flow; that is what outcome is for. > **Note: inference_time is in seconds** > > It is fal's field and fal's unit, and it is the one number on this API not suffixed with _ms. Clients read it straight out of the field, so translating it here would make every one of them wrong by a factor of a thousand. queue_time sits beside it (additive, ours), and is the seconds the job spent waiting to be claimed. Both are null until there is something to report. ## Stream the transitions instead GET /v1/queue/requests/{job_id}/stream answers text/event-stream and pushes each transition as it happens. Every frame carries exactly the status body above, so one parser serves both routes, and the stream closes when the job reaches a terminal state. Use it instead of a polling loop, not alongside one. A client doing both pays twice for the same information. ## GET /v1/queue/requests/{job_id}/stream Server-sent stream of a job's status transitions, instead of polling. Status: Available. Authentication: any live API key on the account. **text/event-stream** ```text data: {"request_id":"job_ab12cd34","status":"IN_QUEUE","outcome":null,"queue_position":3} data: {"request_id":"job_ab12cd34","status":"IN_PROGRESS","outcome":null,"queue_position":null} data: {"request_id":"job_ab12cd34","status":"COMPLETED","outcome":"succeeded","queue_position":null} ``` ## Fetch the result payload is the upstream's own result body, forwarded verbatim: { images: [...] } from an image model, { video: { url } } from a video one, whatever that model returns. It is deliberately not normalised: there is one shape per model, a normalisation would be a guess for every model discovered after it was written, and for the ones it guessed right about it would break the destructuring already written against fal. cost is what you were charged as a decimal string, and gateway_request_id is the req_… row this job wrote to the usage log. ## GET /v1/queue/requests/{job_id} Fetch the output of a finished job. Status: Available. Authentication: any live API key on the account. **GET /v1/queue/requests/job_ab12cd34** ```json { "request_id": "job_ab12cd34", "model": "google/veo-3.1", "created": 1786312455, "outcome": "succeeded", "payload": { "video": { "url": "https://cdn.routehook.ai/vid/ab12cd34.mp4" } }, "gateway_request_id": "req_7c41d9be", "cost": "0.4200", "usage": { "video_seconds": "5" } } ``` ## A failed job answers an error, not a result This route does not answer 200 with an error field. A job whose outcome is failed answers the published envelope (error with code, message and request_id) under the status code that matches the code, which is exactly what a fal client already handles. That is what makes the status deviation safe: the failure still reaches the client, through the route the client already fails on. The same failure is also readable inline on the status route, if you would rather learn about it while polling than on the fetch. **503 Service Unavailable** ```json { "error": { "code": "upstream_unavailable", "message": "fal could not generate this: upstream returned 500.", "request_id": "req_7c41d9be" } } ``` ## Cancel Cancelling is idempotent, and a job that had already finished answers 200 rather than a conflict. A client that cancels is usually reacting to a timeout it cannot tell apart from a lost response, so making it distinguish I stopped it from it had already stopped adds an error path carrying no information: either way the job is not running and nothing further will be charged. canceled says whether this call is what stopped it, and outcome is null for the moment between the cancellation being recorded and the worker abandoning an attempt already in flight upstream. ## PUT /v1/queue/requests/{job_id}/cancel Cancel a job that has not finished and release its hold. Status: Available. Authentication: any live API key on the account. **PUT /v1/queue/requests/job_ab12cd34/cancel** ```json { "request_id": "job_ab12cd34", "status": "COMPLETED", "outcome": "canceled", "canceled": true } ``` ## Wait, if you would rather not poll POST /v1/run/{model} is the same machinery with the wait folded in: it takes the submit body, blocks, and answers with the result body above. It is the right shape for a script and the wrong shape for a browser. Past a couple of minutes you are beyond what most proxies in between will hold, and a connection they drop looks exactly like one we dropped. There is a ceiling on the wait, set by the operator and fifteen minutes by default; reaching it ends the wait and not the job, and the call answers with the request_id so you can poll it the ordinary way. ## POST /v1/run/{model} Submit a job and hold the connection until it finishes or the wait ceiling is reached. Status: Available. Authentication: any live API key on the account. ## Find a job again fal gives you no way to list your own jobs, so a lost request_id is a lost job and a lost charge. GET /v1/jobs is ours: this account's jobs, newest first, filterable by model, by category and by status. That filter takes the stored vocabulary (queued, running, succeeded, failed, canceled) rather than the three wire statuses, because show me my failed jobs is the question the route exists to answer and COMPLETED cannot express it. It is keyset-paged: send the previous page's next_cursor as cursor, up to 200 rows at a time. A row carries the status, the ids and what it cost, never the payload. Fifty jobs of upstream JSON is a megabyte of response to draw a table, so results stay behind their own URL. ## Video, on OpenRouter's paths The same queue answers a second set of paths for video specifically. `POST /v1/videos` submits (it is the same handler as `POST /v1/videos/generations`, which is OpenAI's spelling of it), and `GET /v1/videos/{job_id}` polls, returning the status while the job runs and the result once it finishes. `GET /v1/videos/{job_id}/content` streams the finished bytes. Use whichever set your client already speaks; the job, the id and the bill are the same either way. ## POST /v1/videos/generations Submit a video generation and get a job id back. Answers 202: the work runs for minutes. Status: Available. Authentication: any live API key on the account. ## GET /v1/videos/{job_id} Poll a video job: its status while it runs, its result once it finishes. Status: Available. Authentication: any live API key on the account. ## GET /v1/videos/{job_id}/content Stream the finished video's bytes. Status: Available. Authentication: any live API key on the account. > **Note: Polling is free; only the submit is billed** > > The work was paid for when the job was accepted, so status, streaming, the result and cancellation all cost nothing. A job that has not finished answers 400 on /content rather than 404: the bytes are on their way, and a client told 'not found' would stop asking. ## GET /v1/jobs List this account's jobs, newest first, keyset-paged. Status: Available. Authentication: any live API key on the account. **GET /v1/jobs?status=succeeded&limit=1** ```json { "data": [ { "request_id": "job_ab12cd34", "model": "google/veo-3.1", "category": "video", "status": "COMPLETED", "outcome": "succeeded", "queue_position": null, "gateway_request_id": "req_7c41d9be", "cost": "0.4200", "error_code": null, "created_at": "2026-08-23T11:02:41Z", "started_at": "2026-08-23T11:02:46Z", "completed_at": "2026-08-23T11:04:00Z" } ], "next_cursor": null } ``` ## What a job costs Money is reserved at submit and settled at completion, the same sequence a synchronous call goes through. The hold is a ceiling computed from what you asked for (n at the model's per-image rate, or the seconds of video), and it is taken before any upstream call is made, so a submit can be refused with 402 insufficient_credits before any work starts. GET /v1/credits reports the total of open reservations under held, which is why an account with jobs in flight has a balance larger than what it can spend. - A job that succeeds settles at the real cost, and the rest of the reservation goes back. - A job that fails releases the reservation. Nothing is charged. - A cancelled job releases the reservation. Nothing is charged, whether it had started or not. - A completed job also writes a usage-log row, so it sits beside synchronous calls in the history and GET /v1/generation?id=req_… answers for it. ## Be called back instead Pass webhook_url in the submit body, or fal's ?fal_webhook= as a query parameter (they mean the same thing), and the terminal state is POSTed to that URL, for failures and cancellations as well as successes. There is no delivery for a transition into IN_PROGRESS; use the stream for progress. Every delivery is signed, and a signature you do not check is a callback anyone can forge: the webhooks page has the payload, the three headers and a verification routine in Node and Python. ## How long a job stays readable A finished job's input and output stay readable for a retention window the operator sets, thirty days by default, after which the row is swept. That window is not the billing record: the usage-log row the job wrote outlives it, so what a sweep removes is the prompt and the result, never the charge or the usage history. Store anything you need to keep. Payload carries URLs that expire, not files we hold for you. ## Related - [Webhooks](/docs/webhooks): The callback payload, its signature, and how to verify it. - [Migrate from fal.ai](/docs/migrate-fal): The same paths, and the one difference that matters. - [Cost Tracking](/docs/cost-tracking): Holds, settled charges and what a single job cost. --- # Embeddings > Vectors for search, clustering and retrieval, in OpenAI's shape. Section: Core API. Canonical page: https://routehook.ai/docs/embeddings `POST /v1/embeddings` takes a model and an input (one string, an array of strings, or an array of token arrays), and returns a list of embedding objects with the token usage alongside. It is OpenAI's request and OpenAI's response, so an OpenAI client's `embeddings.create` reaches it with nothing changed but the base URL and the model slug. ## A request **cURL** ```bash curl https://api.routehook.ai/v1/embeddings \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "openai/text-embedding-3-small", "input": ["first document", "second document"] }' ``` **JavaScript** ```javascript import OpenAI from "openai"; const client = new OpenAI({ baseURL: "https://api.routehook.ai/v1", apiKey: process.env.ROUTEHOOK_API_KEY, }); const response = await client.embeddings.create({ model: "openai/text-embedding-3-small", input: ["first document", "second document"], }); // One entry per input, and index says which input it came from. for (const item of response.data) { console.log(item.index, item.embedding.length); } ``` **Python** ```python import os from openai import OpenAI client = OpenAI( base_url="https://api.routehook.ai/v1", api_key=os.environ["ROUTEHOOK_API_KEY"], ) response = client.embeddings.create( model="openai/text-embedding-3-small", input=["first document", "second document"], ) # One entry per input, and index says which input it came from. for item in response.data: print(item.index, len(item.embedding)) ``` ## POST /v1/embeddings Turn text into vectors for search, clustering and retrieval. Status: Available. Authentication: any live API key on the account. ## Request parameters | PARAMETER | TYPE | REQUIRED | DESCRIPTION | | --- | --- | --- | --- | | model | string | required | A slug from GET /v1/models whose category is embedding. | | input | string \| string[] \| number[][] | required | One string, a batch of strings, or pre-tokenised input. | | encoding_format | float \| base64 | optional | How each vector comes back. Default float. | | dimensions | integer | optional | Truncate vectors to this width, where the model supports it. 1 to 8192; a larger value is refused here rather than upstream. | | user | string | optional | An opaque end-user identifier, forwarded upstream. | ## The response **200 OK** ```json { "object": "list", "model": "openai/text-embedding-3-small", "data": [ { "object": "embedding", "index": 0, "embedding": [0.0021, -0.0138, 0.0074] }, { "object": "embedding", "index": 1, "embedding": [0.0019, -0.0142, 0.0069] } ], "usage": { "prompt_tokens": 6, "total_tokens": 6 } } ``` > **Note: The vectors above are truncated** > > Three numbers fit on a page; a real vector is the model's full width, typically several hundred to a few thousand floats. Read the width from the response rather than assuming it: `dimensions`, the model, and an upstream's own defaults can all change it. ## Batching Pass an array and every item is embedded in one call. The results come back in `data` with an `index` naming the input each one came from. Rely on `index` rather than array position if anything in your pipeline reorders. Batching saves round trips and nothing else: the bill is the sum of the tokens either way, so a batch of fifty costs what fifty separate calls cost, minus fifty HTTP handshakes. ## Limits worth knowing Each item is embedded independently, so the model's context length applies to each one and not to the batch. A single document longer than the model's window is a `400`, however small the batch. Chunk long documents before you send them. Very large batches are also one all-or-nothing request: if it fails you retry the whole thing, which is a reason to keep batches in the hundreds rather than the tens of thousands. ## float or base64 `encoding_format: "float"` returns each vector as a JSON array of numbers. It is readable and it is large. A 1536-dimension vector is around 20KB of JSON. `encoding_format: "base64"` returns the same vector as a base64 string of little-endian float32s, roughly a third of the bytes and no decimal parsing on your side. Use base64 for anything at volume; use float when you are reading the response with your eyes. ## Decoding base64 vectors **JavaScript** ```javascript const response = await client.embeddings.create({ model: "openai/text-embedding-3-small", input: ["first document"], encoding_format: "base64", }); const bytes = Buffer.from(response.data[0].embedding, "base64"); const vector = new Float32Array( bytes.buffer, bytes.byteOffset, bytes.length / 4, ); console.log(vector.length, vector[0]); ``` **Python** ```python import base64 import numpy as np response = client.embeddings.create( model="openai/text-embedding-3-small", input=["first document"], encoding_format="base64", ) raw = base64.b64decode(response.data[0].embedding) vector = np.frombuffer(raw, dtype=" **Warning: A non-embedding model is a 400, not a wasted call** > > This route serves models whose category is `embedding`. Name a chat or an image model and the request is refused with `400 invalid_request`: before any upstream call is made, before credit is reserved, and before anything is spent. The message names the model and the category it actually is. Nothing is charged, so a mis-typed slug costs you a round trip and no money. ## Cost Embeddings bill on one axis: the tokens you send. There is no output side, which is why `usage` carries `prompt_tokens` and `total_tokens` and no completion count. The published rate is on the model in `GET /v1/models`; what a specific call was charged is in `GET /v1/generation?id=req_…`, keyed on the request id returned in the `X-Routehook-Request-Id` header. ## Failures | STATUS | CODE | MEANING | | --- | --- | --- | | 400 | invalid_request | Not an embedding model, an empty input, or an item over the context length | | 402 | insufficient_credits | Refused before any upstream call | | 409 | model_unavailable | No live endpoint can serve that embedding model | | 503 | upstream_unavailable | Upstream outage. Safe to retry | ## Related - [Models](/docs/models): The catalogue, filtered by category. - [Pricing](/docs/pricing): The axis each category bills on. - [Errors](/docs/errors): The eight codes, and what to do about each. --- # Completions > OpenAI's legacy prompt completion, routed exactly like chat. Section: Core API. Canonical page: https://routehook.ai/docs/completions `POST /v1/completions` takes a prompt rather than a message list and answers with an object of type `text_completion`. It exists so that code written against OpenAI's original completion API keeps working when the base URL changes, which is the whole point of the compatibility surface. > **Warning: New code should use chat** > > Almost nothing upstream still serves a genuine completion endpoint, so nearly every request here is translated into a chat call and back. The translation is lossless in shape but not in behaviour, and it gives you no tools, no multimodal parts and no message roles. `POST /v1/chat/completions` is the same routing, the same billing and a larger surface. Start there unless you are porting something. ## POST /v1/completions OpenAI's legacy prompt completion, translated onto the same routing as chat. Status: Available. Authentication: any live API key on the account. ## A request **cURL** ```bash curl https://api.routehook.ai/v1/completions \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "openai/gpt-5.4-mini", "prompt": "Write one sentence about harbour fog.", "max_tokens": 64 }' ``` **JavaScript** ```javascript import OpenAI from "openai"; const client = new OpenAI({ baseURL: "https://api.routehook.ai/v1", apiKey: process.env.ROUTEHOOK_API_KEY, }); const completion = await client.completions.create({ model: "openai/gpt-5.4-mini", prompt: "Write one sentence about harbour fog.", max_tokens: 64, }); console.log(completion.choices[0].text); ``` **Python** ```python import os from openai import OpenAI client = OpenAI( base_url="https://api.routehook.ai/v1", api_key=os.environ["ROUTEHOOK_API_KEY"], ) completion = client.completions.create( model="openai/gpt-5.4-mini", prompt="Write one sentence about harbour fog.", max_tokens=64, ) print(completion.choices[0].text) ``` ## Request parameters | PARAMETER | TYPE | REQUIRED | DESCRIPTION | | --- | --- | --- | --- | | model | string | required | Model slug from GET /v1/models. The same catalogue chat uses. | | prompt | string \| string[] | required | The prompt, or a batch of them. A batch answers with one choice per prompt, in order. | | max_tokens | integer | optional | Ceiling on generated tokens. Also caps what is reserved before the call runs. | | stream | boolean | optional | Emit server-sent events instead of one body. Default false. | | temperature | number | optional | 0 to 2. Forwarded unchanged. | | top_p | number | optional | 0 to 1. Forwarded unchanged. | | stop | string \| string[] | optional | Up to eight sequences that end generation when produced. | | n | integer | optional | 1 to 8 completions. Multiplies the reservation and the bill. | | seed | integer | optional | Best-effort determinism, where the model supports it. | | user | string | optional | An opaque end-user identifier, forwarded upstream. | | models | string[] | optional | Fallback model slugs, tried in order after model. | | suffix | string | optional | Up to 4,000 characters, forwarded to the upstream. Only insertion-capable models read it. | | presence_penalty | number | optional | -2 to 2. Forwarded unchanged. | | frequency_penalty | number | optional | -2 to 2. Forwarded unchanged. | | stream_options | object | optional | Only include_usage is read. It adds a final frame carrying usage and cost. | | logprobs | integer | optional | 0 to 5: this route's meaning, not chat's boolean. Translated on the way out; see below. | | echo | boolean | optional | Accepted and discarded. Chat has no equivalent, so it changes nothing. | | best_of | integer | optional | 1 to 8. Accepted and discarded, for the same reason as echo. | | transforms | string[] | optional | Accepted and discarded here, unlike chat, which refuses it outright. | > **Warning: echo, best_of and transforms are dropped, not honoured** > > All three parse, and none of them survives the translation onto a chat completion: `echo` and `best_of` have no chat equivalent at all, and `transforms` has no execution layer on this platform. The request succeeds and the answer simply does not reflect them. Note that `transforms` is treated differently on the two routes: `POST /v1/chat/completions` answers 400 rather than dropping it. If you rely on any of the three, this is the field to check before porting a legacy Completions integration. ## The translation This route is planned, billed and failed over exactly like chat: the same model catalogue, the same routing, the same reservation and settlement, and the same `models` field. The only difference is what happens at the edges of the call when the endpoint chosen for the model speaks chat and nothing else, which is nearly all of them. | ON THE WAY OUT | ON THE WAY BACK | | --- | --- | | prompt becomes a single message: { role: "user", content: } | choices[].message.content is unwrapped back into choices[].text | | max_tokens, temperature, top_p, stop, seed and n are passed through under the same names | finish_reason, index and usage are passed back under the same names | | logprobs: 0-5 becomes chat's logprobs: true plus top_logprobs: | The log probabilities come back under chat's names, unwrapped into this route's choices | | echo, best_of and transforms are consumed here and never sent | Nothing in the answer reflects them | | A prompt array becomes one request per prompt | object is text_completion, and the choices come back in the order the prompts went out | ## The response **200 OK** ```json { "id": "cmpl_5f81c0", "object": "text_completion", "created": 1786312455, "model": "openai/gpt-5.4-mini", "choices": [ { "index": 0, "text": "Fog rolled off the water and swallowed the cranes one by one.", "finish_reason": "stop" } ], "usage": { "prompt_tokens": 8, "completion_tokens": 14, "total_tokens": 22, "cost": "0.0000038" } } ``` > **Note: What the translation cannot give back** > > The shapes convert cleanly; the behaviour does not. A true completion endpoint **continues** the text you send it. An instruction-tuned chat model, handed the same text as a user turn, **answers** it, so a prompt written to be continued comes back replied to, and a few-shot prompt that ends mid-sentence may be finished with a commentary on the pattern rather than the next example. Where that difference matters, write the prompt as an instruction, or use chat and say what you want in a system turn. ## Streaming `stream: true` works here and uses OpenAI's legacy chunk shape: the object stays `text_completion` and each frame carries `choices[].text` rather than a delta. The framing, the `[DONE]` sentinel, the error frame and the `stream_options.include_usage` opt-in are all as described on the streaming page. ## Cost Identical to chat, on the same rates: prompt tokens on the input axis, generated tokens on the output axis, and `usage.cost` in the response with what was settled. A prompt array is charged as the requests it becomes. Batching prompts saves round trips, not money. ## Related - [Chat](/docs/chat): The route to build on, and the one this translates into. - [Streaming](/docs/streaming): The frames, and what a dead stream costs. - [Migrating from OpenRouter](/docs/migrate-openrouter): The same shapes, under the same field names. --- # Messages > The Anthropic Messages API, so an Anthropic client works against this base URL unchanged. Section: Core API. Canonical page: https://routehook.ai/docs/messages `POST /v1/messages` accepts Anthropic's request and answers in Anthropic's response shape. Construct an Anthropic SDK client with a new base URL and your key, change the model to a slug from `GET /v1/models`, and the rest of the integration is untouched, including the streaming event names, which is the part an Anthropic client is strictest about. ## An Anthropic client **JavaScript** ```javascript import Anthropic from "@anthropic-ai/sdk"; const client = new Anthropic({ baseURL: "https://api.routehook.ai", apiKey: process.env.ROUTEHOOK_API_KEY, }); const message = await client.messages.create({ model: "anthropic/claude-fable-5", max_tokens: 512, system: "Answer in one sentence.", messages: [{ role: "user", content: "Describe harbour fog." }], }); console.log(message.content[0].text); ``` **Python** ```python import os from anthropic import Anthropic client = Anthropic( base_url="https://api.routehook.ai", api_key=os.environ["ROUTEHOOK_API_KEY"], ) message = client.messages.create( model="anthropic/claude-fable-5", max_tokens=512, system="Answer in one sentence.", messages=[{"role": "user", "content": "Describe harbour fog."}], ) print(message.content[0].text) ``` **cURL** ```bash curl https://api.routehook.ai/v1/messages \ -H "x-api-key: $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "anthropic/claude-fable-5", "max_tokens": 512, "system": "Answer in one sentence.", "messages": [{ "role": "user", "content": "Describe harbour fog." }] }' ``` > **Warning: The base URL has no version prefix** > > An Anthropic client appends `/v1/messages` to whatever base URL you give it, so it wants https://api.routehook.ai. Pass it https://api.routehook.ai/v1 and the request goes to /v1/v1/messages and 404s. An OpenAI client is the opposite. It wants the prefix. Two clients, two strings; this is the mistake to check first when a working key returns a 404. ## POST /v1/messages Anthropic's Messages API, so an Anthropic client works against this base URL unchanged. Status: Available. Authentication: any live API key on the account. ## Request parameters | PARAMETER | TYPE | REQUIRED | DESCRIPTION | | --- | --- | --- | --- | | model | string | required | A slug from GET /v1/models. Anthropic/claude-sonnet-4, not a vendor date-stamped id. | | messages | array | required | Anthropic's turns: role user or assistant, content a string or an array of blocks. | | max_tokens | integer | required | Required here as it is in Anthropic's own API. There is no default to fall back on. | | system | string | optional | The system prompt, sent apart from the turns rather than as one of them. | | stream | boolean | optional | Emit Anthropic's event framing. Default false. | | temperature | number | optional | 0 to 1: Anthropic's range, not the 0 to 2 chat completions accepts. Above 1 is 400 invalid_request. | | top_p | number | optional | 0 to 1. Forwarded unchanged. | | top_k | integer | optional | Forwarded. Models that do not offer it ignore it. | | stop_sequences | string[] | optional | Up to 8 strings that end generation when produced. | | tools | array | optional | Tool definitions in Anthropic's schema. Read the limits below before relying on these. | | tool_choice | object | optional | Anthropic's tool selection, forwarded with the tools. Bear the tool_use limit below in mind. | | metadata | object | optional | Accepted and carried with the request. | ## max_tokens is required Unlike `POST /v1/chat/completions`, this route will not run without `max_tokens`. That is Anthropic's rule and it is kept, because a client that relies on the requirement would otherwise start issuing unbounded generations the moment it pointed here. Omitting it is `400 invalid_request`, before any upstream call is made and before any credit is reserved. It is also the ceiling the pre-flight reservation is sized on, so an honest number costs you less headroom than a large round one. ## The translation The request is translated into the gateway's internal chat request, routed through exactly the same chain as `POST /v1/chat/completions` (the same routing, the same failover, the same reservation and settlement), and translated back on the way out. Nothing about routing or billing is different; only the shapes at either end are. | ANTHROPIC FIELD | WHAT HAPPENS TO IT | | --- | --- | | system | Becomes a system turn at the head of the message list, rather than staying beside it. | | messages | Roles and content carry over one for one. A string content becomes the turn's text; a block array is flattened into it. | | max_tokens | The output ceiling, and the size of the reservation. | | stop_sequences | The stop list. | | temperature, top_p, top_k | Forwarded under the same names to whatever serves the call. | | metadata | Carried with the request; it does not change routing or price. | ## How a generation ends | WHY IT STOPPED | stop_reason | | --- | --- | | The model finished its answer | end_turn | | It hit your max_tokens | max_tokens | | It produced one of your stop_sequences | stop_sequence, with stop_sequence naming the string that matched | ## The response **200 OK** ```json { "id": "msg_5f81c0", "type": "message", "role": "assistant", "model": "anthropic/claude-fable-5", "content": [{ "type": "text", "text": "Fog swallowed the cranes one by one." }], "stop_reason": "end_turn", "stop_sequence": null, "usage": { "input_tokens": 9, "output_tokens": 12 } } ``` > **Note: model on the way back is our slug** > > The `model` field in the response is the slug you asked for, never the id the upstream knows it by. A model with several targets behind it would otherwise report a different id depending on which was healthy that minute, and your own logging and cost attribution key off that field. Nothing on the response names what actually served the call. ## The key goes in x-api-key An Anthropic SDK sends the key as `x-api-key`, and this route accepts it there, which is what lets a client be constructed with nothing but a new base URL. It is the same key and the same account as a bearer token; `Authorization: Bearer sk_live_…` works here too if you are calling by hand. Every other route on the platform reads the `Authorization` header only. ## Streaming `stream: true` emits Anthropic's framing, not OpenAI's: named events, each written as an `event:` line followed by its `data:` line. There is no `[DONE]` sentinel: `message_stop` ends the stream. The events come in one order: `message_start`, then `content_block_start`, a run of `content_block_delta` frames carrying `text_delta`, `content_block_stop`, then `message_delta` with the final `stop_reason` and output token count, then `message_stop`. ## The event stream **text/event-stream** ```text event: message_start data: {"type":"message_start","message":{"id":"msg_5f81c0","type":"message","role":"assistant","model":"anthropic/claude-fable-5","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":9,"output_tokens":0}}} event: content_block_start data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Fog"}} event: content_block_delta data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" swallowed the cranes"}} event: content_block_stop data: {"type":"content_block_stop","index":0} event: message_delta data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":12}} event: message_stop data: {"type":"message_stop"} ``` ## What is not supported The compatibility is the Messages route and its translation, and nothing wider. Everything below is a real gap, not an omission from this page. Check it against your integration before you switch a base URL. - **Model ids are ours.** Anthropic's own `claude-sonnet-4-20250514` is not a model here — send a slug this catalogue publishes, such as `anthropic/claude-fable-5`. An id this catalogue does not know is `409 model_unavailable`, which is the one line of a ported integration you will always have to change. - **Content blocks come back as text.** The response translation produces `text` blocks, and a stream emits `text_delta` and nothing else. `tools` are accepted and forwarded to the model, but a reply that is a tool call is not re-framed as a `tool_use` block, if your integration depends on `tool_use`, use `POST /v1/chat/completions`, where tool calls come back in OpenAI's shape. - **Errors are the gateway's envelope.** Failures answer `{ error: { code, message, request_id } }` with the eight codes used everywhere else here, not Anthropic's `{ type: "error", error: { type } }`. The HTTP status is right; code that branches on Anthropic's error `type` is not. - **No other Anthropic route exists.** Token counting, the Message Batches API, the Files API and the admin endpoints have no path here and answer 404. `POST /v1/messages` is the whole of the surface. - **Prompt caching and extended thinking are not part of the translation.** `cache_control` on a block and `thinking` blocks are not modelled; where cached input is billed differently, the saving shows up in the charge rather than in this response shape. ## Failures | STATUS | CODE | MEANING | | --- | --- | --- | | 400 | invalid_request | Missing max_tokens, an empty message list, or a malformed turn | | 401 | invalid_api_key | Key missing from both x-api-key and Authorization | | 402 | insufficient_credits | Refused before any upstream call | | 409 | model_unavailable | The slug is unknown, or nothing live can serve it | | 503 | upstream_unavailable | Upstream outage. Safe to retry | ## Related - [Chat](/docs/chat): The OpenAI-shaped route, with tools and streaming. - [SDKs](/docs/sdks): Which client reaches which route, and with which base URL. - [Streaming](/docs/streaming): The other framing, used by chat and completions. --- # Responses > OpenAI's Responses API, served by every text model here. Section: Core API. Canonical page: https://routehook.ai/docs/responses `POST /v1/responses` is OpenAI's newer text API and the one their SDK now defaults to. It takes an `input` (a prompt or a conversation), and answers with an `output` array plus an `output_text` convenience field. If your code says `client.responses.create(...)`, changing the base URL is the whole migration. > **Note: Every text model answers it, not just OpenAI's** > > The request is translated into a chat completion and run down the ordinary chat path (same routing, same failover, same billing), and the answer is translated back. A gateway that forwarded this route verbatim could only offer it on the one upstream that implements it; translating means Claude, Gemini, Llama and the rest answer here too. ## A request **cURL** ```bash curl https://api.routehook.ai/v1/responses \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "openai/gpt-5.4-mini", "input": "Summarise the difference between a hold and a charge." }' ``` **JavaScript** ```javascript import OpenAI from "openai"; const client = new OpenAI({ baseURL: "https://api.routehook.ai/v1", apiKey: process.env.ROUTEHOOK_API_KEY, }); const response = await client.responses.create({ model: "openai/gpt-5.4-mini", input: "Summarise the difference between a hold and a charge.", }); // The one-liner most examples use. console.log(response.output_text); ``` **Python** ```python from openai import OpenAI client = OpenAI( base_url="https://api.routehook.ai/v1", api_key=os.environ["ROUTEHOOK_API_KEY"], ) response = client.responses.create( model="openai/gpt-5.4-mini", input="Summarise the difference between a hold and a charge.", ) print(response.output_text) ``` ## POST /v1/responses OpenAI's Responses API, served by every text model on the platform rather than one vendor's. Status: Available. Authentication: any live API key on the account. ## Carrying a conversation `input` takes a list of role/content items as well as a string, and that is how a conversation is continued here. Send the turns you already have; the reply is appended by your own code, not by us. ```javascript const response = await client.responses.create({ model: "openai/gpt-5.4-mini", instructions: "Answer in one sentence.", input: [ { role: "user", content: "What is a credit hold?" }, { role: "assistant", content: "A reservation taken before a request runs." }, { role: "user", content: "When is it released?" }, ], }); ``` > **Warning: previous_response_id is refused, not ignored** > > OpenAI can store a response and continue it by id. This gateway stores no conversation content at all (only a usage row), so there is nothing here to continue. Sending `previous_response_id` returns `invalid_request` naming the limitation, rather than quietly dropping it and answering with a model that had forgotten everything before it. `store` is accepted and has no effect, for the same reason. ## Streaming Set `stream: true` and the answer arrives as server-sent events in this API's own vocabulary: `response.created`, then `response.output_text.delta` per token, then `response.output_text.done` and `response.completed` carrying the final object and its usage. A failure before the first byte is an ordinary JSON error with a real status; after it, an `error` event inside the stream. The split described on the [Errors](/docs/errors) page. ## Streamed ```javascript const stream = await client.responses.create({ model: "openai/gpt-5.4-mini", input: "Count to five.", stream: true, }); for await (const event of stream) { if (event.type === "response.output_text.delta") { process.stdout.write(event.delta); } } ``` ## Truncation is reported honestly A model cut off by `max_output_tokens` comes back with `status: "incomplete"` and `incomplete_details.reason: "max_output_tokens"`, not `completed`. A client that retries on truncation depends on that distinction, and reporting a cut-off answer as finished is the one status mistake that silently corrupts whatever consumes the text. --- # Audio > Text to speech and speech to text, in OpenAI's shape. Section: Core API. Canonical page: https://routehook.ai/docs/audio Two routes, pointing opposite ways. `POST /v1/audio/speech` takes text and answers with audio bytes; `POST /v1/audio/transcriptions` takes audio and answers with text. Both take OpenAI's fields and return OpenAI's answer, so an existing client reaches them with nothing changed but the base URL and the model slug, with one exception on the transcription route, which is called out below. > **Note: They are billed on different axes** > > Speech is charged **per character of the text you send**. The request itself is the meter, so the cost is known exactly before the call is made. Transcription is charged **per second of audio**. Neither answer has a body that could carry a usage block, which is why `X-Routehook-Cost` is the only place the charge appears. ## Speech The response is the audio itself, streamed rather than buffered, so a long document does not sit in memory on either side. `Content-Type` is the upstream's own (`audio/mpeg` for an MP3), and `X-Content-Type-Options: nosniff` rides with it. `voice` is passed through rather than validated here, so a voice a vendor adds is usable the day it ships instead of the day we notice; an unknown one is refused upstream with a message naming the valid set. ## Generating speech **cURL** ```bash curl https://api.routehook.ai/v1/audio/speech \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "openai/gpt-transcribe", "input": "Routehook audio is live.", "voice": "alloy" }' \ --output speech.mp3 ``` **JavaScript** ```javascript import OpenAI from "openai"; import { writeFile } from "node:fs/promises"; const client = new OpenAI({ baseURL: "https://api.routehook.ai/v1", apiKey: process.env.ROUTEHOOK_API_KEY, }); const speech = await client.audio.speech.create({ model: "openai/gpt-transcribe", input: "Routehook audio is live.", voice: "alloy", }); await writeFile("speech.mp3", Buffer.from(await speech.arrayBuffer())); ``` **Python** ```python import os from openai import OpenAI client = OpenAI( base_url="https://api.routehook.ai/v1", api_key=os.environ["ROUTEHOOK_API_KEY"], ) speech = client.audio.speech.create( model="openai/gpt-transcribe", input="Routehook audio is live.", voice="alloy", ) speech.write_to_file("speech.mp3") ``` ## POST /v1/audio/speech Turn text into spoken audio and stream the bytes back. Status: Available. Authentication: any live API key on the account. ## Speech parameters | PARAMETER | TYPE | REQUIRED | DESCRIPTION | | --- | --- | --- | --- | | model | string | required | A slug from GET /v1/models whose category is audio. | | input | string | required | The text to speak, up to 4,096 characters. This is the billable quantity. | | voice | string | optional | Vendor voice id. Default alloy. Not an enum. A new voice needs no release from us. | | response_format | mp3 \| opus \| aac \| flac \| wav \| pcm | optional | Container for the returned audio. Omitted leaves the model's default, which is usually mp3. | | speed | number | optional | Playback rate, 0.25 to 4.0. Forwarded rather than clamped here. | ## The speech response **200 OK** ```http HTTP/1.1 200 OK Content-Type: audio/mpeg X-Content-Type-Options: nosniff X-Routehook-Request-Id: req_7c41d9be X-Routehook-Cost: 0.00045 ``` > **Note: There is no JSON envelope here** > > The body is the file. Nothing of ours is wrapped around it (no `{ data: … }`, no base64) because an envelope is a difference every existing client would have to write code for, and these routes exist to avoid exactly that. The charge is on `X-Routehook-Cost` and the request id on `X-Routehook-Request-Id`; `GET /v1/generation?id=req_…` has the rest. ## Transcription Send the audio as base64 under `input_audio`, with or without a `data:` prefix, both are accepted, because a blob assembled by hand and one produced by a browser's `FileReader` differ only in that prefix. `format` names the container and is worth sending: the upstream decides how to decode from the filename, so a WAV labelled `mp3` is rejected by the decoder rather than by us. > **Note: OpenAI SDK uploads work too** > > The route also accepts OpenAI's `multipart/form-data` shape with a `file` field and the same model, language, response_format, temperature and timestamp_granularities fields. That means `audio.transcriptions.create()` works after changing the base URL; the JSON form below remains useful for browsers and direct HTTP calls. ## Transcribing audio **cURL** ```bash curl https://api.routehook.ai/v1/audio/transcriptions \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d "{ \"model\": \"openai/gpt-live-transcribe\", \"input_audio\": { \"data\": \"$(base64 -w0 speech.mp3)\", \"format\": \"mp3\" } }" ``` **JavaScript** ```javascript import { readFile } from "node:fs/promises"; const audio = await readFile("speech.mp3"); const response = await fetch("https://api.routehook.ai/v1/audio/transcriptions", { method: "POST", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ model: "openai/gpt-live-transcribe", input_audio: { data: audio.toString("base64"), format: "mp3" }, }), }); const { text } = await response.json(); ``` **Python** ```python import base64 import os import requests with open("speech.mp3", "rb") as handle: audio = base64.b64encode(handle.read()).decode() response = requests.post( "https://api.routehook.ai/v1/audio/transcriptions", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, json={ "model": "openai/gpt-live-transcribe", "input_audio": {"data": audio, "format": "mp3"}, }, ) print(response.json()["text"]) ``` ## POST /v1/audio/transcriptions Transcribe audio to text, with optional word and segment timestamps. Status: Available. Authentication: any live API key on the account. ## Transcription parameters | PARAMETER | TYPE | REQUIRED | DESCRIPTION | | --- | --- | --- | --- | | model | string | required | A slug from GET /v1/models whose category is audio. | | input_audio | object | required | { data, format }. Base64 audio, bare or with a data: prefix, and the container it is in. | | language | string | optional | ISO-639-1 hint. Improves accuracy and latency where the language is known. | | response_format | json \| text \| srt \| verbose_json \| vtt | optional | Shape of the transcript. Default json. | | temperature | number | optional | Sampling temperature, 0 to 1. Lower is more literal. | | timestamp_granularities | string[] | optional | word and/or segment. Only meaningful alongside verbose_json. | ## The transcription response **200 OK** ```json { "text": "Routehook audio is live." } ``` ## The other response formats are the vendor's own Only `json` is a shape we publish. `verbose_json` (the one that carries `duration`, `segments` and word timings) is forwarded from the upstream exactly as it arrived, and `text`, `srt` and `vtt` come back as the file itself under `text/plain; charset=utf-8` rather than as a quoted string. Normalising them would mean guessing at fields for every model discovered after the code was written, so they are passed through instead. Parse `json` if you want a stable shape; ask for the others when you want the vendor's. ## Which models can do this `GET /v1/models` reports a `category` of `audio` for every model on these two routes. The category tells you the route but not the direction (a model that transcribes cannot speak, and the reverse is also true), so read the model's own name for that. Naming a chat or an image model on either route is refused with `400 invalid_request` before any upstream call is made and before any credit is reserved, and the message names the category the model actually is. A target that could otherwise serve the model but does not do audio is skipped by the router rather than called. ## Cost Speech reserves the exact charge before the call: the characters you sent at the model's per-character rate, plus the model's flat per-request price where it carries one. Nothing is estimated, because you have already handed over the thing being counted. Transcription cannot work that way (the billable quantity is the duration of the audio, which nobody knows until the upstream reports one), so the hold is sized from the upload, deliberately high, and replaced by the true charge seconds later. Where no duration is reported the estimate stands and the request row records that the figure was estimated rather than metered. Either way the reservation is released and nothing is charged if the call fails. ## Failures | STATUS | CODE | MEANING | | --- | --- | --- | | 400 | invalid_request | Not an audio model, an input over 4,096 characters, or input_audio that is empty or not base64 | | 402 | insufficient_credits | Refused before any upstream call | | 409 | model_unavailable | No live endpoint can serve that audio model | | 503 | upstream_unavailable | Upstream outage. Safe to retry | ## Related - [Models](/docs/models): The catalogue, filtered by category. - [Pricing](/docs/pricing): The axis each category bills on. - [Errors](/docs/errors): The eight codes, and what to do about each. --- # Reranking > Score documents against a query and return them in order. Section: Core API. Canonical page: https://routehook.ai/docs/rerank `POST /v1/rerank` takes a query and a list of documents and returns them scored for relevance, highest first. It is the second pass of a retrieval pipeline: an embedding search is fast and approximate over the whole corpus, and a rerank is slow and accurate over the handful of candidates that search returned. > **Warning: This route needs a rerank model in the catalogue** > > Reranking is offered by Cohere, Jina and Voyage. None ships with a credential by default, so until an operator adds one the route answers `409 model_unavailable`: not `404`, because the surface exists and only its models are missing. The route is complete: all three vendors expose `POST /rerank` with this exact body, so configuring one is the whole of the work. ## Where it fits The usual shape is: embed the query, search a vector store, take the top fifty, rerank them, keep the top five. The first pass optimises for recall over millions of documents; the second optimises for precision over fifty. Doing only the first gives you results that are topically close but often not answers; doing only the second is too slow to run over a corpus. ## A request **cURL** ```bash curl https://api.routehook.ai/v1/rerank \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "YOUR_MODEL_ID", "query": "how do credit holds work", "documents": [ "A hold reserves the ceiling before the call is made.", "Invoices are issued on the first of the month.", "The reservation is released when the request fails." ], "top_n": 2 }' ``` **JavaScript** ```javascript const response = await fetch("https://api.routehook.ai/v1/rerank", { method: "POST", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ model: "YOUR_MODEL_ID", query: "how do credit holds work", documents: candidates, top_n: 5, }), }); const { results } = await response.json(); // index points back into `candidates`, not into `results`. const ranked = results.map((r) => candidates[r.index]); ``` ## POST /v1/rerank Score a set of documents against a query and return them in relevance order. Status: Available. Authentication: any live API key on the account. > **Note: index points at what you sent** > > Each result carries the position of the document in **your** `documents` array, not its position in the reordered response. That is what lets you map a ranking back onto your own records without matching on text, and it is why dropping `top_n` results still leaves the surviving ones addressable. ## What it costs Billed on the query plus the documents you send. There is no completion, so there is no output axis. Sending fifty candidates costs fifty candidates' worth whether you keep five of them or all of them, so trim the candidate list before the call rather than with `top_n` after it. --- # Models > List every model, its category, its status and the price it is published at. Section: Models. Canonical page: https://routehook.ai/docs/models GET /v1/models is the whole catalogue, every model, its category, its status and every axis it bills against. It answers anonymously and is cached, so a model picker or a pricing page can read it straight from a browser. There is no companion route naming the upstreams behind a model: which host serves a request is not part of what the model is. ## GET /v1/models Every model with its category, status, published price and per-token rates. Status: Available. Authentication: none — this route answers anonymously. ## What a model row carries | FIELD | TYPE | MEANING | | --- | --- | --- | | id | string | What you put in model. The primary alias where one is set, otherwise the canonical slug. | | canonical_slug | string | The stable vendor/model slug. Equal to id unless id is an alias. | | name | string | Display name, for a human. GPT-4o mini. | | description | string | One line on what the model is for. | | category | enum | text, image, video, audio, embedding or rerank. Decides which route serves it. | | status | enum | available, coming_soon or deprecated. | | unit | string | The billing unit in words, per 1M tokens, per 5s clip, 720p. | | price | number, null | Headline price in that unit. Null while a model is coming_soon. | | context_length | integer, null | Largest context this model accepts. Null where the idea does not apply. | | created | integer | Unix seconds the model joined the catalogue. | | architecture | object | Modalities in and out, tokeniser, instruct type. | | pricing | object | Every metered axis, as strings. | | supported_parameters | string[] | Request fields the model honours. | | accepted_durations | integer[], null | Seconds of video this model will accept. Null means no route imposes a set. | | accepted_aspect_ratios | string[], null | Shapes it accepts. Usually ratios (16:9); some models take orientation words (landscape). | | accepted_resolutions | string[], null | Tiers it renders at, 1K/2K/4K or 480p/720p/1080p. Often the axis its rate is keyed on. | | accepted_modes | string[], null | Quality tiers. Send as quality on /v1/images/generations, as mode through the queue. | | accepted_styles | string[], null | Style names it recognises, verbatim. An unrecognised style is ignored, not refused. | > **Note: Build your controls from the accepted_* lists** > > These four are what a model picker needs and cannot guess, and each of them has burned somebody. Veo takes 4, 6 and 8 seconds and refuses the rest. Grok Image, Meta AI Image and Grok Video express shape as landscape / portrait / square rather than as a ratio, so 16:9 names nothing they accept. Nano Banana sells 1K, 2K and 4K and no p-tier at all. Kling spells its middle tier professional and its voiceover tier professional_audio, and FLUX 3 and Minimax H3 accept exactly one mode each and require it. accepted_styles is the one to read most carefully, because a style a model does not recognise is ignored rather than refused: the request succeeds and the image comes back in no style at all. Null on any of them means the routes impose no set — send what the model documents — and never that we do not know. ## Frozen, then additive The first five fields (id, category, status, unit and price) are frozen, and almost every change since has been an addition. That is the kind of change a published catalogue should make: a field appears, an existing one does not change meaning. One field has gone — reference_price, the vendor's own list rate, which almost no maker publishes against an exact model id and which the catalogue therefore could not stand behind. Read the fields you need by name and ignore the rest. ## Rates are strings, the headline price is a number The rates inside pricing come back as JSON strings. A per-token rate is around 0.0000005; a client that parses a million of them as float64 and adds them up drifts away from the invoice, and internally the value is a decimal backed by a numeric column, so emitting a number would mean converting it to a double on the way out. Parse rates with a decimal library, not parseFloat. price stays a number: it is a headline for a human to read, not an input to billing arithmetic. ## The pricing object | AXIS | METERED ON | | --- | --- | | prompt | Each input token. | | completion | Each output token. | | cached_prompt | Each input token served from the upstream prompt cache. | | internal_reasoning | Each reasoning token, where they are billed apart from completion. | | request | A flat charge per call, on top of any metered axis. | | image | Each generated image. | | image_input | Each image supplied as input. | | video_second | Each second of generated video. | | audio_second | Each second of generated or transcribed audio. | | web_search | Each built-in web-search call. | Null on an axis means the model is not metered on it. That is a different claim from the string 0, which means metered and free. A text model has a null image rate, a promotional model has a zero one, and a client that treats the two the same bills the wrong thing the first time one becomes the other. ## architecture architecture.modality is the summary (text->text, text->image), and the arrays under it are the precise version: input_modalities is what you may send, output_modalities is what comes back. tokenizer names the tokeniser the context length is counted in, and instruct_type names the prompt template that models needing one expect; both are null where the vendor has published neither. Check input_modalities before sending an image inside a message, rather than finding out from a 400. ## supported_parameters supported_parameters lists the request fields this model honours. Tools, temperature, response_format and the rest. Read it before you send one: a parameter that is not on the list may be ignored rather than refused, so a request carrying tools can come back as a plain completion. Checking the list at render time is cheaper than discovering it in a response you have already paid for. ## The three statuses | STATUS | PRICE | WHAT A REQUEST DOES | | --- | --- | --- | | available | Set | Routed and billed normally. | | coming_soon | Null | 409 model_unavailable. Announced, listed, not yet servable. | | deprecated | Set | Still served and still billed. It will stop being. Move off it. | Nothing is priced automatically. A model discovered on an upstream catalogue arrives as coming_soon with a null price and stays there until somebody sets one, which is why an unpriced model is refused rather than served for nothing: a 200 that charged nothing cannot be billed for afterwards. deprecated exists so that retiring a model is distinguishable from deleting one, without it, a model on its way out looks exactly like a model that never existed. ## Aliases A model published with a primary alias answers to both — the short name and the full `vendor/model` slug are one model, and `GET /v1/models` prints the pair on every row as `id` and `canonical_slug`. The alias is resolved in the same query as the slug, so the short form costs the same single round trip as the long one and neither is a redirect. The response, the charge and the record on GET /v1/generation are identical. Store canonical_slug rather than id: canonical_slug is stable, while the id published for a model changes if its primary alias changes. ## Narrower reads Four routes answer a smaller question than the full catalogue, and each is cheaper to parse than filtering it yourself. `GET /v1/models/count` is the number of servable models. `GET /v1/embeddings/models`, `/v1/images/models` and `/v1/videos/models` are the catalogue narrowed to one modality, each carrying a `total_count` beside its `data`. `GET /v1/model/{author}/{slug}` is one model. Note the singular `model`, which is the spelling this route has and the plural one does not. ## GET /v1/models/count How many models are servable right now. Status: Available. Authentication: none — this route answers anonymously. ## GET /v1/model/{author}/{slug} One model by slug. Singular 'model'. OpenRouter's spelling for this route. Status: Available. Authentication: none — this route answers anonymously. ## GET /v1/embeddings/models The catalogue narrowed to embedding models, with a total count. Status: Available. Authentication: none — this route answers anonymously. `GET /v1/models/user` exists and answers, but it returns the same set as `GET /v1/models`: model access here is not scoped per key, so there is no narrower list for it to give. It is published so that a client written against a gateway that does scope access gets a catalogue rather than a 404. ## Reading the catalogue **Two catalogue reads, no key** ```bash # Every text model callable today, cheapest first. The catalogue # route takes no query parameters. Narrow it client-side, or use one # of the modality routes above. curl -s https://api.routehook.ai/v1/models \ | jq '[.data[] | select(.category == "text" and .status == "available")] | sort_by(.price) | .[] | {id, price, unit}' # One model in full, including every axis it bills against. curl -s https://api.routehook.ai/v1/model/openai/gpt-5.4-mini \ | jq '.data | {id, name, context_length, pricing, supported_parameters}' ``` > **Warning: Listed is not callable** > > A model appears in the catalogue before it can be called. Filter on status == available when you build a picker, or the first person to choose a coming_soon model gets a 409 you could have prevented at render time. ## Related - [Pricing](/docs/pricing): The live rate table, and how a request becomes a number. - [Routing](/docs/routing): What happens when the first attempt at serving a model fails. --- # Routing > A request names a model. What happens when the first attempt at serving it fails. Section: Models. Canonical page: https://routehook.ai/docs/routing A request names a model and may include OpenRouter-compatible provider preferences. Behind each model is a chain of eligible upstream targets; request preferences may narrow or order that chain, and the request walks it until something answers. > **Note: Provider preferences are request-scoped** > > Use `provider.only`, `ignore`, `order`, `sort`, `allow_fallbacks`, `require_parameters` and `max_price` in a chat request. These preferences can only remove or reorder configured targets; they cannot add an endpoint the operator has not approved. ## Failover When the chosen target errors or times out, the next in the chain is tried inside the same request, and you see one response. Three rules bound that. A failure that will repeat ends the chain immediately. A 400 is your body and a 401 is our credential, and four more attempts produce four more identical rejections and four more seconds of latency. A stream commits at its first byte, after which a second attempt would append a different answer to one you already hold, so nothing fails over past that point. And every attempt is recorded whether it wins or loses, because a fallback that quietly rescues every request hides a dead target indefinitely. ## The circuit breaker A target that keeps failing is skipped rather than tried and waited out. The count is consecutive failures, not a failure rate: 2% failures on a busy target is background noise, while five in a row is a target that is not answering, and any success resets the streak. While its circuit is open the target is not in the chain at all, which is what keeps a dead target costing microseconds instead of a full timeout on every call. It is retried on a probe once the open window passes, and one success closes it again. ## Response headers about your request | HEADER | VALUE | | --- | --- | | X-Routehook-Request-Id | The id to quote in support, and to GET /v1/generation. | | X-Routehook-Attempts | How many attempts the request took. | | X-Routehook-Fallback | true when the attempt that answered was not the first one. | | X-Routehook-Cost | USD charged for this call, as a decimal string. | | X-Routehook-Upstream-Latency-Ms | How long the upstream leg took, excluding our own overhead. | Every one of those is a measurement of your own call. None of them identifies the host that served it, and that is deliberate rather than an oversight. A header is the easiest thing in an API to add by accident, so it is worth saying plainly that two which used to be here, naming the provider and the upstream model id, were removed and are not coming back. ## What X-Routehook-Fallback: true means It means the first attempt failed and a later one answered. The response is good (that is the point of a chain), but it is also the only signal you get that something behind this model is having a bad afternoon, because nothing else about the response looks different. Log the header next to your own request log line. A run of trues on one model is worth raising with us before it turns into errors you can see. ## Whether a fallback changes what you pay The published rate belongs to the model, so being served by a later attempt does not change the rate. What can change is the quantity the rate is applied to: a fallback target may run a different build of the same model, which tokenises your prompt slightly differently and returns an answer of a different length. Both figures are on the response (usage.cost is exact, and GET /v1/generation reports the same call's whole economics), so a bill is always reconcilable against what you were actually served. ## models is the lever you do have You cannot choose the route, but you can choose the fallback *model*. models names other slugs to try, in order, and one is used when the model before it cannot be served at all. Nothing enabled, nothing priced, nothing left in the chain. That is a change of model rather than of route, which is why it is your decision and never ours, and it does move the bill: the model that answers is billed at its own published price. The response body always carries the model that actually answered, so cost attribution keyed on that field stays correct. ## Naming a fallback model **A cheaper sibling, tried only if the first cannot be served** ```json { "model": "anthropic/claude-sonnet-4-6", "models": ["openai/gpt-5.4-mini"], "messages": [{ "role": "user", "content": "Hello" }] } ``` ## An ordinary request **There is nothing to configure. Name the model and send it** ```bash curl https://api.routehook.ai/v1/chat/completions \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "openai/gpt-5.4-mini", "messages": [{ "role": "user", "content": "Hello" }] }' ``` ## When nothing can serve it 409 model_unavailable means the chain came out empty: the model is in the catalogue and no route for it is available right now. The message does not enumerate what was dropped or why. Those reasons name individual upstream targets, and there is nothing in them you could act on anyway. Retry, or send a different slug from GET /v1/models. If a model answers 409 for more than a few minutes, that is worth reporting. ## Related - [Models](/docs/models): The catalogue, and what each field on a model means. - [Cost tracking](/docs/cost-tracking): Reading back what one request actually cost. - [Errors](/docs/errors): The eight codes, and which of them are worth retrying. --- # Pricing > How a request becomes a number, and where that number lands. Section: Models. Canonical page: https://routehook.ai/docs/pricing Each model bills against one unit — per million tokens, per image, per second of video — and publishes that unit alongside its rate, so the cost of a request is arithmetic rather than an estimate and switching models is a price change you can read before you make it. ## One model, several rates A media model does not always have a single price. Seedance 2 sells at six rates from $0.078 to $0.351 a second depending on the mode and resolution you ask for, and GPT Image 2 at nineteen from $0.039 to $0.728 an image. Those are configurations, and the one your request matches is the one you are billed at. Send the parameters that select it — mode, resolution, duration — and the charge is decided before the work starts. Send none and the route's own documented default applies, and that configuration's rate is what you pay. Plenty of models sell at one rate for every configuration instead: Nano Banana Pro is $0.0195 an image at 1K, 2K and 4K alike, and Veo 3.1 Fast is $0.026 for a video of any length it accepts. Each model's page says which of the two it is. ## What price on a model means GET /v1/models publishes price as the cheapest rate a request can actually reach, so on a model sold at several it is a from figure rather than the only one, and unit says what that rate is charged against — per image, per second of video, or per video however long it runs. Read both: a per-video rate multiplied by a clip length is an invoice that never arrives. Token rates are quoted per million there, matching the unit beside them, while pricing carries the exact per-token string for arithmetic. Every model's own page lists its full ladder, one row per configuration, which is the place to look before you commit to a shape. ## Durations are not a free range Video routes accept fixed sets rather than any number of seconds: Veo takes 4, 6 or 8 and refuses everything else, Grok takes 4 to 10 and 15. Models that constrain it publish accepted_durations on GET /v1/models, and a null there means the route imposes no set rather than that we do not know. Read it before you build a control, because a duration the route rejects fails after you have been quoted, not before. ## Live rates | MODEL ID | CATEGORY | PRICE | UNIT | | --- | --- | --- | --- | | black-forest-labs/flux-3 | Video | $0.84 | per second | | bytedance/seedance-2 | Video | $0.078 | per second | | bytedance/seedance-2-5-omni | Video | $0.182 | per second | | bytedance/seedance-2-mini | Video | $0.065 | per second | | bytedance/seedance-2-omni | Video | $0.1235 | per second | | google/veo-2 | Video | $0.13 | per video | | google/veo-3.1 | Video | $0.65 | per video | | google/veo-3.1-fast | Video | $0.026 | per video | | google/veo-3.1-lite | Video | $0.026 | per video | | kling/kling-video-2-1-10s | Video | $0.039 | per video | | kling/kling-video-2-1-5s | Video | $0.0455 | per video | | kling/kling-video-2-5 | Video | $0.0195 | per video | | kling/kling-video-2-6 | Video | $0.039 | per video | | kling/kling-video-3-0 | Video | $0.0715 | per second | | kling/kling-video-3-0-edit | Video | $0.078 | per video | | kling/kling-video-motion | Video | $0.039 | per video | | kling/kling-video-motion-3 | Video | $0.065 | per video | | routehook/omni-flash | Video | $0.04875 | per video | | xai/grok-video | Video | $0.0975 | per video | | google/nano-banana-2 | Image | $0.0195 | per image | | google/nano-banana-2-lite | Image | $0.0195 | per image | | google/nano-banana-pro | Image | $0.0195 | per image | | openai/gpt-image-2 | Image | $0.039 | per image | | xai/grok-image | Image | $0.026 | per image | | anthropic/claude-fable-5 | Text | $12.50 | per 1M tokens | | anthropic/claude-fable-5-1 | Text | $12.50 | per 1M tokens | | anthropic/claude-opus-5 | Text | $6.25 | per 1M tokens | | anthropic/claude-sonnet-4-6 | Text | $3.75 | per 1M tokens | | anthropic/claude-sonnet-5 | Text | $2.50 | per 1M tokens | | deepseek/deepseek-flash | Text | $0.375 | per 1M tokens | | deepseek/deepseek-v4-pro | Text | $0.375 | per 1M tokens | | openai/gpt-5.4 | Text | $3.125 | per 1M tokens | | openai/gpt-5.4-mini | Text | $0.9375 | per 1M tokens | | openai/gpt-5.4-nano | Text | $0.25 | per 1M tokens | | openai/gpt-5.5 | Text | $6.25 | per 1M tokens | | openai/gpt-5.6-luna | Text | $0.25 | per 1M tokens | | openai/gpt-5.6-sol | Text | $5.00 | per 1M tokens | | openai/gpt-5.6-terra | Text | $2.50 | per 1M tokens | | openai/gpt-6-astra | Text | $12.50 | per 1M tokens | | openai/gpt-audio-1.5 | Text | $40.00 | per 1M tokens | | openai/gpt-audio-mini | Text | $12.50 | per 1M tokens | | openai/gpt-image-2.5-flare | Text | $10.00 | per 1M tokens | | openai/gpt-image-2.5-sunburst | Text | $10.00 | per 1M tokens | | openai/gpt-live-1 | Audio | $0.001042 | per second | | openai/gpt-live-transcribe | Audio | $0.000354 | per second | | openai/gpt-realtime-translate | Audio | $0.000708 | per second | | openai/gpt-realtime-whisper | Audio | $0.000354 | per second | | openai/gpt-transcribe | Audio | $0.000094 | per second | | openai/text-embedding-3-large | Embeddings | $0.1625 | per 1M tokens | | openai/text-embedding-3-small | Embeddings | $0.025 | per 1M tokens | ## Rates are strings Per-token rates come back as JSON strings, not numbers. A rate of 0.00000015 per token does not survive a round trip through a double intact, and a billing figure that is almost right is worse than one that is obviously a string. Parse them with a decimal library, not parseFloat. Balances on GET /v1/credits are the exception: those are numbers, rounded to two places. ## Reserve, then settle Nothing is charged before it is known. The most a request could cost is held against the balance when it starts, the real cost is settled when it finishes, and the hold is released if it fails, which is why GET /v1/credits reports balance and held separately, and why a retry after a 5xx does not pay twice. ## What one call cost GET /v1/generation?id=req_… reports the charge for a single request alongside the tokens it was computed from, so a charge can be checked against the usage behind it rather than taken on trust. ## What is not charged - Requests that fail before any upstream call is made. - Jobs that end failed or canceled. The hold is released, not settled. - Input and reference images. An edit against references costs what a generation costs. - Storing a result, or downloading it afterwards. - Reading the catalogue, a job's status, the key, the balance or the status page. > **Note: The rate in the table is the whole rate** > > There is no per-seat fee, no monthly minimum, no storage line and no egress line. A request costs its model's rate for the configuration it matched, and nothing else is added to it. > **Note: Read the price, do not store it** > > Rates move as contracts move. Read them from GET /v1/models rather than pinning a number in your code, or the first renegotiation makes your invoice and your dashboard disagree. ## Related - [Models](/docs/models): The catalogue, its statuses and its published rates. - [Cost Tracking](/docs/cost-tracking): Balance, holds, and what a single request cost. --- # Migrate from OpenRouter > Change a base URL and a key. The shapes are the same ones. Section: Guides. Canonical page: https://routehook.ai/docs/migrate-openrouter `/v1/chat/completions`, `/v1/completions`, `/v1/models`, `/v1/key`, `/v1/credits` and `/v1/generation` answer in OpenRouter's shapes, under OpenRouter's field names. For most integrations the migration is two lines. Everything this gateway adds is additive (extra keys on the same objects), so a client that ignores what it does not recognise will never see them. **The whole change, for most integrations** ```diff const client = new OpenAI({ - baseURL: "https://openrouter.ai/api/v1", - apiKey: process.env.OPENROUTER_API_KEY, + baseURL: "https://api.routehook.ai/v1", + apiKey: process.env.ROUTEHOOK_API_KEY, }); ``` ## Identical, and needs no edit - The request body of `/v1/chat/completions` and `/v1/completions`, including `models`, `stream`, `tools`, `response_format`, provider routing and every sampling parameter. - The streaming framing: `data: {…}` frames, terminated by `data: [DONE]`. - `usage`, with `prompt_tokens`, `completion_tokens` and `total_tokens`. - The `Authorization: Bearer` header, and unknown body fields being forwarded upstream rather than refused. - The request and response of `/v1/responses`, `/v1/embeddings`, `/v1/images`, `/v1/videos`, `/v1/audio/speech`, `/v1/audio/transcriptions` and `/v1/rerank`. ## Route map | OPENROUTER | HERE | WHAT CHANGES | | --- | --- | --- | | POST /api/v1/chat/completions | POST /v1/chat/completions | Nothing. Same body, same response, same SSE framing. | | POST /api/v1/completions | POST /v1/completions | Nothing on the wire. The prompt is wrapped as one user message upstream and unwrapped back to choices[].text. | | GET /api/v1/models | GET /v1/models | Same shape. The ids are this catalogue's slugs. Check them, do not assume them. | | GET /api/v1/key | GET /v1/key | Their seven fields, plus key_id, account_id and timestamps. | | GET /api/v1/credits | GET /v1/credits | total_credits and total_usage unchanged; balance, held and available added. | | GET /api/v1/generation?id= | GET /v1/generation?id= | Same field names. Neither provider_cost nor provider_name is published. | | GET /api/v1/providers | - | No equivalent. There is no published provider directory here. | | POST /api/v1/auth/keys | - | No OAuth exchange. Keys are issued in the dashboard. | | GET, POST /api/v1/keys | - | No key-provisioning API. is_provisioning_key is always false. | | GET /api/v1/models/user | GET /v1/models/user | Answers, but returns the full catalogue: there are no per-key model allowances here to narrow it by. | | GET /api/v1/models/count | GET /v1/models/count | Same shape. | | GET /api/v1/model/:author/:slug | GET /v1/model/{author}/{slug} | Same shape. Singular `model`: their spelling for this route, kept. | | GET /api/v1/embeddings/models | GET /v1/embeddings/models | Same shape, with total_count. Likewise /v1/images/models and /v1/videos/models. | | POST /api/v1/responses | POST /v1/responses | Same body and same output[] / output_text response. Served by every text model here, not one vendor's. | | POST /api/v1/embeddings | POST /v1/embeddings | Nothing. Same body, same list response. | | POST /api/v1/images | POST /v1/images | Same body. /v1/images/generations is the same handler under OpenAI's path. | | POST /api/v1/videos | POST /v1/videos | Same body. Poll with GET /v1/videos/{job_id} and fetch bytes from /content. | | POST /api/v1/audio/speech | POST /v1/audio/speech | Same body. Answers with the audio bytes and the vendor's content type. | | POST /api/v1/audio/transcriptions | POST /v1/audio/transcriptions | The JSON form, with the audio base64 under input_audio. | | POST /api/v1/rerank | POST /v1/rerank | Same body. Needs a rerank model in the catalogue before it serves. See Reranking. | ## The base URL `https://openrouter.ai/api/v1` becomes `https://api.routehook.ai/v1`. The `/api` segment goes and the `/v1` stays. An OpenAI client appends the route to whatever base URL it is given, so that is the string it wants verbatim. An Anthropic client is the one exception, and /docs/sdks says what it wants instead. ## The key An OpenRouter key is `sk-or-v1-…`; here it is `sk_live_…`. The header does not change. There is no test key and no sandbox (every key spends the balance), so cap what a new one may spend and read `GET /v1/key` to assert which key a deploy picked up, rather than finding out from an invoice. ## Attribution headers `HTTP-Referer` and `X-Title` do not have to be removed. `HTTP-Referer` is recorded as `origin` on the request record and comes back on `GET /v1/generation`. `X-Title` is accepted and then ignored. There is no public application leaderboard here to be ranked on, so the header has nowhere to go. ## Model ids Slugs are `vendor/model`, and many are spelt exactly as OpenRouter spells them, but they are this catalogue's ids, not a mirror of theirs. Resolve every id you send today against `GET /v1/models` before cutting over. An id that does not exist here answers `409 model_unavailable`, which is a clean failure rather than a silent substitution. ## Variant suffixes | OPENROUTER | HERE | | --- | --- | | model:nitro | The model id on its own. There is no per-request sort. Routing is ours. | | model:floor | The model id on its own. There is no per-request sort. Routing is ours. | | model:free | Nothing. There is no free tier, and is_free_tier is always false. | ## Fallback models OpenRouter's `models` array is here and behaves the same way: up to eight alternatives, tried in order, and only once every attempt at the preceding model has been exhausted. It changes which *model* answers, which is the caller's decision and never the operator's, so nothing is tried unless you send it. **A request with its own fallback list** ```json { "model": "openai/gpt-5.4-mini", "models": ["anthropic/claude-fable-5"], "messages": [{ "role": "user", "content": "Hello" }] } ``` > **Note: The provider block ports directly** > > Chat requests accept OpenRouter's `provider` object: `only`, `ignore`, `order`, `sort`, `allow_fallbacks`, `require_parameters` and `max_price`. The block narrows or orders the configured route before credit is reserved and before an upstream is called. The configured circuit breaker still removes unhealthy targets automatically. Your block is applied on top: `only` and `ignore` narrow the chain, `order` and `sort` reorder it, `allow_fallbacks: false` pins the first eligible target, and `require_parameters` removes targets that cannot honour fields in this request. > **Warning: If the block was a data-residency control, talk to us first** > > `only` is the one use of it that has no automatic equivalent. A list pinned because a contract says where inference may run is a real requirement, and it is not something failover can honour on your behalf. Get in touch before you migrate rather than after: it is an account-level arrangement here, not a request field. > **Warning: error.code is a string here** > > OpenRouter puts the HTTP status in `error.code`. Here `error.code` is a string — `invalid_request`, `invalid_api_key`, `insufficient_credits`, `model_unavailable`, `rate_limited`, `upstream_unavailable`, `not_found`, `gone` or `duplicate_request` — and the status stays on the response, where it always was. The Errors page lists all nine with the status each arrives on. Error handling that compares `error.code` to a number will read every failure as unrecognised and retry none of them. ## Before **JavaScript** ```javascript import OpenAI from "openai"; const client = new OpenAI({ baseURL: "https://openrouter.ai/api/v1", apiKey: process.env.OPENROUTER_API_KEY, defaultHeaders: { "HTTP-Referer": "https://your.app", "X-Title": "Your App", }, }); const completion = await client.chat.completions.create({ model: "openai/gpt-5.4-mini", models: ["anthropic/claude-fable-5"], provider: { sort: "throughput" }, messages: [{ role: "user", content: "Hello" }], }); console.log(completion.choices[0].message.content); ``` **Python** ```python import os from openai import OpenAI client = OpenAI( base_url="https://openrouter.ai/api/v1", api_key=os.environ["OPENROUTER_API_KEY"], ) completion = client.chat.completions.create( model="openai/gpt-5.4-mini", messages=[{"role": "user", "content": "Hello"}], extra_body={ "models": ["anthropic/claude-fable-5"], "provider": {"sort": "throughput"}, }, ) print(completion.choices[0].message.content) ``` ## After **JavaScript** ```javascript import OpenAI from "openai"; const client = new OpenAI({ baseURL: "https://api.routehook.ai/v1", apiKey: process.env.ROUTEHOOK_API_KEY, defaultHeaders: { "HTTP-Referer": "https://your.app", "X-Title": "Your App", }, }); const completion = await client.chat.completions.create({ model: "openai/gpt-5.4-mini", models: ["anthropic/claude-fable-5"], messages: [{ role: "user", content: "Hello" }], }); console.log(completion.choices[0].message.content); ``` **Python** ```python import os from openai import OpenAI client = OpenAI( base_url="https://api.routehook.ai/v1", api_key=os.environ["ROUTEHOOK_API_KEY"], ) completion = client.chat.completions.create( model="openai/gpt-5.4-mini", messages=[{"role": "user", "content": "Hello"}], extra_body={ "models": ["anthropic/claude-fable-5"], }, ) print(completion.choices[0].message.content) ``` ## What is not here - **No key provisioning.** There is no `/keys` API and no OAuth exchange. Keys are created in the dashboard, and `is_provisioning_key` on `GET /v1/key` is always `false`. - **No per-key model allowances.** Every key reaches the whole catalogue, so there is no filtered model list to migrate and nothing to grant. - **No free tier.** `is_free_tier` is always `false`, and a `:free` id resolves to no model at all. - **Transforms and plugins are not implemented yet.** A non-empty request is rejected explicitly instead of being silently changed. Trim long prompts yourself and remove plugin configuration before migrating. - **No bring-your-own-key.** There is no route to attach your own upstream credentials and be billed by that vendor instead of by us. ## What you gain - `POST /v1/embeddings` and `POST /v1/images/generations`, neither of which OpenRouter serves. - `POST /v1/messages`, so an Anthropic client reaches the same account and the same balance. - The queue (`POST /v1/queue/{model}` and the routes that follow it) for video and anything else that outlives an HTTP request. - `POST /v1/mcp`: the catalogue, the gateway and the queue as MCP tools. ## Cutting over 1. **Issue a key for the migration** Give the cutover its own key with a spend cap, rather than reusing one already in production. It is obvious in a log, cheap to revoke, and the cap bounds what a mistranslated request can cost while you are still checking the mapping. 2. **Resolve every model id** Read `GET /v1/models` and map each id your code sends today onto a slug that exists here. This is the only step that cannot be automated, and the only one that fails at runtime if it is skipped. 3. **Point one environment at the new base URL** Change the two lines and deploy staging. The `provider` block, `models` array and streaming loop carry over unedited; remove plugins and transforms until those execution layers are available. 4. **Reconcile a handful of requests** Take the `X-Routehook-Request-Id` from a few responses and read `GET /v1/generation?id=req_…`. Compare `usage` and `total_cost` against what you were paying before. 5. **Swap the live key, then revoke the old one** In that order. Revocation on either side takes effect immediately, so doing it the other way round takes the integration down for the length of a deploy. ## Next - [Routing](/docs/routing): Failover, the circuit breaker, and the headers that report them. - [Cost Tracking](/docs/cost-tracking): Balance, holds, and what a single request cost. - [Errors](/docs/errors): The eight codes, and which of them are worth retrying. --- # Migrate from fal.ai > The queue routes, in fal's path shapes, with one deliberate difference. Section: Guides. Canonical page: https://routehook.ai/docs/migrate-fal Submit with `POST /v1/queue/{model}`, poll the status route, fetch the result, cancel with `PUT`, and be called back with `?fal_webhook=`. The paths keep fal's shape and the submit response keeps fal's field names: `request_id`, `status_url`, `response_url`, `cancel_url`. The one deliberate difference is failure: `status` carries fal's three values only, so a job that failed still reads `COMPLETED`, and the additive `outcome` field is what tells the three apart. > **Warning: COMPLETED does not mean succeeded** > > fal publishes `IN_QUEUE`, `IN_PROGRESS` and `COMPLETED` and nothing else, and a client polling until `COMPLETED` is the loop fal's own SDK produces. Inventing a `FAILED` status would make that loop spin for ever, so we did not: a job that failed or was cancelled reports `COMPLETED` too. **Read `outcome` (`succeeded`, `failed` or `canceled`) before you use the output.** Fetching the result of a failed job answers the standard error envelope with the failure's status code, exactly as fal does. ## Route map | FAL | HERE | WHAT CHANGES | | --- | --- | --- | | POST https://queue.fal.run/{model} | POST /v1/queue/{model} | The model's own input is the body, unchanged. Response is fal's submit shape. | | GET https://queue.fal.run/{model}/requests/{id}/status | GET /v1/queue/{model}/requests/{job_id}/status | Same handler as the short form below. ?logs=1 is gated exactly as fal gates it. | | - | GET /v1/queue/requests/{job_id}/status | Short form. The model segment is optional on status and result. | | GET https://queue.fal.run/{model}/requests/{id} | GET /v1/queue/{model}/requests/{job_id} | The result. GET /v1/queue/requests/{job_id} is the same handler. | | PUT https://queue.fal.run/{model}/requests/{id}/cancel | PUT /v1/queue/requests/{job_id}/cancel | Short form only. There is no model-prefixed cancel. Follow cancel_url. | | GET https://queue.fal.run/{model}/requests/{id}/status/stream | GET /v1/queue/requests/{job_id}/stream | SSE of status transitions. Different path, and short form only. | | POST https://fal.run/{model} | POST /v1/run/{model} | Synchronous. Past the wait ceiling it hands back the job to poll rather than failing. | | ?fal_webhook= | ?fal_webhook= | Honoured. webhook_url in the body means the same thing and survives a callback URL with its own query string. | | - | GET /v1/jobs | Ours. fal has no job list; this is where a lost job id is found. | > **Warning: Cancel and stream have no model-prefixed form** > > Status and result answer on both path shapes. Cancel and stream answer only on `/v1/queue/requests/{job_id}/…`, so a client that rebuilds fal's `/{model}/requests/{id}/cancel` by hand will 404. Use the absolute `cancel_url` and `status_url` the submit response hands back. They are absolute for exactly this reason, and following them is the version of this code that never breaks. ## The body is the input, at the top level fal's HTTP API takes a model's own parameters at the top level of the POST body (`prompt`, `image_url`, `duration`, `num_inference_steps`, whatever that model accepts), and so does this one. Unknown keys are forwarded upstream rather than refused. The `fal-client` SDKs wrap that object under an `input` key of their own; porting a client call means taking what was inside `input` and making it the body. ## Fields the gateway reads first `prompt`, `n`, `size` and `duration` are read before the body is forwarded, because the hold taken at submit is a ceiling computed from how many images or how many seconds you asked for. `webhook_url` is ours and is stripped on the way out, so an upstream never receives it. Everything else passes through. ## Authentication fal sends `Authorization: Key :`. Here it is `Authorization: Bearer sk_live_…`, the same key as every other route on the account. There is no separate queue credential and no per-model key. ## Model ids fal ids are namespaced by fal: `fal-ai/veo3`, `fal-ai/flux/dev`. Slugs here come from `GET /v1/models` and are not a mechanical rewrite of them: some line up, some do not. Resolve every id you submit today before cutting over. A slug that does not exist answers `409 model_unavailable` at submit, before any money is held. ## What a submit answers **POST /v1/queue/google/veo-3.1** ```json { "request_id": "job_ab12cd34", "status": "IN_QUEUE", "queue_position": 3, "status_url": "https://api.routehook.ai/v1/queue/requests/job_ab12cd34/status", "response_url": "https://api.routehook.ai/v1/queue/requests/job_ab12cd34", "cancel_url": "https://api.routehook.ai/v1/queue/requests/job_ab12cd34/cancel" } ``` ## What a poll answers **GET /v1/queue/requests/job_ab12cd34/status?logs=1** ```json { "request_id": "job_ab12cd34", "status": "COMPLETED", "outcome": "succeeded", "queue_position": 0, "logs": [ { "message": "sampling frame 120/120", "level": "INFO", "timestamp": "2026-08-23T11:02:47Z" } ], "metrics": { "inference_time": 74210 }, "error": null } ``` ## Before **JavaScript** ```javascript import { fal } from "@fal-ai/client"; fal.config({ credentials: process.env.FAL_KEY }); const result = await fal.subscribe("fal-ai/veo3", { input: { prompt: "drone shot over a harbour at dawn", duration: 5, }, logs: true, }); console.log(result.data.video.url); ``` **Python** ```python import fal_client result = fal_client.subscribe( "fal-ai/veo3", arguments={ "prompt": "drone shot over a harbour at dawn", "duration": 5, }, with_logs=True, ) print(result["video"]["url"]) ``` ## After **JavaScript** ```javascript const AUTH = { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` }; const submit = await fetch("https://api.routehook.ai/v1/queue/google/veo-3.1", { method: "POST", headers: { ...AUTH, "Content-Type": "application/json" }, body: JSON.stringify({ prompt: "drone shot over a harbour at dawn", duration: 5, }), }); const job = await submit.json(); // Three statuses only, so this loop terminates on failure as well. let state; do { await new Promise((resolve) => setTimeout(resolve, 1000)); state = await fetch(job.status_url, { headers: AUTH }).then((r) => r.json()); } while (state.status !== "COMPLETED"); if (state.outcome !== "succeeded") { throw new Error(`${job.request_id} ${state.outcome}`); } const result = await fetch(job.response_url, { headers: AUTH }).then((r) => r.json(), ); console.log(result.output.video.url); ``` **Python** ```python import os, time, requests AUTH = {"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"} job = requests.post( "https://api.routehook.ai/v1/queue/google/veo-3.1", headers=AUTH, json={ "prompt": "drone shot over a harbour at dawn", "duration": 5, }, ).json() # Three statuses only, so this loop terminates on failure as well. while True: state = requests.get(job["status_url"], headers=AUTH).json() if state["status"] == "COMPLETED": break time.sleep(1) if state["outcome"] != "succeeded": raise RuntimeError(f"{job['request_id']} {state['outcome']}") result = requests.get(job["response_url"], headers=AUTH).json() print(result["output"]["video"]["url"]) ``` ## fal-client cannot be repointed Neither SDK takes a base URL. `fal.config()` accepts credentials and a `proxyUrl`, and the proxy has to be a route on your own origin that you write, so keeping the client means writing a forwarder that rewrites the path onto `/v1/queue/{model}`, swaps the `Key` credential for a bearer token, and unwraps `input` into the body. Four `fetch` calls, as above, is less code than the proxy and one fewer hop to debug. ## Webhooks `?fal_webhook=` is honoured and means exactly what it means at fal. `webhook_url` in the body means the same thing and is the better field when your callback URL carries a query string of its own. Nesting one URL inside another's query parameter is the encoding most often got wrong. The URL must be `https` and must not resolve into a private range. **What is POSTed to the callback on a terminal state** ```json { "request_id": "job_ab12cd34", "gateway_request_id": "req_7c41d9be", "status": "COMPLETED", "outcome": "succeeded", "payload": { "video": { "url": "https://cdn.routehook.ai/vid/ab12cd34.mp4" } }, "error": null } ``` Deliveries are signed. `X-Routehook-Webhook-Signature` carries `v1=` over `..`, keyed on the account webhook secret, alongside `X-Routehook-Webhook-Id` and `X-Routehook-Webhook-Timestamp`. Verify before you act on a payload, /docs/webhooks has the check. ## Idempotency Send an `Idempotency-Key` header on a submit and a repeat returns the existing job instead of opening (and billing) a second one. fal has no equivalent, so this is the one place worth adding a line rather than removing one: a retry after a timeout is the case that quietly pays twice. ## What is not here - **No file storage.** There is no `fal.storage.upload` equivalent. Inputs that take a file take a publicly reachable `https` URL you host. - **No realtime transport.** There is no WebSocket surface. `GET /v1/queue/requests/{job_id}/stream` streams status transitions over SSE, not tokens or frames. - **No `fal.stream`.** Partial output is not streamed off the queue; poll or stream status, then fetch the result once. - **No client-side proxy handler.** fal ships route handlers for Next.js and friends. Here the key stays on your server and your server calls the API. See /docs/authentication. ## Porting a call 1. **Move input up a level** Whatever sat under `input` in the client call becomes the request body. Nothing inside it needs renaming. 2. **Resolve the model id** Find the slug in `GET /v1/models` and put it in the path. Slugs contain slashes and the path segment is a wildcard, so `google/veo-3.1` goes in whole: `/v1/queue/google/veo-3.1`. 3. **Swap the credential** `Authorization: Key :` becomes `Authorization: Bearer sk_live_…`. 4. **Follow the URLs you were handed** Poll `status_url`, fetch `response_url`, cancel with `cancel_url`. They are absolute, and using them is what keeps the client working when a path shape changes. 5. **Branch on outcome, not on status** `status === "COMPLETED"` ends the loop. `outcome === "succeeded"` is what says the output is usable. ## Next - [Queue](/docs/queue): The full asynchronous surface, including /v1/run and /v1/jobs. - [Webhooks](/docs/webhooks): The signature, the retry schedule and how to verify a delivery. - [Pricing](/docs/pricing): How a hold becomes a charge, and what a cancel refunds. --- # MCP Server > Reach the catalogue, the gateway and the queue from an MCP client as tools. Section: Guides. Canonical page: https://routehook.ai/docs/mcp `POST`, `GET` and `DELETE` on `/v1/mcp` implement the Model Context Protocol over the Streamable HTTP transport: JSON-RPC 2.0, protocol version `2025-06-18`, authenticated by OAuth 2.1 or the same API key as every other route. Sixteen tools cover the catalogue, live rankings, provider routing, documentation, account data, feedback, the gateway and the queue. ## POST /v1/mcp JSON-RPC 2.0 over Streamable HTTP: initialise a session and call the tools. Status: Available. Authentication: any live API key on the account. > **Warning: An MCP client holding a live key can spend the balance** > > **`send-message`, `generate-image` and `submit_job` charge the account that owns the key**, and a model decides on its own when to call them. Keys carry no per-tool scopes, so there is nothing to withhold at the key level. Issue the client its own key with a spend cap (`GET /v1/key` reports the cap as `limit` and what is left as `limit_remaining`), so the ceiling is set before the model reaches for a tool, and revoke that one key without touching anything else you run. ## OAuth. The default for interactive MCP clients Give a compatible client only `https://api.routehook.ai/v1/mcp`. An unauthenticated request advertises protected-resource metadata, dynamic client registration and an authorization-code flow with mandatory S256 PKCE. The browser signs in to Routehook, access tokens last one hour, and refresh access expires after seven days. OAuth is accepted only by this MCP endpoint; it does not turn into a general-purpose `/v1` API credential. > **Note: OAuth uses the same account balance** > > OAuth changes the login ceremony, not billing. Billable tools reserve and settle against the signed-in account's balance and use its routing and rate limits. If an automation needs an independently revocable secret or a separate operational ceiling, configure it with a dedicated API key instead. ## Claude Code A static-key setup is still supported. This command stores the header with the server, so the key is not repeated per call. **Adding the server** ```bash claude mcp add --transport http routehook https://api.routehook.ai/v1/mcp \ --header "Authorization: Bearer sk_live_..." ``` ## Claude Desktop, and anything reading a config file The same server as JSON. `type: "http"` selects the Streamable HTTP transport. This server has no stdio entry point, so a `command`/`args` block will not reach it. **claude_desktop_config.json** ```json { "mcpServers": { "routehook": { "type": "http", "url": "https://api.routehook.ai/v1/mcp", "headers": { "Authorization": "Bearer sk_live_..." } } } } ``` ## A generic Streamable HTTP client Any client that speaks JSON-RPC 2.0 over HTTP can connect directly. Complete OAuth discovery or send an API key as a bearer token, accept both `application/json` and `text/event-stream`, and start with `initialize`. **The handshake, by hand** ```bash curl -i -X POST https://api.routehook.ai/v1/mcp \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -H "MCP-Protocol-Version: 2025-06-18" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "your-client", "version": "1.0.0" } } }' ``` ## The session 1. **initialize** The reply carries `protocolVersion`, `capabilities` and `serverInfo`, and an `Mcp-Session-Id` **response header**. `-i` above is there so you can see it. 2. **notifications/initialized** A notification, so no `id` and no result: it answers `202` with an empty body. Skipping it leaves a client that some servers will refuse to serve. 3. **Send Mcp-Session-Id on everything after that** Every later `POST`, the `GET` stream and the `DELETE` all carry the session header. 4. **Discover** `tools/list`, `resources/list`, `resources/templates/list` and `prompts/list`. `ping` is answered too, for clients that keepalive. 5. **Call, and close** `tools/call` runs a tool. `GET /v1/mcp` opens the stream for server-initiated messages; `DELETE /v1/mcp` ends the session and drops it. Ending a session does nothing to the API key. ## Read-only tools | TOOL | ARGUMENTS | RETURNS | | --- | --- | --- | | list-models | category, vendor, search, limit (default 50, max 200) | Models with published price, category and context length. | | get-model | model (required) | One model: price on every metered axis, context length, and the parameters it honours. | | list-model-endpoints | model (required) | Eligible providers with published pricing, context limits, supported parameters, latency and health. | | list-providers | none | Connected providers with customer-safe aggregate health and model counts. | | list-daily-model-rankings | days (1-30), limit (1-50), category | Per-day rankings by live token volume, including token share. | | search-docs | query (required), limit (1-10) | Relevant Routehook documentation pages and summaries. | | get_job | job_id (required), logs | request_id, status, outcome, queue_position, output and error. Read outcome. A failed job still reports COMPLETED. | | get-credits | none | balance, held, available, credit_limit, total_credits, total_usage, currency. | | get_usage | range (24h, 7d, 30d, 90d), model, limit | Spend by model: request counts, tokens, what was charged, and the same traffic at the vendors' list prices. | | get-generation | id (required) | One request: tokens, latency, amount charged, provider attribution and vendor list-price comparison. | | ping | none | Connection health status. | ## Tools that change something | TOOL | ARGUMENTS | RETURNS | BILLING | | --- | --- | --- | --- | | send-message | model (required), messages or prompt, max_tokens, temperature | text, model, finish_reason, cost, usage, request_id. | Charges the account. | | generate-image | model, prompt (both required), n (1-10), size | images[].url, cost, request_id. | Charges once per image. N multiplies the bill. | | submit_job | model, input (both required), webhook_url, idempotency_key | The job id and its queue position. Poll it with get_job. | Reserves credit at submit, charges on completion. | | cancel_job | job_id (required) | The job's terminal state. | Releases unspent credit; work already done is still charged. | | send-feedback | generation_id, category (both required), comment | A support reference after verifying the generation belongs to this account. | Free; writes a support record. | ## How a tool answers Every result is `{ content: [{ type: "text", text: }] }`, with `isError: true` on a failure. `content[0].text` is a JSON document **as a string**. A client that hands it straight to a model is doing the right thing, but code reading it has to parse it. Where a tool declares an output schema (`send-message`, `generate-image`, `get_job` and `get-credits`) the same object arrives parsed alongside as `structuredContent`, and that is the one to read from code. ## A worked example An agent asked to summarise something cheaply has no business guessing a model slug. It lists first, then spends, which is why the read-only tools say so in their own descriptions: a model with a budget will call `list-models` three times to get `send-message` right once. **1: tools/call, list-models** ```json { "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "list-models", "arguments": { "category": "text", "search": "mini", "limit": 1 } } } ``` **2: the answer, with the payload as a string** ```json { "jsonrpc": "2.0", "id": 2, "result": { "content": [ { "type": "text", "text": "{\"data\":[{\"id\":\"openai/gpt-5.4-mini\",\"category\":\"text\",\"unit\":\"per 1M tokens\",\"price\":0.15,\"context_length\":128000}]}" } ] } } ``` **3: tools/call, send-message. This one charges the account.** ```json { "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "send-message", "arguments": { "model": "openai/gpt-5.4-mini", "prompt": "Summarise this changelog in two sentences.", "max_tokens": 200 } } } ``` **4: the answer, parsed as well as rendered** ```json { "jsonrpc": "2.0", "id": 3, "result": { "content": [{ "type": "text", "text": "Two sentences of summary." }], "structuredContent": { "text": "Two sentences of summary.", "model": "openai/gpt-5.4-mini", "finish_reason": "stop", "cost": "0.000103", "usage": { "prompt_tokens": 412, "completion_tokens": 58, "total_tokens": 470 }, "request_id": "req_7c41d9be" } } } ``` `cost` is a decimal string and `request_id` is the same id `get-generation` takes, so an agent can report what it just spent without being told. ## Resources - `routehook://models`: the catalogue, as `GET /v1/models` serves it. - `routehook://credits`: the calling account's balance, held amount and headroom. - `routehook://models/{slug}`: one model. A **template**, served by `resources/templates/list` rather than `resources/list`, so read it with a slug substituted in and never with the literal braces. ## Prompt One prompt, `pick_model`. It takes `task` (required. What the model has to do, in plain words) and `budget` (optional), and returns a message listing candidate models with their prices. It exists beside `list-models` because a person wants one command that produces a shortlist, where a model is happy to list and reason for itself. ## Protocol details worth knowing - **Version negotiation.** State `2025-06-18`. `2025-03-26` is still accepted for clients pinned there. A version we do not speak is answered with ours rather than refused. The spec makes continuing the client's decision, and refusing at the transport turns a soft downgrade into a connection nobody can debug. - **Two error channels.** An unknown method is JSON-RPC `-32601` inside a `200`. Transport failures stay HTTP: an absent, expired or revoked credential is a real `401`, carrying a `WWW-Authenticate` header that points to OAuth protected-resource metadata. - **Streaming.** A `POST` answers `application/json` for a single response, or `text/event-stream` when the client accepts it and the call streams. The `GET` stream carries server-initiated messages only. Tool calls always go over `POST`. - **No SDK behind it.** The server is hand-rolled JSON-RPC framing rather than a vendored MCP library, so the wire behaviour is exactly what is documented here. ## Next - [Authentication](/docs/authentication): How keys authenticate, and how to cap what one can spend. - [Cost Tracking](/docs/cost-tracking): What an agent just spent, per request and per window. - [Queue](/docs/queue): Where submit_job, get_job and cancel_job actually land. --- # SDKs > There is no first-party SDK. The API answers in shapes other people's clients already speak. Section: Guides. Canonical page: https://routehook.ai/docs/sdks A wrapper around a JSON API this small would be more surface than it saves, so there is none. Instead the routes answer in the shapes the widely-used clients already send: an OpenAI client for chat, completions, embeddings and images, an Anthropic client for messages, and plain HTTP for the queue. > **Warning: The base URL differs by client** > > An OpenAI client appends the route to whatever base URL you give it, so it needs https://api.routehook.ai/v1. An Anthropic client appends /v1/messages itself, so it needs https://api.routehook.ai. Pass it the version prefix as well and the request goes to /v1/v1/messages and 404s. ## OpenAI clients **cURL** ```bash curl https://api.routehook.ai/v1/chat/completions \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "openai/gpt-5.4-mini", "messages": [ { "role": "user", "content": "Hello" } ] }' ``` **JavaScript** ```javascript import OpenAI from "openai"; const client = new OpenAI({ baseURL: "https://api.routehook.ai/v1", apiKey: process.env.ROUTEHOOK_API_KEY, }); const completion = await client.chat.completions.create({ model: "openai/gpt-5.4-mini", messages: [{ role: "user", content: "Hello" }], }); console.log(completion.choices[0].message.content); ``` **Python** ```python import os from openai import OpenAI client = OpenAI( base_url="https://api.routehook.ai/v1", api_key=os.environ["ROUTEHOOK_API_KEY"], ) completion = client.chat.completions.create( model="openai/gpt-5.4-mini", messages=[{"role": "user", "content": "Hello"}], ) print(completion.choices[0].message.content) ``` ## What an OpenAI client can reach chat.completions, completions, embeddings and images.generate all map onto routes that exist here. Anything else in that SDK (assistants, files, fine-tuning, the batch API) has no route behind it and will 404. The model field takes a slug from GET /v1/models, not an OpenAI model name. ## Anthropic clients **JavaScript** ```javascript import Anthropic from "@anthropic-ai/sdk"; const client = new Anthropic({ baseURL: "https://api.routehook.ai", apiKey: process.env.ROUTEHOOK_API_KEY, }); const message = await client.messages.create({ model: "anthropic/claude-fable-5", max_tokens: 512, messages: [{ role: "user", content: "Hello" }], }); console.log(message.content[0].text); ``` **Python** ```python import os from anthropic import Anthropic client = Anthropic( base_url="https://api.routehook.ai", api_key=os.environ["ROUTEHOOK_API_KEY"], ) message = client.messages.create( model="anthropic/claude-fable-5", max_tokens=512, messages=[{"role": "user", "content": "Hello"}], ) print(message.content[0].text) ``` ## The queue The queue is four routes and no library: submit, poll, fetch, cancel. The submit call below is the whole of the integration a video model needs. See the queue page for the polling loop and the status vocabulary. ## Submitting a job **cURL** ```bash curl -X POST https://api.routehook.ai/v1/queue/google/veo-3.1 \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "prompt": "drone shot over a harbour at dawn", "duration": 5 }' ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/queue/google/veo-3.1", { method: "POST", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ prompt: "drone shot over a harbour at dawn", duration: 5, }), }); const job = await res.json(); console.log(job.request_id, job.status); ``` **Python** ```python import os, requests res = requests.post( "https://api.routehook.ai/v1/queue/google/veo-3.1", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, json={ "prompt": "drone shot over a harbour at dawn", "duration": 5, }, ) job = res.json() print(job["request_id"], job["status"]) ``` ## Migrating an existing integration - [From OpenRouter](/docs/migrate-openrouter): The same shapes, under the same field names. - [From fal.ai](/docs/migrate-fal): The queue, in fal's path shapes, with one difference. - [MCP](/docs/mcp): Reach the API from an agent runtime as tools. --- # Webhooks > Be called back when a job finishes, and verify that the call came from us. Section: Guides. Canonical page: https://routehook.ai/docs/webhooks A job can call you instead of being polled. Give a submit a URL and the terminal state is POSTed to it, for failures and cancellations as well as successes, which is the point of having one: a receiver told only about successes would still have to poll to learn about everything else. Deliveries are signed, retried and at-least-once, and all three of those change how the handler has to be written. ## Asking for a callback **webhook_url** ```bash curl -X POST https://api.routehook.ai/v1/queue/google/veo-3.1 \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "prompt": "drone shot over a harbour at dawn", "duration": 5, "webhook_url": "https://your.app/hooks/routehook" }' ``` **fal_webhook** ```bash # fal's query parameter, accepted and meaning exactly the same thing. # The value is a URL inside a URL, so it has to be percent-encoded. This is # the single most common reason a callback never arrives. curl -X POST "https://api.routehook.ai/v1/queue/google/veo-3.1?fal_webhook=https%3A%2F%2Fyour.app%2Fhooks%2Froutehook" \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "prompt": "drone shot over a harbour at dawn", "duration": 5 }' ``` ## The payload One POST, application/json, sent when the job reaches a terminal state. status is always COMPLETED (a delivery only happens on a terminal state), so outcome is the field that carries the news. payload is the upstream's result body, exactly as the result route would have returned it, and is null unless the outcome is succeeded; error is filled in when it is not. gateway_request_id is the req_… usage-log row, so a receiver can reconcile the charge without a second call. **POST https://your.app/hooks/routehook** ```json { "request_id": "job_ab12cd34", "gateway_request_id": "req_7c41d9be", "status": "COMPLETED", "outcome": "succeeded", "payload": { "video": { "url": "https://cdn.routehook.ai/vid/ab12cd34.mp4" } }, "error": null } ``` ## The three headers | HEADER | VALUE | | --- | --- | | X-Routehook-Webhook-Id | Identifies this delivery. Stable across every retry of it. This is what you de-duplicate on. | | X-Routehook-Webhook-Timestamp | Unix seconds at the moment this attempt was sent. A retry carries the time it was retried. | | X-Routehook-Webhook-Signature | v1=, the HMAC-SHA256 of .. keyed on your account's webhook secret. The v1= prefix exists so the digest can be changed later without inventing a fourth header. | ## What is signed, and why not the body alone A signature over the body alone is valid for ever and is byte-identical every time that body is sent. Anyone who captures a single delivery (a proxy log, a mis-configured receiver, an intermediary) can replay it whenever they like, and your HMAC still verifies, because nothing inside what was signed says when it was sent or which delivery it was. Binding the id and the timestamp into the signed string closes both halves of that: you reject a delivery whose timestamp is outside your tolerance, and you ignore an id you have already processed. Because both values sit inside the signature, neither can be rewritten in flight to get past those checks. > **Warning: Hash the bytes you received** > > The HMAC covers the exact body we sent. A body that has been through a JSON parser and printed again is a different string (one space of indentation, one reordered key), and will not verify. Read the raw body first, check the signature, and parse afterwards. In Express that means express.raw() on the route rather than the global express.json(); in Flask it means request.get_data() rather than request.json. ## Verifying a delivery **Node** ```javascript import crypto from "node:crypto"; import express from "express"; const SECRET = process.env.ROUTEHOOK_WEBHOOK_SECRET; const TOLERANCE_SECONDS = 300; const app = express(); // express.raw() on this route only. The signature is over the bytes we // sent, and the global JSON parser would have thrown them away. app.post("/hooks/routehook", express.raw({ type: "*/*" }), (req, res) => { const id = req.get("X-Routehook-Webhook-Id"); const timestamp = req.get("X-Routehook-Webhook-Timestamp"); const signature = req.get("X-Routehook-Webhook-Signature"); if (!id || !timestamp || !signature || !signature.startsWith("v1=")) { return res.status(400).end(); } // Reject anything too old to be live. A replayed capture fails here. const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)); if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) { return res.status(400).end(); } const body = req.body.toString("utf8"); const expected = crypto .createHmac("sha256", SECRET) .update(`${id}.${timestamp}.${body}`) .digest("hex"); // Constant time, and length-checked first: timingSafeEqual throws on // buffers of different lengths instead of returning false. const mine = Buffer.from(expected, "utf8"); const theirs = Buffer.from(signature.slice(3), "utf8"); if (mine.length !== theirs.length || !crypto.timingSafeEqual(mine, theirs)) { return res.status(401).end(); } const event = JSON.parse(body); // At-least-once: seen it before, do nothing. Both functions are yours. if (!alreadySeen(id)) { recordAndEnqueue(id, event); } // Answer now; do the work off the request. res.status(204).end(); }); ``` **Python** ```python import hashlib import hmac import json import os import time from flask import Flask, request SECRET = os.environ["ROUTEHOOK_WEBHOOK_SECRET"].encode() TOLERANCE_SECONDS = 300 app = Flask(__name__) @app.post("/hooks/routehook") def routehook(): hook_id = request.headers.get("X-Routehook-Webhook-Id", "") timestamp = request.headers.get("X-Routehook-Webhook-Timestamp", "") signature = request.headers.get("X-Routehook-Webhook-Signature", "") if not hook_id or not signature.startswith("v1="): return "", 400 # Reject anything too old to be live. A replayed capture fails here. try: age = abs(int(time.time()) - int(timestamp)) except ValueError: return "", 400 if age > TOLERANCE_SECONDS: return "", 400 # get_data() is the raw body. request.json would re-serialise it and the # digest would never match. body = request.get_data() base = f"{hook_id}.{timestamp}.".encode() + body expected = hmac.new(SECRET, base, hashlib.sha256).hexdigest() # compare_digest is the constant-time comparison. A plain == leaks the # position of the first differing byte to anyone who can time the reply. if not hmac.compare_digest(expected, signature[3:]): return "", 401 event = json.loads(body) # At-least-once: seen it before, do nothing. Both functions are yours. if not already_seen(hook_id): record_and_enqueue(hook_id, event) # Answer now; do the work off the request. return "", 204 ``` ## Choosing a tolerance The timestamp is unix seconds at the moment that attempt was sent, and every retry is signed afresh with the time it went out, so your tolerance has to cover the difference between our clock and yours, not the length of the backoff. Five minutes is the usual choice and is what the samples above use. Too tight and a host whose clock has drifted a minute rejects live deliveries; too loose and a captured delivery stays replayable for as long as you allow. > **Warning: A webhook is at-least-once** > > A delivery is retried whenever our read of your response fails, and our read failing is not the same as your handler failing: a response lost on the way back means you have already done the work and will be asked to do it again. Make the handler idempotent. Key on X-Routehook-Webhook-Id, which is stable across every attempt at one delivery, store it before you act on it, and make a second arrival a no-op. request_id works as a key too (one job reaches one terminal state), and is the better choice if you also want to ignore a delivery you have already learned about by polling. ## Retries and backoff The first attempt is made as soon as the job reaches a terminal state. Anything other than a 2xx, or no answer at all, is a failed attempt and is retried with exponential backoff up to a cap the operator sets, five retries by default, and zero is permitted, which makes delivery a single shot. Every attempt is recorded against the job, so a delivery that never landed is visible rather than silent. Answer quickly: a handler that renders a thumbnail before replying is indistinguishable from a broken one, and it will be retried while it is still working. > **Warning: https, and nothing private** > > A webhook_url must be https. A callback carrying a result and a signature over plaintext is a result and a signature in public. It must also not resolve into a private range (127.0.0.1, 10.0.0.0/8, 169.254.169.254 and the rest) because our egress reaching an internal address on a customer's instruction is the shape of every SSRF report ever filed. The scheme is checked when the job is submitted; the address is resolved and checked again at delivery time, because DNS can change in between. ## A receiver worth deploying - Reads the raw body, verifies, then parses. Never the other way round. - Compares digests in constant time. TimingSafeEqual, or hmac.compare_digest. - Rejects a timestamp outside its tolerance, and says 400 rather than 200. - De-duplicates on the delivery id before doing any work. - Answers within a second or two and does the work elsewhere. - Treats a missing callback as possible: poll GET /v1/jobs for anything that has gone quiet, rather than assuming a job that never called back never finished. ## The secret is not the API key Signatures are keyed on the account's webhook secret, which is a separate credential from the keys that spend money. A receiver needs to verify deliveries; it has no reason to hold something that can bill you, and a leak on the receiving end should not be a leak of your gateway access. Keep it in the environment beside the API key and rotate it on the same suspicion you would rotate that one. ## Related - [Queue](/docs/queue): Where a webhook comes from: submit, poll, fetch, cancel. - [Errors](/docs/errors): The codes a failed delivery carries in its error object. - [Cost Tracking](/docs/cost-tracking): Reconciling gateway_request_id against what you were charged. --- # Cost Tracking > What the balance is, what one request cost, and what the same call costs elsewhere. Section: Guides. Canonical page: https://routehook.ai/docs/cost-tracking Four places report what you spent, in the order they become available: usage.cost on the response body, the X-Routehook-Cost header beside it, GET /v1/generation for one request after the fact, and GET /v1/credits for the account. They agree, because they are one ledger read from four ends. ## usage.cost, on the response you already have Chat responses carry `usage.cost` as an OpenRouter-compatible JSON number and `usage.cost_decimal` as the exact decimal string. A streamed response puts the same fields in the final usage frame only when `stream_options.include_usage` is true; otherwise look the request up afterwards. ## Headers on every gateway response | HEADER | VALUE | | --- | --- | | X-Routehook-Request-Id | req_…. The id to look the request up by, and the id to quote. | | X-Routehook-Cost | USD charged, as a decimal string. | | X-Routehook-Attempts | How many targets were tried. | | X-Routehook-Fallback | true when the target that answered was not the first one. | | X-Routehook-Upstream-Latency-Ms | How long the upstream leg took, excluding our own overhead. | A streamed response has no cost header. Headers go out before the first token and the charge is not known until after the last one, so a stream's total arrives in the final usage frame or from GET /v1/generation. The request id is set before the request is routed, which is why it is on every response including the failures. ## GET /v1/generation Read what one request cost and how long it took. Status: Available. Authentication: any live API key on the account. GET /v1/generation?id=req_… is the record of one request: usage is what you were charged, attempts and fallback_used say whether the first attempt worked, generation_time and latency are milliseconds, and status with error_code covers the requests that failed. It is scoped to the calling account, another account's id is a 404, not a 403, because a 403 would confirm the id exists. ## One call, then its cost **cURL** ```bash # 1: make the call, keeping the response headers. curl -sS https://api.routehook.ai/v1/chat/completions \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -D headers.txt \ -d '{ "model": "openai/gpt-5.4-mini", "messages": [{ "role": "user", "content": "Hello" }] }' | jq '.usage' # 2: the id it was recorded under. REQUEST_ID=$(awk 'tolower($1) == "x-routehook-request-id:" { print $2 }' \ headers.txt | tr -d '\r') # 3: the same request, read back. curl -sS "https://api.routehook.ai/v1/generation?id=$REQUEST_ID" \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ | jq '.data | {usage, total_cost, attempts, fallback_used}' ``` **JavaScript** ```javascript const auth = { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` }; const res = await fetch("https://api.routehook.ai/v1/chat/completions", { method: "POST", headers: { ...auth, "Content-Type": "application/json" }, body: JSON.stringify({ model: "openai/gpt-5.4-mini", messages: [{ role: "user", content: "Hello" }], }), }); const completion = await res.json(); const requestId = res.headers.get("x-routehook-request-id"); // Charged, in the response you already hold. console.log(completion.usage.cost, res.headers.get("x-routehook-cost")); // The same request, read back from the log. const record = await fetch( `https://api.routehook.ai/v1/generation?id=${requestId}`, { headers: auth }, ).then((r) => r.json()); console.log(record.data.usage, record.data.total_cost); ``` **Python** ```python import os, requests auth = {"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"} res = requests.post( "https://api.routehook.ai/v1/chat/completions", headers=auth, json={ "model": "openai/gpt-5.4-mini", "messages": [{"role": "user", "content": "Hello"}], }, ) completion = res.json() request_id = res.headers["X-Routehook-Request-Id"] # Charged, in the response you already hold. print(completion["usage"]["cost"], res.headers.get("X-Routehook-Cost")) # The same request, read back from the log. record = requests.get( "https://api.routehook.ai/v1/generation", headers=auth, params={"id": request_id}, ).json() print(record["data"]["usage"], record["data"]["total_cost"]) ``` > **Note: The record lands as the response leaves** > > The usage row is written as the last byte goes out, so a lookup fired in the same instant can miss it. Retry once before treating a 404 as real. The id itself is valid from the moment you receive it. ## GET /v1/credits Read the account balance, the amount held by requests in flight and what is left to spend. Status: Available. Authentication: any live API key on the account. GET /v1/credits describes the account, not one key. total_credits is lifetime deposits and total_usage lifetime spend, both OpenRouter's names; balance is what is settled, held is what requests in flight have reserved, and available is balance minus held plus any credit limit. available is the figure to check before starting a batch. Balance on its own will happily tell you that you can afford work you have already committed to. These are numbers rounded to two places, not strings: per-token rates are strings, balances are not. ## Why held exists Money is reserved before it is spent and settled at the real cost afterwards. A request holds the most it could possibly cost, the actual charge replaces that hold when the response is delivered, and the hold is released if the request fails. So held is non-zero while work is in flight, a 5xx costs nothing, and a queue job's money is committed at submit rather than at completion, which is the answer to why the balance moved before a video existed. ## GET /v1/key Describe the key making the call: its label, spend cap, lifetime usage and rate limit. Status: Available. Authentication: any live API key on the account. GET /v1/key describes the key that made the call: usage is lifetime USD spent on that key, limit and limit_remaining are its own spend cap where one is set, and rate_limit is the requests-per-interval it is subject to. Spend is attributed per key, so one key per project turns the account balance into a per-project bill with no tagging of your own. ## A workable setup - One key per project or environment, so usage on GET /v1/key is already the breakdown. - Log X-Routehook-Request-Id on every call, successful or not. - Log X-Routehook-Cost beside it, and X-Routehook-Fallback to catch the days something behind a model is struggling. - Reconcile against GET /v1/generation when a number looks wrong, rather than re-adding your own logs. - Poll GET /v1/credits on a schedule, and alert on available rather than balance. ## Quote the request id Every response carries the request id on X-Routehook-Request-Id (the same header on every route, images included), and every error body repeats it inside the error object. A support report carrying that id is a lookup. The attempts, the underlying error and the charge are all on one row. Without it, the same report is an investigation. ## Related - [Pricing](/docs/pricing): The rate table, and the hold-settle-release cycle in full. - [Routing](/docs/routing): Why a fallback shows up in what you paid. - [Support](/docs/support): What to send with a request id when a number looks wrong. --- # API Reference > Every route on the platform, with its parameters, a runnable request and what comes back. Section: Resources. Canonical page: https://routehook.ai/docs/api-reference Every route, in one page. The endpoint headers elsewhere in these docs and the blocks below read from the same definition, so a route cannot be described as live on one page and planned on another. The cURL, JavaScript and Python snippets are generated from the sample body shown beneath each route. They are the request, not a paraphrase of it. ## Conventions | ASPECT | VALUE | | --- | --- | | Base URL | https://api.routehook.ai/v1 | | Authentication | Authorization: Bearer | | Anthropic route | x-api-key is accepted on POST /v1/messages | | Request encoding | application/json | | Response encoding | application/json, or text/event-stream when streaming | | Idempotency | Idempotency-Key on a queue submit returns the existing job | | Errors | { error: { code, message, request_id } } | ## GET /v1/key Describe the key making the call: its label, spend cap, lifetime usage and rate limit. Status: Available. Authentication: any live API key on the account. > The route describes the key that called it. There is no way to read another key with this one. `limit_reset`, `is_management_key` and `byok_usage` are published as explicit constants rather than omitted. A client reading them gets a definite answer instead of a missing key it cannot tell from a typo. `key_id` is the key's own id, unprefixed; it is the id the dashboard shows and the one to quote in a support report. ### Request **cURL** ```bash curl https://api.routehook.ai/v1/key \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/key", { method: "GET", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` }, }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.request( "GET", "https://api.routehook.ai/v1/key", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, ) data = res.json() ``` ### Response ```json { "data": { "label": "Production", "limit": null, "usage": 128.4419, "limit_remaining": null, "is_provisioning_key": false, "is_free_tier": false, "rate_limit": { "requests": 600, "interval": "60s" }, "limit_reset": null, "is_management_key": false, "byok_usage": 0, "key_id": "3xK9mQ2p7vL4nR8t", "account_id": "acct_9f2c41be", "created_at": "2026-05-01T09:14:22Z", "last_used_at": "2026-08-23T11:02:47Z", "expires_at": null } } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 401 | `invalid_api_key` | Key missing, malformed or revoked. | | 404 | `model_unavailable` | No such id, or it belongs to another account. Note the code. This route does not answer not_found. | | 429 | `rate_limited` | Back off using the Retry-After header. | ## GET /v1/credits Read the account balance, the amount held by requests in flight and what is left to spend. Status: Available. Authentication: any live API key on the account. > Figures here are JSON numbers, rounded to two decimal places. Per-token *rates* elsewhere are strings. See /docs/pricing for why. ### Request **cURL** ```bash curl https://api.routehook.ai/v1/credits \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/credits", { method: "GET", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` }, }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.request( "GET", "https://api.routehook.ai/v1/credits", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, ) data = res.json() ``` ### Response ```json { "data": { "total_credits": 250.0, "total_usage": 128.44, "balance": 121.56, "held": 0.42, "available": 121.14, "credit_limit": 0, "currency": "USD" } } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 401 | `invalid_api_key` | Key missing, malformed or revoked. | | 404 | `model_unavailable` | No such id, or it belongs to another account. Note the code. This route does not answer not_found. | | 429 | `rate_limited` | Back off using the Retry-After header. | ## GET /v1/generation Read what one request cost and how long it took. Status: Available. Authentication: any live API key on the account. > `total_cost` is what this call was charged to your balance. It is the only cost figure this route carries; what the platform pays upstream is never published. ### Parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `id` | string | Required | — | The request id returned in the X-Routehook-Request-Id header, or on an error body. | ### Request **cURL** ```bash curl https://api.routehook.ai/v1/generation?id=req_7c41d9be \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/generation?id=req_7c41d9be", { method: "GET", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` }, }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.request( "GET", "https://api.routehook.ai/v1/generation?id=req_7c41d9be", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, ) data = res.json() ``` ### Response ```json { "data": { "id": "req_7c41d9be", "model": "openai/gpt-5.4-mini", "streamed": false, "generation_time": 812, "created_at": "2026-08-23T11:02:47Z", "tokens_prompt": 9, "tokens_completion": 8, "native_tokens_prompt": 9, "native_tokens_completion": 8, "num_media_prompt": 0, "num_media_completion": 0, "origin": "api", "usage": 0.000103, "total_cost": 0.000103, "cache_discount": 0, "finish_reason": "stop", "latency": 812, "moderation_latency": 0, "request_id": "req_7c41d9be", "status": "succeeded", "error_code": null, "attempts": 1, "fallback_used": false } } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 401 | `invalid_api_key` | Key missing, malformed or revoked. | | 404 | `model_unavailable` | No such id, or it belongs to another account. Note the code. This route does not answer not_found. | | 429 | `rate_limited` | Back off using the Retry-After header. | ## POST /v1/chat/completions Chat completions in OpenAI's shape, streamed or whole, with automatic failover. Status: Available. Authentication: any live API key on the account. > Unknown request fields are forwarded upstream rather than refused, so a vendor parameter this gateway has never heard of still works. ### Parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `model` | string | Required | — | Model slug, as returned by GET /v1/models. | | `messages` | array | Required | — | Ordered turns, each with a role of system, user or assistant, and content. | | `stream` | boolean | Optional | false | Emit server-sent events instead of one body. See /docs/streaming. | | `max_tokens` | integer | Optional | model default | Ceiling on generated tokens. Caps the output side of the bill. | | `temperature` | number | Optional | model default | Sampling randomness. Forwarded upstream unchanged. | ### Request **cURL** ```bash curl -X POST https://api.routehook.ai/v1/chat/completions \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "openai/gpt-5.4-mini", "messages": [ { "role": "user", "content": "Hello" } ] }' ``` **JavaScript** ```javascript 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({ "model": "openai/gpt-5.4-mini", "messages": [ { "role": "user", "content": "Hello" } ] }), }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.post( "https://api.routehook.ai/v1/chat/completions", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, json={ "model": "openai/gpt-5.4-mini", "messages": [ { "role": "user", "content": "Hello" } ] }, ) data = res.json() ``` ### Response ```json { "id": "chatcmpl_5f81c0", "object": "chat.completion", "created": 1786312455, "model": "openai/gpt-5.4-mini", "choices": [ { "index": 0, "message": { "role": "assistant", "content": "Hello. How can I help?" }, "finish_reason": "stop" } ], "usage": { "prompt_tokens": 9, "completion_tokens": 8, "total_tokens": 17, "cost": "0.000103" } } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 400 | `invalid_request` | Malformed body, or a parameter outside its bounds. | | 401 | `invalid_api_key` | Key missing, malformed or revoked. | | 402 | `insufficient_credits` | Top up the balance before retrying. | | 429 | `rate_limited` | Back off using the Retry-After header. | | 503 | `upstream_unavailable` | Upstream outage. Safe to retry. | | 409 | `model_unavailable` | Model is listed but has no live endpoint to route to. | ## POST /v1/completions OpenAI's legacy prompt completion, translated onto the same routing as chat. Status: Available. Authentication: any live API key on the account. > Almost nothing upstream still serves a true completion endpoint. The prompt is wrapped as one user message on the way out and the message content unwrapped back to `text` on the way in. ### Parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `model` | string | Required | — | Model slug, as returned by GET /v1/models. | | `prompt` | string \| string[] | Required | — | The prompt, or a batch of them. A batch answers with one choice per prompt, in order. | | `max_tokens` | integer | Optional | model default | Ceiling on generated tokens. | | `stream` | boolean | Optional | false | Emit server-sent events instead of one body. | | `temperature` | number | Optional | model default | Sampling randomness. Forwarded upstream unchanged. | ### Request **cURL** ```bash curl -X POST https://api.routehook.ai/v1/completions \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "openai/gpt-5.4-mini", "prompt": "Write one sentence about harbour fog.", "max_tokens": 64 }' ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/completions", { method: "POST", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ "model": "openai/gpt-5.4-mini", "prompt": "Write one sentence about harbour fog.", "max_tokens": 64 }), }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.post( "https://api.routehook.ai/v1/completions", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, json={ "model": "openai/gpt-5.4-mini", "prompt": "Write one sentence about harbour fog.", "max_tokens": 64 }, ) data = res.json() ``` ### Response ```json { "id": "cmpl_5f81c0", "object": "text_completion", "created": 1786312455, "model": "openai/gpt-5.4-mini", "choices": [ { "index": 0, "text": "Fog rolled off the water and swallowed the cranes one by one.", "finish_reason": "stop" } ], "usage": { "prompt_tokens": 8, "completion_tokens": 14, "total_tokens": 22, "cost": "0.0000038" } } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 400 | `invalid_request` | Malformed body, or a parameter outside its bounds. | | 401 | `invalid_api_key` | Key missing, malformed or revoked. | | 402 | `insufficient_credits` | Top up the balance before retrying. | | 429 | `rate_limited` | Back off using the Retry-After header. | | 503 | `upstream_unavailable` | Upstream outage. Safe to retry. | | 409 | `model_unavailable` | Model is listed but has no live endpoint to route to. | ## POST /v1/messages Anthropic's Messages API, so an Anthropic client works against this base URL unchanged. Status: Available. Authentication: any live API key on the account. > The key may arrive as `x-api-key` as well as a bearer token, so an Anthropic client needs no header rewriting. `model` on the way back is the gateway slug, never the upstream's. ### Parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `model` | string | Required | — | Model slug, as returned by GET /v1/models. | | `messages` | array | Required | — | Anthropic's turns. Role user or assistant, and content. | | `max_tokens` | integer | Required | — | Required by the Messages API, unlike chat completions. There is no default to fall back on. | | `system` | string | Optional | — | System prompt, sent apart from the turns. | | `stream` | boolean | Optional | false | Emit Anthropic's event framing: message_start through message_stop. | | `stop_sequences` | string[] | Optional | — | Strings that end generation when produced. | | `tools` | array | Optional | — | Tool definitions, in Anthropic's schema. | ### Request **cURL** ```bash curl -X POST https://api.routehook.ai/v1/messages \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "anthropic/claude-fable-5", "max_tokens": 256, "messages": [ { "role": "user", "content": "Hello" } ] }' ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/messages", { method: "POST", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ "model": "anthropic/claude-fable-5", "max_tokens": 256, "messages": [ { "role": "user", "content": "Hello" } ] }), }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.post( "https://api.routehook.ai/v1/messages", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, json={ "model": "anthropic/claude-fable-5", "max_tokens": 256, "messages": [ { "role": "user", "content": "Hello" } ] }, ) data = res.json() ``` ### Response ```json { "id": "msg_5f81c0", "type": "message", "role": "assistant", "model": "anthropic/claude-fable-5", "content": [{ "type": "text", "text": "Hello. How can I help?" }], "stop_reason": "end_turn", "stop_sequence": null, "usage": { "input_tokens": 9, "output_tokens": 8 } } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 400 | `invalid_request` | Malformed body, or a parameter outside its bounds. | | 401 | `invalid_api_key` | Key missing, malformed or revoked. | | 402 | `insufficient_credits` | Top up the balance before retrying. | | 429 | `rate_limited` | Back off using the Retry-After header. | | 503 | `upstream_unavailable` | Upstream outage. Safe to retry. | | 409 | `model_unavailable` | Model is listed but has no live endpoint to route to. | ## POST /v1/images/generations Generate images and return their URLs. The connection is held until the images exist. Status: Available. Authentication: any live API key on the account. > The connection is held for the 20–60 seconds a generation takes. Video cannot work this way. Use the queue. Batching with `n` is a latency optimisation, not a discount. ### Parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `model` | string | Required | — | Model slug, as returned by GET /v1/models. | | `prompt` | string | Required | — | What to generate. Up to 32,000 characters. | | `n` | integer | Optional | 1 | Images per request, 1 to 10. Each bills at the unit rate. | | `size` | string | Optional | model default | Dimensions (1024x1024), a ratio (16:9) or a tier (1K). Translated to whatever the model expects, and only the half it states: a ratio sets the shape, a tier sets the magnitude, dimensions set both. | | `aspect_ratio` | string | Optional | model default | The shape, named directly: 16:9, 9:16, 1:1. Wins over anything derived from size. Snapped to the nearest ratio a model accepts rather than refused. | | `resolution` | string | Optional | model default | The magnitude, named directly: 1K, 2K, 4K, 1024x1024. Wins over size. This is the field media rates are keyed on, so naming it names the rate you are billed at. | | `quality` | string | Optional | — | Provider hint, same vocabulary as chat. Priced alongside resolution on models that sell both, such as GPT Image 2. | | `reference_urls` | string[] | Optional | — | Publicly reachable input images, for models that take references. Ignored by models that do not. | ### Request **cURL** ```bash curl -X POST https://api.routehook.ai/v1/images/generations \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "google/nano-banana-2", "prompt": "isometric factory floor, technical illustration", "size": "1024x1024", "n": 2 }' ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/images/generations", { method: "POST", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ "model": "google/nano-banana-2", "prompt": "isometric factory floor, technical illustration", "size": "1024x1024", "n": 2 }), }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.post( "https://api.routehook.ai/v1/images/generations", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, json={ "model": "google/nano-banana-2", "prompt": "isometric factory floor, technical illustration", "size": "1024x1024", "n": 2 }, ) data = res.json() ``` ### Response ```json { "created": 1786312455, "model": "google/nano-banana-2", "data": [ { "url": "https://cdn.routehook.ai/img/9d21f4-0.png" }, { "url": "https://cdn.routehook.ai/img/9d21f4-1.png" } ] } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 400 | `invalid_request` | Malformed body, or a parameter outside its bounds. | | 401 | `invalid_api_key` | Key missing, malformed or revoked. | | 402 | `insufficient_credits` | Top up the balance before retrying. | | 429 | `rate_limited` | Back off using the Retry-After header. | | 503 | `upstream_unavailable` | Upstream outage. Safe to retry. | | 409 | `model_unavailable` | Model is listed but has no live endpoint to route to. | ## POST /v1/images The same image generation under OpenRouter's path. One handler, one bill. Status: Available. Authentication: any live API key on the account. > The same handler as POST /v1/images/generations, at OpenRouter's spelling of the path. One implementation, one bill, one response shape. Which of the two you call is a matter of which client library you already have. ### Parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `model` | string | Required | — | Model slug, as returned by GET /v1/models. | | `prompt` | string | Required | — | What to generate. Up to 32,000 characters. | | `n` | integer | Optional | 1 | Images per request, 1 to 10. Each bills at the unit rate. | | `size` | string | Optional | model default | Dimensions (1024x1024), a ratio (16:9) or a tier (1K). Translated to whatever the model expects, and only the half it states: a ratio sets the shape, a tier sets the magnitude, dimensions set both. | | `aspect_ratio` | string | Optional | model default | The shape, named directly: 16:9, 9:16, 1:1. Wins over anything derived from size. Snapped to the nearest ratio a model accepts rather than refused. | | `resolution` | string | Optional | model default | The magnitude, named directly: 1K, 2K, 4K, 1024x1024. Wins over size. This is the field media rates are keyed on, so naming it names the rate you are billed at. | | `quality` | string | Optional | — | Provider hint, same vocabulary as chat. Priced alongside resolution on models that sell both, such as GPT Image 2. | | `reference_urls` | string[] | Optional | — | Publicly reachable input images, for models that take references. Ignored by models that do not. | ### Request **cURL** ```bash curl -X POST https://api.routehook.ai/v1/images \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "google/nano-banana-2", "prompt": "isometric factory floor, technical illustration", "size": "1024x1024", "n": 2 }' ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/images", { method: "POST", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ "model": "google/nano-banana-2", "prompt": "isometric factory floor, technical illustration", "size": "1024x1024", "n": 2 }), }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.post( "https://api.routehook.ai/v1/images", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, json={ "model": "google/nano-banana-2", "prompt": "isometric factory floor, technical illustration", "size": "1024x1024", "n": 2 }, ) data = res.json() ``` ### Response ```json { "created": 1786312455, "model": "google/nano-banana-2", "data": [ { "url": "https://cdn.routehook.ai/img/9d21f4-0.png" }, { "url": "https://cdn.routehook.ai/img/9d21f4-1.png" } ] } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 400 | `invalid_request` | Malformed body, or a parameter outside its bounds. | | 401 | `invalid_api_key` | Key missing, malformed or revoked. | | 402 | `insufficient_credits` | Top up the balance before retrying. | | 429 | `rate_limited` | Back off using the Retry-After header. | | 503 | `upstream_unavailable` | Upstream outage. Safe to retry. | | 409 | `model_unavailable` | Model is listed but has no live endpoint to route to. | ## POST /v1/embeddings Turn text into vectors for search, clustering and retrieval. Status: Available. Authentication: any live API key on the account. > Only models in the `embedding` category are accepted; anything else is refused with `invalid_request` before a hold is taken. Vectors are truncated in this example. Real ones are the model's full width. ### Parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `model` | string | Required | — | Model slug, as returned by GET /v1/models. | | `input` | string \| string[] \| number[][] | Required | — | One string, a batch of strings, or pre-tokenised input to embed in a single call. | | `encoding_format` | float \| base64 | Optional | float | How each vector is encoded in the response. | | `dimensions` | integer | Optional | model default | Truncate vectors to this width, where the model supports it. | | `user` | string | Optional | — | Opaque end-user identifier, forwarded upstream. | ### Request **cURL** ```bash curl -X POST https://api.routehook.ai/v1/embeddings \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "openai/text-embedding-3-small", "input": [ "first document", "second document" ] }' ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/embeddings", { method: "POST", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ "model": "openai/text-embedding-3-small", "input": [ "first document", "second document" ] }), }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.post( "https://api.routehook.ai/v1/embeddings", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, json={ "model": "openai/text-embedding-3-small", "input": [ "first document", "second document" ] }, ) data = res.json() ``` ### Response ```json { "object": "list", "model": "openai/text-embedding-3-small", "data": [ { "object": "embedding", "index": 0, "embedding": [0.0021, -0.0138, 0.0074] }, { "object": "embedding", "index": 1, "embedding": [0.0019, -0.0142, 0.0069] } ], "usage": { "prompt_tokens": 6, "total_tokens": 6 } } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 400 | `invalid_request` | Malformed body, or a parameter outside its bounds. | | 401 | `invalid_api_key` | Key missing, malformed or revoked. | | 402 | `insufficient_credits` | Top up the balance before retrying. | | 429 | `rate_limited` | Back off using the Retry-After header. | | 503 | `upstream_unavailable` | Upstream outage. Safe to retry. | | 409 | `model_unavailable` | Model is listed but has no live endpoint to route to. | ## POST /v1/responses OpenAI's Responses API, served by every text model on the platform rather than one vendor's. Status: Available. Authentication: any live API key on the account. > Translated onto a chat completion rather than forwarded, so every text model in the catalogue answers here, not only the one vendor that implements this route. `previous_response_id` is refused rather than ignored: this gateway stores no conversations, and a reply that had silently forgotten the earlier turns would be worse than an error saying so. ### Parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `model` | string | Required | — | Model slug, as returned by GET /v1/models. | | `input` | string \| object[] | Required | — | A prompt, or the conversation so far as a list of role/content items. | | `instructions` | string | Optional | — | The system prompt. Sent as its own turn, not folded into the first user message. | | `max_output_tokens` | integer | Optional | model default | Ceiling on the answer. | | `stream` | boolean | Optional | false | Emit the answer as server-sent events. | | `tools` | object[] | Optional | — | Function definitions, in the same shape chat takes. | | `tool_choice` | string \| object | Optional | — | auto, none, required, or a named function. | | `parallel_tool_calls` | boolean | Optional | — | Allow more than one tool call in a single turn. | | `temperature` | number | Optional | — | 0 to 2. Forwarded unchanged. | | `top_p` | number | Optional | — | 0 to 1. Forwarded unchanged. | | `metadata` | object | Optional | — | Echoed back on the response object rather than forwarded upstream. It changes neither routing nor price. | | `store` | boolean | Optional | — | Accepted and ignored. This gateway stores no conversations, so there is nothing for it to switch on. | | `previous_response_id` | string | Optional | — | Refused with 400 rather than ignored, for the reason in the note below. | | `user` | string | Optional | — | An opaque end-user id, forwarded upstream. | ### Request **cURL** ```bash curl -X POST https://api.routehook.ai/v1/responses \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "openai/gpt-5.4-mini", "input": "Say exactly: parity works", "max_output_tokens": 20 }' ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/responses", { method: "POST", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ "model": "openai/gpt-5.4-mini", "input": "Say exactly: parity works", "max_output_tokens": 20 }), }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.post( "https://api.routehook.ai/v1/responses", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, json={ "model": "openai/gpt-5.4-mini", "input": "Say exactly: parity works", "max_output_tokens": 20 }, ) data = res.json() ``` ### Response ```json { "id": "resp_A81FB265", "object": "response", "created_at": 1787654321, "status": "completed", "error": null, "incomplete_details": null, "model": "openai/gpt-5.4-mini", "output": [ { "type": "message", "role": "assistant", "content": [{ "type": "output_text", "text": "parity works", "annotations": [] }] } ], "output_text": "parity works", "instructions": null, "max_output_tokens": 20, "temperature": null, "top_p": null, "parallel_tool_calls": true, "tool_choice": "auto", "tools": [], "metadata": {}, "usage": { "input_tokens": 12, "output_tokens": 3, "total_tokens": 15 } } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 400 | `invalid_request` | Malformed body, or a parameter outside its bounds. | | 401 | `invalid_api_key` | Key missing, malformed or revoked. | | 402 | `insufficient_credits` | Top up the balance before retrying. | | 429 | `rate_limited` | Back off using the Retry-After header. | | 503 | `upstream_unavailable` | Upstream outage. Safe to retry. | | 409 | `model_unavailable` | Model is listed but has no live endpoint to route to. | ## POST /v1/audio/speech Turn text into spoken audio and stream the bytes back. Status: Available. Authentication: any live API key on the account. > The response is audio, not JSON. The bytes are streamed rather than buffered, so a long document does not sit in memory on either side. Billing is per character of input, and the charge is on X-Routehook-Cost because there is no body to put it in. ### Parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `model` | string | Required | — | Model slug, as returned by GET /v1/models. | | `input` | string | Required | — | The text to speak. Capped at 4096 characters, which is also the axis this route is billed on. | | `voice` | string | Optional | alloy | Vendor voice id. Not an enum here. A new voice should not need a release from us to be usable. | | `response_format` | mp3 \| opus \| aac \| flac \| wav \| pcm | Optional | mp3 | Container for the returned audio. | | `speed` | number | Optional | 1.0 | Playback rate, 0.25 to 4.0. | ### Request **cURL** ```bash curl -X POST https://api.routehook.ai/v1/audio/speech \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "openai/gpt-transcribe", "input": "Routehook audio is live.", "voice": "alloy" }' ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/audio/speech", { method: "POST", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ "model": "openai/gpt-transcribe", "input": "Routehook audio is live.", "voice": "alloy" }), }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.post( "https://api.routehook.ai/v1/audio/speech", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, json={ "model": "openai/gpt-transcribe", "input": "Routehook audio is live.", "voice": "alloy" }, ) data = res.json() ``` ### Response ```json HTTP/1.1 200 OK Content-Type: audio/mpeg X-Routehook-Cost: 0.00045 ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 400 | `invalid_request` | Malformed body, or a parameter outside its bounds. | | 401 | `invalid_api_key` | Key missing, malformed or revoked. | | 402 | `insufficient_credits` | Top up the balance before retrying. | | 429 | `rate_limited` | Back off using the Retry-After header. | | 503 | `upstream_unavailable` | Upstream outage. Safe to retry. | | 409 | `model_unavailable` | Model is listed but has no live endpoint to route to. | ## POST /v1/audio/transcriptions Transcribe audio to text, with optional word and segment timestamps. Status: Available. Authentication: any live API key on the account. > Billed per second of audio. Where a duration is reported that number is used; where it is not, the upload is measured and the request row records that the figure was estimated rather than metered. ### Parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `model` | string | Required | — | Model slug, as returned by GET /v1/models. | | `input_audio` | object | Required | — | `{ data, format }`: base64 audio, with or without a data: prefix, and the container it is in. | | `language` | string | Optional | — | ISO-639-1 hint. Improves both accuracy and latency when it is known. | | `response_format` | json \| text \| srt \| verbose_json \| vtt | Optional | json | Shape of the transcript. Non-JSON formats are returned verbatim. | | `timestamp_granularities` | string[] | Optional | — | `word` and/or `segment`. Only meaningful with verbose_json. | ### Request **cURL** ```bash curl -X POST https://api.routehook.ai/v1/audio/transcriptions \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "openai/gpt-live-transcribe", "input_audio": { "data": "UklGRiQAAABXQVZF...", "format": "mp3" } }' ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/audio/transcriptions", { method: "POST", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ "model": "openai/gpt-live-transcribe", "input_audio": { "data": "UklGRiQAAABXQVZF...", "format": "mp3" } }), }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.post( "https://api.routehook.ai/v1/audio/transcriptions", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, json={ "model": "openai/gpt-live-transcribe", "input_audio": { "data": "UklGRiQAAABXQVZF...", "format": "mp3" } }, ) data = res.json() ``` ### Response ```json { "text": "Routehook audio is live." } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 400 | `invalid_request` | Malformed body, or a parameter outside its bounds. | | 401 | `invalid_api_key` | Key missing, malformed or revoked. | | 402 | `insufficient_credits` | Top up the balance before retrying. | | 429 | `rate_limited` | Back off using the Retry-After header. | | 503 | `upstream_unavailable` | Upstream outage. Safe to retry. | | 409 | `model_unavailable` | Model is listed but has no live endpoint to route to. | ## POST /v1/rerank Score a set of documents against a query and return them in relevance order. Status: Available. Authentication: any live API key on the account. > `index` is the position in the request's document list, not in this response, so a reordered result still points at what you sent. Billed on the query plus the documents. ### Parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `model` | string | Required | — | Model slug, as returned by GET /v1/models. | | `query` | string | Required | — | What the documents are being scored against. | | `documents` | string[] \| object[] | Required | — | Up to 1000 documents, as plain strings or as objects carrying a `text` field. | | `top_n` | integer | Optional | all | Return only the highest-scoring N. | | `return_documents` | boolean | Optional | false | Echo each document back beside its score. | ### Request **cURL** ```bash curl -X POST https://api.routehook.ai/v1/rerank \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "YOUR_MODEL_ID", "query": "how do holds work", "documents": [ "A hold reserves the ceiling before the call.", "Invoices are issued monthly." ], "top_n": 2 }' ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/rerank", { method: "POST", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ "model": "YOUR_MODEL_ID", "query": "how do holds work", "documents": [ "A hold reserves the ceiling before the call.", "Invoices are issued monthly." ], "top_n": 2 }), }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.post( "https://api.routehook.ai/v1/rerank", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, json={ "model": "YOUR_MODEL_ID", "query": "how do holds work", "documents": [ "A hold reserves the ceiling before the call.", "Invoices are issued monthly." ], "top_n": 2 }, ) data = res.json() ``` ### Response ```json { "id": "req_9f2c41be", "model": "YOUR_MODEL_ID", "results": [ { "index": 0, "relevance_score": 0.97 }, { "index": 1, "relevance_score": 0.04 } ], "usage": { "total_tokens": 24 } } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 400 | `invalid_request` | Malformed body, or a parameter outside its bounds. | | 401 | `invalid_api_key` | Key missing, malformed or revoked. | | 402 | `insufficient_credits` | Top up the balance before retrying. | | 429 | `rate_limited` | Back off using the Retry-After header. | | 503 | `upstream_unavailable` | Upstream outage. Safe to retry. | | 409 | `model_unavailable` | Model is listed but has no live endpoint to route to. | ## POST /v1/queue/{model} Submit a job and return immediately with the id and the URLs that follow it. Status: Available. Authentication: any live API key on the account. > Everything in the body other than the fields above is the model's own input and is passed through. Money is held at submit and settled when the job reaches a terminal state. ### Parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `model` | string | Required | — | Path parameter, and a wildcard. The whole slug, slashes included. | | `webhook_url` | string | Optional | — | https URL to POST the terminal state to. `?fal_webhook=` is accepted for the same purpose. | | `prompt` | string | Optional | — | Up to 32,000 characters. Read by the gateway for the request record, and forwarded. | | `n` | integer | Optional | — | 1 to 10. Multiplies the reservation and the bill. | | `size` | string | Optional | — | Vendor size string, forwarded as sent. | | `duration` | number | Optional | — | Seconds, 1 to 600. The axis a video model is billed on, so it also sizes the hold. | | `Idempotency-Key` | header | Optional | — | Repeating a submit with the same key returns the existing job instead of opening a second one. | ### Request **cURL** ```bash curl -X POST https://api.routehook.ai/v1/queue/google/veo-3.1 \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "prompt": "drone shot over a harbour at dawn", "duration": 5, "webhook_url": "https://your.app/hooks/routehook" }' ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/queue/google/veo-3.1", { method: "POST", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ "prompt": "drone shot over a harbour at dawn", "duration": 5, "webhook_url": "https://your.app/hooks/routehook" }), }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.post( "https://api.routehook.ai/v1/queue/google/veo-3.1", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, json={ "prompt": "drone shot over a harbour at dawn", "duration": 5, "webhook_url": "https://your.app/hooks/routehook" }, ) data = res.json() ``` ### Response ```json { "request_id": "job_ab12cd34", "status": "IN_QUEUE", "queue_position": 3, "status_url": "https://api.routehook.ai/v1/queue/requests/job_ab12cd34/status", "response_url": "https://api.routehook.ai/v1/queue/requests/job_ab12cd34", "cancel_url": "https://api.routehook.ai/v1/queue/requests/job_ab12cd34/cancel" } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 400 | `invalid_request` | Malformed body, or a parameter outside its bounds. | | 401 | `invalid_api_key` | Key missing, malformed or revoked. | | 402 | `insufficient_credits` | Top up the balance before retrying. | | 429 | `rate_limited` | Back off using the Retry-After header. | | 503 | `upstream_unavailable` | Upstream outage. Safe to retry. | | 409 | `model_unavailable` | Model is listed but has no live endpoint to route to. | ## GET /v1/queue/requests/{job_id}/status Poll a job: queue position, terminal outcome, timings and optional logs. Status: Available. Authentication: any live API key on the account. > `status` carries fal's three values only (IN_QUEUE, IN_PROGRESS, COMPLETED), so a client polling until COMPLETED terminates. A job that failed or was cancelled is COMPLETED here and says which in `outcome`. `queue_position` is null once the job has left the queue rather than 0, because a running job reporting 0 reads as about to start. `metrics` is in seconds. Fal's unit, and the one place this API does not suffix a duration `_ms`. `GET /v1/queue/{model}/requests/{job_id}/status` is the same handler. ### Parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `job_id` | string | Required | — | Path parameter. The request_id returned by the submit call. | | `logs` | 0 \| 1 | Optional | 0 | Include the job's log lines in the response. | ### Request **cURL** ```bash curl https://api.routehook.ai/v1/queue/requests/job_ab12cd34/status?logs=1 \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/queue/requests/job_ab12cd34/status?logs=1", { method: "GET", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` }, }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.request( "GET", "https://api.routehook.ai/v1/queue/requests/job_ab12cd34/status?logs=1", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, ) data = res.json() ``` ### Response ```json { "request_id": "job_ab12cd34", "status": "IN_PROGRESS", "outcome": null, "queue_position": null, "logs": [ { "message": "sampling frame 12/120", "level": "INFO", "timestamp": "2026-08-23T11:02:47Z" } ], "metrics": { "inference_time": null, "queue_time": 4.1 }, "error": null } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 401 | `invalid_api_key` | Key missing, malformed or revoked. | | 404 | `model_unavailable` | No such id, or it belongs to another account. Note the code. This route does not answer not_found. | | 429 | `rate_limited` | Back off using the Retry-After header. | ## GET /v1/queue/requests/{job_id} Fetch the output of a finished job. Status: Available. Authentication: any live API key on the account. > **Check the status code.** While the job is still running this route answers `202` with the status body — `request_id`, `status`, `queue_position` — not with the result above; 200 means finished. A client that parses the body without looking at the code reads a running job as a result with no payload. `payload` is the upstream's own result body, forwarded verbatim rather than normalised, so there is one shape per model. A job that failed answers with the standard error envelope and the failure's status code, not with a 200 carrying an error field. `GET /v1/queue/{model}/requests/{job_id}` is the same handler. ### Parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `job_id` | string | Required | — | Path parameter. The request_id returned by the submit call. | ### Request **cURL** ```bash curl https://api.routehook.ai/v1/queue/requests/job_ab12cd34 \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/queue/requests/job_ab12cd34", { method: "GET", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` }, }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.request( "GET", "https://api.routehook.ai/v1/queue/requests/job_ab12cd34", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, ) data = res.json() ``` ### Response ```json { "request_id": "job_ab12cd34", "model": "google/veo-3.1", "created": 1786312455, "outcome": "succeeded", "payload": { "video": { "url": "https://cdn.routehook.ai/vid/ab12cd34.mp4" } }, "gateway_request_id": "req_7c41d9be", "cost": "0.7500", "usage": { "video_seconds": "5" } } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 401 | `invalid_api_key` | Key missing, malformed or revoked. | | 404 | `model_unavailable` | No such id, or it belongs to another account. Note the code. This route does not answer not_found. | | 429 | `rate_limited` | Back off using the Retry-After header. | ## PUT /v1/queue/requests/{job_id}/cancel Cancel a job that has not finished and release its hold. Status: Available. Authentication: any live API key on the account. > Cancelling releases the hold. The call is idempotent and answers 200 even for a job that had already finished: `canceled` is false there, and exists for the audit trail rather than for control flow. `outcome` is null in the window between the cancellation being recorded and the worker abandoning an attempt already in flight upstream. ### Parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `job_id` | string | Required | — | Path parameter. The request_id returned by the submit call. | ### Request **cURL** ```bash curl -X PUT https://api.routehook.ai/v1/queue/requests/job_ab12cd34/cancel \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/queue/requests/job_ab12cd34/cancel", { method: "PUT", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` }, }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.request( "PUT", "https://api.routehook.ai/v1/queue/requests/job_ab12cd34/cancel", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, ) data = res.json() ``` ### Response ```json { "request_id": "job_ab12cd34", "status": "COMPLETED", "outcome": "canceled", "canceled": true } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 401 | `invalid_api_key` | Key missing, malformed or revoked. | | 404 | `model_unavailable` | No such id, or it belongs to another account. Note the code. This route does not answer not_found. | | 429 | `rate_limited` | Back off using the Retry-After header. | ## GET /v1/queue/requests/{job_id}/stream Server-sent stream of a job's status transitions, instead of polling. Status: Available. Authentication: any live API key on the account. > The response is `text/event-stream`; the object above is one event's `data:` payload. The stream ends when the job reaches a terminal state. Use it instead of a polling loop, not alongside one. ### Parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `job_id` | string | Required | — | Path parameter. The request_id returned by the submit call. | ### Request **cURL** ```bash curl https://api.routehook.ai/v1/queue/requests/job_ab12cd34/stream \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/queue/requests/job_ab12cd34/stream", { method: "GET", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` }, }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.request( "GET", "https://api.routehook.ai/v1/queue/requests/job_ab12cd34/stream", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, ) data = res.json() ``` ### Response ```json { "request_id": "job_ab12cd34", "status": "IN_PROGRESS", "outcome": null, "queue_position": 0 } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 401 | `invalid_api_key` | Key missing, malformed or revoked. | | 404 | `model_unavailable` | No such id, or it belongs to another account. Note the code. This route does not answer not_found. | | 429 | `rate_limited` | Back off using the Retry-After header. | ## GET /v1/queue/{model}/requests/{job_id}/status The same poll, at fal's URL shape. The model segment is ignored. Status: Available. Authentication: any live API key on the account. > fal's path shape for GET /v1/queue/requests/{job_id}/status, reaching the same handler. It exists so a client that kept fal's URLs works without rewriting them; a new integration should use the short form. ### Parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `model` | string | Required | — | Path parameter, and a wildcard. Matched and then ignored — the job id alone identifies the job, and any slug here is accepted. | | `job_id` | string | Required | — | Path parameter. The request_id returned by the submit call. | | `logs` | 0 \| 1 | Optional | 0 | Include the job's log lines in the response. | ### Request **cURL** ```bash curl https://api.routehook.ai/v1/queue/google/veo-3.1/requests/job_ab12cd34/status?logs=1 \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/queue/google/veo-3.1/requests/job_ab12cd34/status?logs=1", { method: "GET", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` }, }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.request( "GET", "https://api.routehook.ai/v1/queue/google/veo-3.1/requests/job_ab12cd34/status?logs=1", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, ) data = res.json() ``` ### Response ```json { "request_id": "job_ab12cd34", "status": "IN_PROGRESS", "outcome": null, "queue_position": null, "logs": [ { "message": "sampling frame 12/120", "level": "INFO", "timestamp": "2026-08-23T11:02:47Z" } ], "metrics": { "inference_time": null, "queue_time": 4.1 }, "error": null } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 401 | `invalid_api_key` | Key missing, malformed or revoked. | | 404 | `model_unavailable` | No such id, or it belongs to another account. Note the code. This route does not answer not_found. | | 429 | `rate_limited` | Back off using the Retry-After header. | ## GET /v1/queue/{model}/requests/{job_id} The same result, at fal's URL shape. Status: Available. Authentication: any live API key on the account. > fal's path shape for GET /v1/queue/requests/{job_id}, reaching the same handler — including the 202 while the job is still running. It exists so a client that kept fal's URLs works without rewriting them; a new integration should use the short form. ### Parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `model` | string | Required | — | Path parameter, and a wildcard. Matched and then ignored — the job id alone identifies the job, and any slug here is accepted. | | `job_id` | string | Required | — | Path parameter. The request_id returned by the submit call. | ### Request **cURL** ```bash curl https://api.routehook.ai/v1/queue/google/veo-3.1/requests/job_ab12cd34 \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/queue/google/veo-3.1/requests/job_ab12cd34", { method: "GET", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` }, }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.request( "GET", "https://api.routehook.ai/v1/queue/google/veo-3.1/requests/job_ab12cd34", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, ) data = res.json() ``` ### Response ```json { "request_id": "job_ab12cd34", "model": "google/veo-3.1", "created": 1786312455, "outcome": "succeeded", "payload": { "video": { "url": "https://cdn.routehook.ai/vid/ab12cd34.mp4" } }, "gateway_request_id": "req_7c41d9be", "cost": "0.7500", "usage": { "video_seconds": "5" } } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 401 | `invalid_api_key` | Key missing, malformed or revoked. | | 404 | `model_unavailable` | No such id, or it belongs to another account. Note the code. This route does not answer not_found. | | 429 | `rate_limited` | Back off using the Retry-After header. | ## PUT /v1/queue/{model}/requests/{job_id}/cancel The same cancel, at fal's URL shape. Status: Available. Authentication: any live API key on the account. > fal's path shape for PUT /v1/queue/requests/{job_id}/cancel, reaching the same handler. It exists so a client that kept fal's URLs works without rewriting them; a new integration should use the short form. ### Parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `model` | string | Required | — | Path parameter, and a wildcard. Matched and then ignored — the job id alone identifies the job, and any slug here is accepted. | | `job_id` | string | Required | — | Path parameter. The request_id returned by the submit call. | ### Request **cURL** ```bash curl -X PUT https://api.routehook.ai/v1/queue/google/veo-3.1/requests/job_ab12cd34/cancel \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/queue/google/veo-3.1/requests/job_ab12cd34/cancel", { method: "PUT", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` }, }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.request( "PUT", "https://api.routehook.ai/v1/queue/google/veo-3.1/requests/job_ab12cd34/cancel", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, ) data = res.json() ``` ### Response ```json { "request_id": "job_ab12cd34", "status": "COMPLETED", "outcome": "canceled", "canceled": true } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 401 | `invalid_api_key` | Key missing, malformed or revoked. | | 404 | `model_unavailable` | No such id, or it belongs to another account. Note the code. This route does not answer not_found. | | 429 | `rate_limited` | Back off using the Retry-After header. | ## POST /v1/run/{model} Submit a job and hold the connection until it finishes or the wait ceiling is reached. Status: Available. Authentication: any live API key on the account. > Identical to submitting and polling, with the wait done for you. Past the platform's wait ceiling the call returns the job so you can poll it. The work is not thrown away. ### Parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `model` | string | Required | — | Path parameter, and a wildcard. The whole slug, slashes included. | ### Request **cURL** ```bash curl -X POST https://api.routehook.ai/v1/run/google/veo-3.1 \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "prompt": "drone shot over a harbour at dawn", "duration": 5 }' ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/run/google/veo-3.1", { method: "POST", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ "prompt": "drone shot over a harbour at dawn", "duration": 5 }), }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.post( "https://api.routehook.ai/v1/run/google/veo-3.1", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, json={ "prompt": "drone shot over a harbour at dawn", "duration": 5 }, ) data = res.json() ``` ### Response ```json { "request_id": "job_ab12cd34", "model": "google/veo-3.1", "created": 1786312455, "outcome": "succeeded", "payload": { "video": { "url": "https://cdn.routehook.ai/vid/ab12cd34.mp4" } }, "gateway_request_id": "req_7c41d9be", "cost": "0.7500", "usage": { "video_seconds": "5" } } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 400 | `invalid_request` | Malformed body, or a parameter outside its bounds. | | 401 | `invalid_api_key` | Key missing, malformed or revoked. | | 402 | `insufficient_credits` | Top up the balance before retrying. | | 429 | `rate_limited` | Back off using the Retry-After header. | | 503 | `upstream_unavailable` | Upstream outage. Safe to retry. | | 409 | `model_unavailable` | Model is listed but has no live endpoint to route to. | ## POST /v1/videos/generations Submit a video generation and get a job id back. Answers 202: the work runs for minutes. Status: Available. Authentication: any live API key on the account. > **202, not 200.** The name looks synchronous and the behaviour is not: video generation runs for minutes and no connection is held for it. Poll `status_url`, or GET /v1/videos/{job_id}, which is the same job under a shorter path. Everything in the body other than the fields above is the model's own input and is forwarded untouched. ### Parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `model` | string | Required | — | Body field, not a path segment. This route has none to carry it, unlike POST /v1/queue/{model}. | | `prompt` | string | Optional | — | Up to 32,000 characters. Read by the gateway for the request record, and forwarded. | | `duration` | number | Optional | — | Seconds, 1 to 600. The axis a video model is billed on, so it also sizes the hold. | | `size` | string | Optional | — | Vendor size string, forwarded as sent. | | `n` | integer | Optional | — | 1 to 10. Multiplies the reservation and the bill. | | `webhook_url` | string | Optional | — | https URL to POST the terminal state to. `?fal_webhook=` is accepted for the same purpose. | | `Idempotency-Key` | header | Optional | — | Repeating a submit with the same key returns the existing job instead of opening a second one. | ### Request **cURL** ```bash curl -X POST https://api.routehook.ai/v1/videos/generations \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "google/veo-3.1", "prompt": "drone shot over a harbour at dawn", "duration": 5 }' ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/videos/generations", { method: "POST", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ "model": "google/veo-3.1", "prompt": "drone shot over a harbour at dawn", "duration": 5 }), }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.post( "https://api.routehook.ai/v1/videos/generations", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, json={ "model": "google/veo-3.1", "prompt": "drone shot over a harbour at dawn", "duration": 5 }, ) data = res.json() ``` ### Response ```json { "request_id": "job_ab12cd34", "status": "IN_QUEUE", "queue_position": 3, "status_url": "https://api.routehook.ai/v1/queue/requests/job_ab12cd34/status", "response_url": "https://api.routehook.ai/v1/queue/requests/job_ab12cd34", "cancel_url": "https://api.routehook.ai/v1/queue/requests/job_ab12cd34/cancel" } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 400 | `invalid_request` | Malformed body, or a parameter outside its bounds. | | 401 | `invalid_api_key` | Key missing, malformed or revoked. | | 402 | `insufficient_credits` | Top up the balance before retrying. | | 429 | `rate_limited` | Back off using the Retry-After header. | | 503 | `upstream_unavailable` | Upstream outage. Safe to retry. | | 409 | `model_unavailable` | Model is listed but has no live endpoint to route to. | ## POST /v1/videos The same submit under OpenRouter's path. Status: Available. Authentication: any live API key on the account. > The same handler as POST /v1/videos/generations, at OpenRouter's spelling of the path. **202, not 200** — poll `status_url` or GET /v1/videos/{job_id}. ### Parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `model` | string | Required | — | Body field, not a path segment. This route has none to carry it, unlike POST /v1/queue/{model}. | | `prompt` | string | Optional | — | Up to 32,000 characters. Read by the gateway for the request record, and forwarded. | | `duration` | number | Optional | — | Seconds, 1 to 600. The axis a video model is billed on, so it also sizes the hold. | | `size` | string | Optional | — | Vendor size string, forwarded as sent. | | `n` | integer | Optional | — | 1 to 10. Multiplies the reservation and the bill. | | `webhook_url` | string | Optional | — | https URL to POST the terminal state to. `?fal_webhook=` is accepted for the same purpose. | | `Idempotency-Key` | header | Optional | — | Repeating a submit with the same key returns the existing job instead of opening a second one. | ### Request **cURL** ```bash curl -X POST https://api.routehook.ai/v1/videos \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "google/veo-3.1", "prompt": "drone shot over a harbour at dawn", "duration": 5 }' ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/videos", { method: "POST", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ "model": "google/veo-3.1", "prompt": "drone shot over a harbour at dawn", "duration": 5 }), }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.post( "https://api.routehook.ai/v1/videos", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, json={ "model": "google/veo-3.1", "prompt": "drone shot over a harbour at dawn", "duration": 5 }, ) data = res.json() ``` ### Response ```json { "request_id": "job_ab12cd34", "status": "IN_QUEUE", "queue_position": 3, "status_url": "https://api.routehook.ai/v1/queue/requests/job_ab12cd34/status", "response_url": "https://api.routehook.ai/v1/queue/requests/job_ab12cd34", "cancel_url": "https://api.routehook.ai/v1/queue/requests/job_ab12cd34/cancel" } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 400 | `invalid_request` | Malformed body, or a parameter outside its bounds. | | 401 | `invalid_api_key` | Key missing, malformed or revoked. | | 402 | `insufficient_credits` | Top up the balance before retrying. | | 429 | `rate_limited` | Back off using the Retry-After header. | | 503 | `upstream_unavailable` | Upstream outage. Safe to retry. | | 409 | `model_unavailable` | Model is listed but has no live endpoint to route to. | ## GET /v1/videos/{job_id} Poll a video job: its status while it runs, its result once it finishes. Status: Available. Authentication: any live API key on the account. > The queue's result route under a shorter path, and the same handler. While the job is running it answers **202** with the status body — `request_id`, `status`, `queue_position` — and once it finishes, 200 with the result above. Poll it until the status code changes. The finished video's bytes are at GET /v1/videos/{job_id}/content. ### Parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `job_id` | string | Required | — | Path parameter. The request_id returned by the submit call. | ### Request **cURL** ```bash curl https://api.routehook.ai/v1/videos/job_ab12cd34 \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/videos/job_ab12cd34", { method: "GET", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` }, }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.request( "GET", "https://api.routehook.ai/v1/videos/job_ab12cd34", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, ) data = res.json() ``` ### Response ```json { "request_id": "job_ab12cd34", "model": "google/veo-3.1", "created": 1786312455, "outcome": "succeeded", "payload": { "video": { "url": "https://cdn.routehook.ai/vid/ab12cd34.mp4" } }, "gateway_request_id": "req_7c41d9be", "cost": "0.7500", "usage": { "video_seconds": "5" } } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 401 | `invalid_api_key` | Key missing, malformed or revoked. | | 404 | `model_unavailable` | No such id, or it belongs to another account. Note the code. This route does not answer not_found. | | 429 | `rate_limited` | Back off using the Retry-After header. | ## GET /v1/videos/{job_id}/content Stream the finished video's bytes. Status: Available. Authentication: any live API key on the account. > The same proxy as GET /v1/files/{id}, reached by job id instead of by media reference. A job that has not finished answers 400 rather than 404: the bytes are on their way, and a client told 'not found' would stop polling. ### Request **cURL** ```bash curl https://api.routehook.ai/v1/videos/job_ab12cd34/content \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/videos/job_ab12cd34/content", { method: "GET", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` }, }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.request( "GET", "https://api.routehook.ai/v1/videos/job_ab12cd34/content", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, ) data = res.json() ``` ### Response ```json HTTP/1.1 200 OK Content-Type: video/mp4 Accept-Ranges: bytes ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 401 | `invalid_api_key` | Key missing, malformed or revoked. | | 404 | `not_found` | No such id, or it belongs to another account. | | 429 | `rate_limited` | Back off using the Retry-After header. | | 410 | `gone` | The reference expired. Permanent. Do not retry. | ## GET /v1/files/{id} Fetch generated media by reference. Proxied at request time, never redirected upstream. Status: Available. Authentication: any live API key on the account. > The origin copy is fetched at request time and streamed through. It never redirects: a 302 would publish the upstream URL, which is the one thing the indirection exists to hide. References expire in minutes, and an expired one answers 410 rather than a retryable code. ### Request **cURL** ```bash curl https://api.routehook.ai/v1/files/file_9f2c41be \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/files/file_9f2c41be", { method: "GET", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` }, }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.request( "GET", "https://api.routehook.ai/v1/files/file_9f2c41be", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, ) data = res.json() ``` ### Response ```json HTTP/1.1 200 OK Content-Type: video/mp4 Accept-Ranges: bytes Cache-Control: private, max-age=280, must-revalidate ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 401 | `invalid_api_key` | Key missing, malformed or revoked. | | 404 | `not_found` | No such id, or it belongs to another account. | | 429 | `rate_limited` | Back off using the Retry-After header. | | 410 | `gone` | The reference expired. Permanent. Do not retry. | ## GET /v1/jobs List this account's jobs, newest first, keyset-paged. Status: Available. Authentication: any live API key on the account. > This route is ours rather than fal's (fal has no job list), so it is the one place to find a job whose id was lost. A row carries no payload: fifty jobs' worth of upstream JSON is a megabyte of response to render a table, so the output stays behind `response_url`. `cost` is null until the hold is settled. An open job has been reserved against, not charged. ### Parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `status` | string | Optional | all | Filter to queued, running, succeeded, failed or canceled. The stored vocabulary, not the three wire statuses. | | `model` | string | Optional | all | Filter to one model slug, matched exactly. | | `category` | string | Optional | all | Filter to one modality: text, image, video, audio, embedding or rerank. | | `cursor` | string | Optional | — | Keyset cursor. The `next_cursor` value from the previous page. | | `limit` | integer | Optional | 50 | Rows per page, 1 to 200. | ### Request **cURL** ```bash curl https://api.routehook.ai/v1/jobs?limit=50&status=running \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/jobs?limit=50&status=running", { method: "GET", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` }, }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.request( "GET", "https://api.routehook.ai/v1/jobs?limit=50&status=running", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, ) data = res.json() ``` ### Response ```json { "data": [ { "request_id": "job_ab12cd34", "model": "google/veo-3.1", "category": "video", "status": "IN_PROGRESS", "outcome": null, "queue_position": null, "gateway_request_id": "req_7c41d9be", "cost": null, "error_code": null, "created_at": "2026-08-23T11:02:47Z", "started_at": "2026-08-23T11:02:51Z", "completed_at": null } ], "next_cursor": null } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 401 | `invalid_api_key` | Key missing, malformed or revoked. | | 429 | `rate_limited` | Back off using the Retry-After header. | ## GET /v1/models Every model with its category, status, published price and per-token rates. Status: Available. Authentication: none — this route answers anonymously. > Anonymous and cached, no key needed. Per-token rates are strings, because a client that sums a million float64 rates drifts. The headline `price` stays a number. ### Request **cURL** ```bash curl https://api.routehook.ai/v1/models \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/models", { method: "GET", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` }, }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.request( "GET", "https://api.routehook.ai/v1/models", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, ) data = res.json() ``` ### Response ```json { "data": [ { "id": "openai/gpt-5.4-mini", "canonical_slug": "openai/gpt-5.4-mini", "name": "GPT-4o mini", "category": "text", "status": "available", "unit": "per 1M tokens", "price": 0.15, "context_length": 128000, "pricing": { "prompt": "0.00000015", "completion": "0.0000006", "request": null, "image": null }, "supported_parameters": ["tools", "temperature", "max_tokens"] } ] } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 429 | `rate_limited` | Back off using the Retry-After header. | ## GET /v1/models/count How many models are servable right now. Status: Available. Authentication: none — this route answers anonymously. > Counts models whose status is `available`. Everything listed but not yet servable — `coming_soon` — is excluded, so this is the number of slugs a request can actually route to rather than the length of `GET /v1/models`. ### Request **cURL** ```bash curl https://api.routehook.ai/v1/models/count \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/models/count", { method: "GET", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` }, }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.request( "GET", "https://api.routehook.ai/v1/models/count", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, ) data = res.json() ``` ### Response ```json { "data": { "count": 214 } } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 429 | `rate_limited` | Back off using the Retry-After header. | ## GET /v1/models/user The catalogue as this caller sees it. Status: Available. Authentication: none — this route answers anonymously. > Returns the same catalogue as GET /v1/models. OpenRouter narrows this route by an account's provider preferences; there is no such per-account filtering here — every published model is available to every key — so the honest answer is the whole catalogue rather than a stub. The route exists so a client that calls it gets a catalogue instead of a 404, and it will narrow on its own the day per-account preferences do. ### Request **cURL** ```bash curl https://api.routehook.ai/v1/models/user \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/models/user", { method: "GET", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` }, }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.request( "GET", "https://api.routehook.ai/v1/models/user", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, ) data = res.json() ``` ### Response ```json { "data": [ { "id": "openai/gpt-5.4-mini", "canonical_slug": "openai/gpt-5.4-mini", "name": "GPT-4o mini", "category": "text", "status": "available", "unit": "per 1M tokens", "price": 0.15, "context_length": 128000, "pricing": { "prompt": "0.00000015", "completion": "0.0000006", "request": null, "image": null }, "supported_parameters": ["tools", "temperature", "max_tokens"] } ] } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 429 | `rate_limited` | Back off using the Retry-After header. | ## GET /v1/model/{author}/{slug} One model by slug. Singular 'model'. OpenRouter's spelling for this route. Status: Available. Authentication: none — this route answers anonymously. > Singular `model`, which is OpenRouter's spelling for this route and not a typo for the plural one. An unknown slug answers 404 with `model_unavailable` rather than `not_found` — note the code, and see the header of this file for why the two differ. ### Parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `author/slug` | string | Required | — | Path parameter, and two segments. Catalogue slugs are namespaced, so `openai/gpt-5.4-mini` is the whole of it. | ### Request **cURL** ```bash curl https://api.routehook.ai/v1/model/openai/gpt-5.4-mini \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/model/openai/gpt-5.4-mini", { method: "GET", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` }, }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.request( "GET", "https://api.routehook.ai/v1/model/openai/gpt-5.4-mini", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, ) data = res.json() ``` ### Response ```json { "data": { "id": "openai/gpt-5.4-mini", "canonical_slug": "openai/gpt-5.4-mini", "name": "GPT-4o mini", "category": "text", "status": "available", "unit": "per 1M tokens", "price": 0.15, "context_length": 128000, "architecture": { "modality": "text->text", "input_modalities": ["text"], "output_modalities": ["text"], "tokenizer": null, "instruct_type": null }, "pricing": { "prompt": "0.00000015", "completion": "0.0000006", "request": null, "image": null }, "supported_parameters": ["tools", "temperature", "max_tokens"], "alias_target": null, "reasoning": { "supported": false }, "discount": null } } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 429 | `rate_limited` | Back off using the Retry-After header. | | 404 | `model_unavailable` | No such id, or it belongs to another account. Note the code. This route does not answer not_found. | ## GET /v1/embeddings/models The catalogue narrowed to embedding models, with a total count. Status: Available. Authentication: none — this route answers anonymously. > Note the envelope: `total_count` sits beside `data` here, and does not on GET /v1/models. That is OpenRouter's shape for the narrowed listings and ours matches it per route, so a client written against theirs parses this without a branch. Anonymous and cached. ### Request **cURL** ```bash curl https://api.routehook.ai/v1/embeddings/models \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/embeddings/models", { method: "GET", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` }, }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.request( "GET", "https://api.routehook.ai/v1/embeddings/models", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, ) data = res.json() ``` ### Response ```json { "data": [ { "id": "openai/text-embedding-3-small", "name": "Text Embedding 3 Small", "category": "embedding", "status": "available", "unit": "per 1M tokens", "price": 0.02, "pricing": { "prompt": "0.00000002", "completion": null, "request": null, "image": null }, "supported_parameters": ["dimensions", "encoding_format"] } ], "total_count": 6 } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 429 | `rate_limited` | Back off using the Retry-After header. | ## GET /v1/images/models The catalogue narrowed to image models. Status: Available. Authentication: none — this route answers anonymously. > Note the envelope: `total_count` sits beside `data` here, and does not on GET /v1/models. That is OpenRouter's shape for the narrowed listings and ours matches it per route, so a client written against theirs parses this without a branch. Anonymous and cached. ### Request **cURL** ```bash curl https://api.routehook.ai/v1/images/models \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/images/models", { method: "GET", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` }, }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.request( "GET", "https://api.routehook.ai/v1/images/models", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, ) data = res.json() ``` ### Response ```json { "data": [ { "id": "google/nano-banana-2", "name": "Nano Banana 2", "category": "image", "status": "available", "unit": "per image, 1024x1024", "price": 0.039, "pricing": { "prompt": null, "completion": null, "request": null, "image": "0.039" }, "supported_parameters": ["size", "n", "reference_urls"] } ], "total_count": 24 } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 429 | `rate_limited` | Back off using the Retry-After header. | ## GET /v1/videos/models The catalogue narrowed to video models. Status: Available. Authentication: none — this route answers anonymously. > Note the envelope: `total_count` sits beside `data` here, and does not on GET /v1/models. That is OpenRouter's shape for the narrowed listings and ours matches it per route, so a client written against theirs parses this without a branch. Anonymous and cached. Every model listed here is served through the queue, not through a synchronous route — see /docs/queue. ### Request **cURL** ```bash curl https://api.routehook.ai/v1/videos/models \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/videos/models", { method: "GET", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` }, }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.request( "GET", "https://api.routehook.ai/v1/videos/models", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, ) data = res.json() ``` ### Response ```json { "data": [ { "id": "google/veo-3.1", "name": "Veo 3", "category": "video", "status": "available", "unit": "per 5s clip, 720p", "price": 0.75, "pricing": { "prompt": null, "completion": null, "request": "0.75", "image": null }, "supported_parameters": ["duration", "size", "prompt"] } ], "total_count": 18 } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 429 | `rate_limited` | Back off using the Retry-After header. | ## GET /v1/catalog The discovery catalogue behind the public site: names, blurbs, families and headline rates. Status: Available. Authentication: none — this route answers anonymously. > camelCase, unlike every other /v1 route, because this one feeds the public site rather than an OpenAI-shaped client. Build against /v1/models instead unless you are rendering a catalogue. ### Request **cURL** ```bash curl https://api.routehook.ai/v1/catalog \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/catalog", { method: "GET", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` }, }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.request( "GET", "https://api.routehook.ai/v1/catalog", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, ) data = res.json() ``` ### Response ```json { "data": [ { "id": "openai/gpt-5.4-mini", "slug": "openai-gpt-4o-mini", "name": "GPT-4o mini", "description": "Small, fast general-purpose text model.", "category": "text", "status": "available", "unit": "per 1M tokens", "price": 0.15, "reference": 0.30, "speed": "fast", "featured": true, "capabilities": ["tools", "vision"], "contextLength": 128000, "addedAt": "2026-05-01T09:14:22Z" } ], "generatedAt": "2026-08-23T11:02:47Z" } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 429 | `rate_limited` | Back off using the Retry-After header. | ## GET /v1/stats Platform totals: accounts, tokens billed over the trailing 30 days, providers and live models. Status: Available. Authentication: none — this route answers anonymously. > Four counts and nothing derived from them. `tokens` is input plus output over the trailing thirty days; cached-input and reasoning tokens are excluded on purpose, so the figure matches what an account would recognise from its own usage page. Cached for five minutes. ### Request **cURL** ```bash curl https://api.routehook.ai/v1/stats \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/stats", { method: "GET", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` }, }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.request( "GET", "https://api.routehook.ai/v1/stats", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, ) data = res.json() ``` ### Response ```json { "accounts": 1284, "tokens": 91744812, "providers": 17, "models": 214, "generatedAt": "2026-08-23T11:02:47Z" } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 429 | `rate_limited` | Back off using the Retry-After header. | ## POST /v1/mcp JSON-RPC 2.0 over Streamable HTTP: initialise a session and call the tools. Status: Available. Authentication: any live API key on the account. > The initialize reply carries an `Mcp-Session-Id` header. Send it on every later call. `notifications/initialized` answers 202 with no body. Tools that spend money say so in their own description. ### Parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `jsonrpc` | string | Required | — | Always "2.0". | | `method` | string | Required | — | initialize, tools/list, tools/call, resources/list, resources/read or prompts/get. | | `params` | object | Optional | — | Method arguments, per the MCP specification. | | `MCP-Protocol-Version` | header | Optional | 2025-06-18 | Negotiated protocol version. 2025-06-18, falling back to 2025-03-26. | ### Request **cURL** ```bash curl -X POST https://api.routehook.ai/v1/mcp \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "your-client", "version": "1.0.0" } } }' ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/mcp", { method: "POST", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "your-client", "version": "1.0.0" } } }), }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.post( "https://api.routehook.ai/v1/mcp", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, json={ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "your-client", "version": "1.0.0" } } }, ) data = res.json() ``` ### Response ```json { "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2025-06-18", "capabilities": { "tools": { "listChanged": false }, "resources": { "subscribe": false, "listChanged": false }, "prompts": {} }, "serverInfo": { "name": "routehook", "version": "1.0.0" } } } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 400 | `invalid_request` | Malformed body, or a parameter outside its bounds. | | 401 | `invalid_api_key` | Key missing, malformed or revoked. | | 402 | `insufficient_credits` | Top up the balance before retrying. | | 429 | `rate_limited` | Back off using the Retry-After header. | | 503 | `upstream_unavailable` | Upstream outage. Safe to retry. | | 200 | `-32601` | JSON-RPC error, not an HTTP one: unknown method. Transport errors stay HTTP. | ## GET /v1/mcp Open the server-sent stream for server-initiated MCP messages. Status: Available. Authentication: any live API key on the account. > `text/event-stream`; the object above is one event's `data:` payload. Only server-initiated messages arrive here. Tool calls go over POST. A session id this server does not know answers `404` carrying a JSON-RPC `-32600`, not the gateway's `{ error: { code } }` envelope: an MCP client is a JSON-RPC client, and the specification makes a 404 on a request bearing a session id mean "that session is gone, call initialize again". ### Parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `Mcp-Session-Id` | header | Required | — | The session id returned by initialize. | ### Request **cURL** ```bash curl https://api.routehook.ai/v1/mcp \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/mcp", { method: "GET", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` }, }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.request( "GET", "https://api.routehook.ai/v1/mcp", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, ) data = res.json() ``` ### Response ```json { "jsonrpc": "2.0", "method": "notifications/message", "params": { "level": "info", "data": "job_ab12cd34 finished" } } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 401 | `invalid_api_key` | Key missing, malformed or revoked. | | 429 | `rate_limited` | Back off using the Retry-After header. | ## DELETE /v1/mcp End an MCP session. Status: Available. Authentication: any live API key on the account. > There is no response body. Ending a session drops its stream; the API key itself is untouched. An unknown session id answers `404` with a JSON-RPC `-32600` rather than the gateway envelope, exactly as on the stream above. ### Parameters | Name | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `Mcp-Session-Id` | header | Required | — | The session to end. | ### Request **cURL** ```bash curl -X DELETE https://api.routehook.ai/v1/mcp \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/mcp", { method: "DELETE", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` }, }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.request( "DELETE", "https://api.routehook.ai/v1/mcp", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, ) data = res.json() ``` ### Response ```json 204 No Content ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 401 | `invalid_api_key` | Key missing, malformed or revoked. | | 429 | `rate_limited` | Back off using the Retry-After header. | ## GET /v1/status Platform health: component states, current incidents and recent uptime. Status: Available. Authentication: none — this route answers anonymously. > camelCase, unlike the OpenAI-shaped routes. `overall` and each component's `status` are upper case (OPERATIONAL, DEGRADED, DOWN or UNCONFIGURED), while a history day's `state` is lower case: operational, degraded, down or none. They are different vocabularies and a client that lower-cases one to match the other will be wrong about the day it did it to. There are four components, keyed `api`, `providers`, `storage` and `payments`, and `history` carries one entry per day for the whole 90-day window, oldest first (trimmed to one above). `monitored` is false when no probe has ever recorded a sample, which is a different claim from "everything is fine" and must not render the same way. `uptime` is a 0–1 fraction or null, never a percentage. The route deliberately keeps answering during maintenance (a status page that 503s while the platform is down is worse than none), and publishes measurements only: no vendor names, no topology. ### Request **cURL** ```bash curl https://api.routehook.ai/v1/status \ -H "Authorization: Bearer $ROUTEHOOK_API_KEY" ``` **JavaScript** ```javascript const res = await fetch("https://api.routehook.ai/v1/status", { method: "GET", headers: { Authorization: `Bearer ${process.env.ROUTEHOOK_API_KEY}` }, }); const data = await res.json(); ``` **Python** ```python import os, requests res = requests.request( "GET", "https://api.routehook.ai/v1/status", headers={"Authorization": f"Bearer {os.environ['ROUTEHOOK_API_KEY']}"}, ) data = res.json() ``` ### Response ```json { "overall": "OPERATIONAL", "monitored": true, "windowDays": 90, "generatedAt": "2026-08-23T11:02:47Z", "components": [ { "key": "api", "name": "API", "description": "The gateway and the dashboards behind it.", "status": "OPERATIONAL", "uptime": 0.9997, "latencyMs": 84, "latencyMeaning": "Median round trip to the database.", "sampleCount": 129600, "checkedAt": "2026-08-23T11:02:41Z", "history": [ { "date": "2026-05-26", "state": "operational", "uptime": 1, "sampleCount": 1440 } ] } ] } ``` ### Errors | Status | Code | Meaning | | --- | --- | --- | | 429 | `rate_limited` | Back off using the Retry-After header. | > **Note: Quote the request id** > > Every response carries a request id, and every error body repeats it. It is the key every lookup here is made on. A support thread that starts with one is a lookup, and one that starts without it is an investigation. --- # Changelog > What was listed, and when. Section: Resources. Canonical page: https://routehook.ai/docs/changelog Listing history is generated from the model catalogue rather than maintained by hand, so it cannot fall behind what the API actually serves. ## Model listings | DATE | MODEL | MODALITY | STATUS | | --- | --- | --- | --- | | 2026-09-21 | Deepseek Flash | Text | Live | | 2026-09-08 | Gpt Live 1 | Audio | Live | | 2026-09-04 | Gpt Image 2 5 Sunburst | Text | Live | | 2026-09-04 | Gpt Image 2 5 Flare | Text | Live | | 2026-09-01 | Kling 3.0 Edit | Video | Live | | 2026-09-01 | Kling Motion 3 | Video | Live | | 2026-09-01 | Kling Motion | Video | Live | | 2026-09-01 | Seedance 2.5 Omni | Video | Live | | 2026-09-01 | Deepseek V4 Pro | Text | Live | | 2026-09-01 | FLUX 3 Video | Video | Live | | 2026-09-01 | Seedance 2 Mini | Video | Live | | 2026-09-01 | Kling 2.1 (10s) | Video | Live | | 2026-09-01 | Kling 2.1 (5s) | Video | Live | | 2026-08-29 | Kling 2.5 | Video | Live | | 2026-08-29 | Kling 2.6 | Video | Live | | 2026-08-29 | Kling 3.0 | Video | Live | | 2026-08-29 | Seedance 2 Omni | Video | Live | | 2026-08-29 | Seedance 2 | Video | Live | | 2026-08-29 | Grok Video | Video | Live | | 2026-08-29 | Omni Flash | Video | Live | | 2026-08-29 | Veo 2 | Video | Live | | 2026-08-29 | Veo 3.1 Lite | Video | Live | | 2026-08-29 | Veo 3.1 Fast | Video | Live | | 2026-08-29 | Veo 3.1 | Video | Live | | 2026-08-29 | GPT Image 2 | Image | Live | | 2026-08-29 | Grok Image | Image | Live | | 2026-08-29 | Nano Banana 2 Lite | Image | Live | | 2026-08-29 | Nano Banana 2 | Image | Live | | 2026-08-29 | Nano Banana Pro | Image | Live | | 2026-08-28 | Claude Fable 5.1 | Text | Live | | 2026-08-27 | Gpt 6 Astra | Text | Live | | 2026-07-27 | Gpt Live Transcribe | Audio | Live | | 2026-07-27 | Gpt Transcribe | Audio | Live | | 2026-07-24 | Claude Opus 5 | Text | Live | | 2026-06-29 | Claude Sonnet 5 | Text | Live | | 2026-06-23 | Gpt 5 6 Luna | Text | Live | | 2026-06-23 | Gpt 5 6 Terra | Text | Live | | 2026-06-23 | Gpt 5 6 Sol | Text | Live | | 2026-06-07 | Claude Fable 5 | Text | Live | | 2026-05-05 | Gpt Realtime Whisper | Audio | Live | | 2026-05-05 | Gpt Realtime Translate | Audio | Live | | 2026-04-22 | Gpt 5 5 | Text | Live | | 2026-03-14 | Gpt 5 4 Mini | Text | Live | | 2026-03-14 | Gpt 5 4 Nano | Text | Live | | 2026-03-05 | Gpt 5 4 | Text | Live | | 2026-02-20 | Gpt Audio 1 5 | Text | Live | | 2026-02-17 | Claude Sonnet 4.6 | Text | Live | | 2025-10-03 | Gpt Audio Mini | Text | Live | | 2024-01-22 | Text Embedding 3 Large | Embeddings | Live | | 2024-01-22 | Text Embedding 3 Small | Embeddings | Live | > **Note: API changes** > > Additive fields arrive without notice. A new key on an existing object breaks nothing that ignores what it does not recognise. A change that would break a working client is announced here with a date and a migration, and the previous behaviour stays available for a stated window. --- # Support > Check the platform first, then send one request id and what you expected. Section: Resources. Canonical page: https://routehook.ai/docs/support Two things answer most questions before anyone is asked. GET /v1/status says whether the platform is degraded, and GET /v1/generation?id=req_… says exactly what happened to one request. How many attempts it took, whether a fallback was used, and what it cost. ## GET /v1/status Platform health: component states, current incidents and recent uptime. Status: Available. Authentication: none — this route answers anonymously. ## Status during an outage The status route deliberately keeps answering while the platform is in maintenance. A status endpoint that goes down with everything else tells a caller nothing at the moment they need it most, so this one is exempt from the maintenance guard every other route carries. ## What to include in a report - The request id: req_… from the response header, or from the error body. - The model slug you called and the route you called it on. - What you expected and what arrived, including the status code. - The timestamp with its timezone, if the request id is no longer to hand. > **Note: A failed request has a record too** > > Failures are logged like successes, so a request id from a 5xx is still a lookup. It is the fastest way to tell an upstream outage from a malformed body. ## Elsewhere - [Contact](/contact): Reach a human about billing, limits or an incident. - [Errors](/docs/errors): The eight codes, and which of them are worth retrying. --- # Policies These are drafts. Every place a real document would state a commitment carries an explicit note of what is still to be decided, rather than a number. Do not summarise a draft clause as though it were settled. # Terms of Service > The agreement between you and Routehook covering the website, the dashboard and the API. Effective date: To be confirmed. Canonical page: https://routehook.ai/terms ## About these terms These terms govern access to the Routehook website, dashboard and API. Together, the Platform. By creating an account, adding credits, or sending a request to the API, you agree to them. If you are agreeing on behalf of a company or other organisation, you confirm that you are authorised to bind that organisation, and “you” means that organisation. > **Not yet settled.** The contracting entity has not been settled. > > - Legal entity name, registered address and company number > - Which entity contracts with customers in which regions > - Whether separate terms apply to business and individual accounts ## Accounts You need an account to use the API. You are responsible for the information on it being accurate, for the security of the credentials that reach it, and for everything done through it. - Keep your contact details current. Billing and security notices go there. - You are responsible for activity under your account, including activity by people you give access to. - Tell us promptly if you believe an account or key has been compromised. > **Not yet settled.** Account eligibility rules are not final. > > - Minimum age and any restrictions on who may hold an account > - Whether team members, organisations or seats exist as account concepts > - Identity or business verification requirements, if any ## Credits and billing The Platform is prepaid. You add credits to your account, and a completed request deducts its own cost from that balance. There is no subscription and no monthly minimum. - A request that fails deducts nothing. - The cost of each call is reported on the response that caused it. - Rates for each model are published on the pricing page and returned by the models endpoint. > **Not yet settled.** Every commercial rule below is undecided. None is stated elsewhere on this site, and nothing should be inferred from the absence. > > - Cancellations and chargeback handling (refunds are settled: see the Refund Policy) > - Whether credits expire, and on what schedule > - Taxes, VAT and who bears them > - Billing currency and how conversion is handled > - Notice given before a published rate changes > - Whether unused credits survive account closure ## API keys and security An API key is a credential. Anything sent with your key is treated as sent by you, so how you store and distribute keys matters more than any control we can apply after the fact. - Keep keys server-side. Keys shipped to a browser or mobile client are treated as compromised. - Use a separate key per project so revoking one does not take down another. - Rotate keys when anyone with access to them leaves, and on any suspicion of exposure. > **Not yet settled.** Incident handling is not defined. > > - Where to report a compromised key, and how quickly we respond > - Whether we notify you of suspected compromise, and how > - What happens to usage billed against a compromised key ## Acceptable use Your use of the Platform is subject to the Acceptable Use Policy, which forms part of these terms. It covers what may not be generated, what may not be done to the Platform, and your responsibility for anyone you expose the API to. Breaching that policy is a breach of these terms, and can lead to suspension or termination. ## Your content Inputs are the prompts, files and data you send to the API. Outputs are what a model returns for them. You are responsible for having the rights to the inputs you send, and for how you use what comes back. > **Not yet settled.** Rights in inputs and outputs are the central commercial question here and have not been settled. > > - Who owns or is licensed to use outputs, and on what terms > - The licence, if any, we need over inputs to route and serve a request > - How ownership interacts with each model provider's own position on outputs > - Whether identical outputs may be returned to other customers ## Third-party model providers Requests are served by models run by third parties. Their policies can apply to your use in addition to ours, and a provider can change, restrict or withdraw a model on their own timetable. > **Not yet settled.** The pass-through position needs drafting. > > - Which provider terms and policies are passed through to you > - How model changes, restrictions and withdrawals are notified > - What happens to work in progress when a provider withdraws a model ## Availability and changes to the service We intend the Platform to be dependable, and the documentation describes how it behaves when it is not. Status codes, retry guidance and rate limit headers. That is a description of behaviour, not a promise about uptime. > **Not yet settled.** No availability commitment has been made anywhere on this site, and none is made here. > > - Whether a service level commitment exists, and what it covers > - Maintenance windows and how they are announced > - Notice period before an endpoint or model is deprecated > - Remedies, if any, when a commitment is missed ## Suspension and termination You can stop using the Platform at any time. We may suspend or limit an account where use breaches these terms or the Acceptable Use Policy, where there is a credible security concern, or where we are required to. > **Not yet settled.** The process around suspension is not defined. > > - Notice given before suspension, and when it can be immediate > - How to appeal or restore an account > - What happens to a remaining credit balance > - How long account data is kept after closure. See the Privacy Policy ## Disclaimers Model output can be wrong, biased or unsuitable for a particular purpose, and the same input can produce different output on different calls. Check output before relying on it, particularly anywhere a mistake carries consequences. > **Not yet settled.** The formal disclaimer has not been drafted. > > - Warranty position and its wording, per jurisdiction > - Consumer rights that cannot be excluded and how they are preserved > - Any stated limits on suitability for regulated or high-risk uses ## Limitation of liability > **Not yet settled.** Deliberately left blank. Liability caps and exclusions are the kind of term that has to be written by a lawyer for a specific jurisdiction, and inventing one here would be worse than leaving the gap visible. > > - Categories of loss excluded > - Any monetary cap, and how it is calculated > - Carve-outs that cannot be limited by law ## Indemnity > **Not yet settled.** Not drafted. > > - Whether either party indemnifies the other, and for what > - Whether any IP indemnity is offered for model output > - Conduct-of-claims process ## Changes to these terms These terms will change as the Platform does. When they do, the updated version will be published on this page. > **Not yet settled.** The change mechanism is not settled. > > - Notice period before a material change takes effect > - How notice is given. Email, dashboard, or this page alone > - Whether continued use counts as acceptance, and what your options are if you disagree ## Governing law and disputes > **Not yet settled.** No jurisdiction is stated here, because none has been chosen. Naming one before that decision is made would be a claim about where you would have to bring a claim. > > - Governing law > - Exclusive venue for disputes > - Whether arbitration or a class-action waiver applies > - Any informal resolution step required first ## Contact > **Not yet settled.** No legal contact route has been set up. > > - Address for legal and contractual notices > - Email for terms questions > - Process for service of formal notice --- # Privacy Policy > What the Platform collects, why it collects it, and where it goes when a request is served. Effective date: To be confirmed. Canonical page: https://routehook.ai/privacy ## Scope This policy covers personal information handled through the Routehook website, dashboard and API. It does not cover how a third-party model provider handles a request once it reaches them, which is governed by their own policy. ## What we collect Four kinds of information, distinguished because they are handled differently: - Account information. What you give us to create and hold an account, such as name, email address and authentication details. - Billing information. The record of credits added and drawn down. Card details are handled by a payment processor. - Usage information. Request metadata such as the model called, timestamps, cost, request identifiers, and technical data such as IP address and user agent. - Content. The prompts, files and other inputs you send, and the outputs returned for them. > **Not yet settled.** The specifics of collection are not confirmed. > > - Which payment processor is used and what it stores on our behalf > - Whether authentication is first-party, federated, or both > - Exactly which technical fields are logged, and at what granularity ## How we use it - To operate the Platform. Routing requests, returning results, and keeping accounts working. - To bill accurately. Deducting the cost of completed requests and showing usage back to you. - To keep the Platform secure. Detecting abuse, compromised keys and activity that degrades service for others. - To support you. Investigating issues you report, which is why every response carries a request identifier. - To meet legal obligations that apply to us. > **Not yet settled.** Legal bases have not been mapped. > > - The lawful basis relied on for each purpose, per applicable regime > - Whether any processing is based on consent, and how it is withdrawn ## Prompts, inputs and outputs Serving a request means sending your input to the model provider that runs the model you selected. What that provider does with it is governed by their policy, not this one. Avoid sending personal or confidential information in a prompt unless you have established that doing so is appropriate for your use and for the provider serving it. > **Not yet settled.** This is the most consequential gap in this document. Nothing here should be assumed either way until it is filled. > > - Whether prompt and output content is stored, and if so for how long > - Whether content is ever used to train or improve any model, by us or by a provider > - Whether staff can access content, under what controls, and for what reasons > - Whether a zero-retention or no-training option exists ## Who we share it with Information is shared with the model provider serving a request, and with the service providers needed to run the Platform. Hosting, payments, and support tooling among them. > **Not yet settled.** The subprocessor position needs to be written down before this is published. > > - A published subprocessor list and where it lives > - How changes to that list are notified, and whether objection is possible > - Whether data is shared for any purpose beyond running the Platform > - What happens to data in a sale, merger or insolvency ## Retention > **Not yet settled.** No retention period is stated here, because none has been set. A period published before it can be honoured is worse than an acknowledged gap. > > - How long account records are kept after closure > - How long usage and billing records are kept, and any legal minimum > - How long prompt and output content is kept, if at all > - How long security and access logs are kept > - Whether deletion is immediate or on a cycle, and how backups are handled ## Security Keys are shown in full once and stored in masked form afterwards, and can be revoked without a redeploy. Access to production systems is restricted to the people who need it. No system is perfectly secure, and nothing in this section is a guarantee against compromise. > **Not yet settled.** No certification or audit is claimed on this site, and none should be added here until it exists and can be evidenced. > > - Any security certification or audit actually held > - Encryption in transit and at rest, stated only once confirmed > - Breach notification process and timeframes ## International transfers Requests are routed to the nearest available region, and model providers operate their own infrastructure, so information may be processed outside the country you are in. > **Not yet settled.** Transfer mechanics are undecided. > > - Regions where data is processed and stored > - Transfer mechanism relied on for each region > - Whether regional pinning or data residency is offered ## Your choices and rights You can update account information from the dashboard, and closing an account stops further collection through it. > **Not yet settled.** No statutory regime is named here, because which ones apply depends on the contracting entity and the regions served, both of which are still open in the Terms. > > - Which privacy regimes apply, and the rights each grants > - How to make a request, and how identity is verified > - Response timeframes and any appeal route > - Contact point for privacy requests, and a data protection officer if one is appointed ## Cookies and analytics Some cookies are set because the Platform cannot work without them: a session cookie once you sign in, a cookie recording which sign-in you last used, and a cookie recording your answer to the question below. None of these are read by anyone but us, and none are used for advertising. Beyond those, two things are optional and are asked about rather than assumed. Analytics measures which pages are read, in aggregate. Advertising records which ad or link brought you here — a click identifier from the ad network, stored so that a later sign-up can be credited to the campaign that paid for it — and loads the measurement tags belonging to the networks we advertise on. Where a cookie banner is shown, nothing optional is loaded until you answer it, and the answer is honoured across the marketing site and the dashboard alike. You can change it at any time from “Cookie settings” at the foot of any page; withdrawing advertising consent also deletes the click identifier already stored. > **Not yet settled.** The legal framing around the above has not been settled, and the list of destinations depends on which ad networks are connected in a given deployment. > > - The lawful basis relied on for each category, per region > - Which advertising and analytics providers receive data, and where they process it > - How long each cookie is kept, stated per cookie > - Whether a jurisdiction requires the banner to be shown at all times, rather than only where configured ## Children The Platform is intended for developers building software, not for children. > **Not yet settled.** Age handling is not defined. > > - Minimum age for an account, per region > - What happens if an underage account is identified ## Changes to this policy This policy will change as the Platform does. The current version is always the one on this page. > **Not yet settled.** Notification approach undecided. > > - How material changes are notified > - Whether prior versions are kept available ## Contact > **Not yet settled.** No privacy contact route has been set up. > > - Email address for privacy enquiries > - Postal address of the controlling entity > - Escalation or supervisory authority details, where applicable --- # Refund Policy > Credit you have not spent can be refunded. Ask us, and it is back with you within seven days. Effective date: To be confirmed. Canonical page: https://routehook.ai/refunds ## What can be refunded Credit you have added but not yet spent is refundable. There is no subscription to cancel and no minimum term, so a refund here means one thing: returning the unused balance sitting on your account. Usage that has already happened is not. Once a request has been served the model has run and the cost has been settled against your balance, and that is the point at which the money has been spent rather than held. - Unused credit. Refundable, on request. - Requests already served, not refundable, whether or not the output was useful. - Requests that failed, never charged in the first place, so there is nothing to refund. If you were charged for something that did not run, or charged twice for one request, that is a billing error rather than a refund. Tell us and we will correct it. Corrections are not limited by anything in this policy and are not treated as a refund of unused credit. ## How to ask for one Contact us. Refunds are not automatic and there is no button in the dashboard that issues one. A refund moves money back to a payment method, so a person checks it first. Write from the email address on the account, tell us how much you want returned, and say whether you want the account closed afterwards. If the whole balance is going back, say so, and we will return it as it stands at the moment we process it. > **Not yet settled.** The intake route is not fixed. Until it is, the contact form is the address of record. > > - Whether a dedicated billing address exists, or the general contact form is it > - What we require to confirm a request came from the account holder > - Whether an organisation account can nominate someone other than the owner ## How long it takes Up to seven days from the day we accept the request. Most are quicker. Seven days is the outside figure we hold ourselves to, and it covers our side of the work: reviewing the request, deducting the balance, and sending the money back to the method it came from. Your bank or card issuer may take longer than that to show it. That part is outside our control and is not counted inside the seven days, so a refund sent on day five can appear on a statement some days after that. The credit leaves your balance when we process the refund, not when it lands. From that moment the account can no longer spend it, so requests sent against a balance you have asked us to return will fail rather than quietly eat into it. ## What comes off the amount Payment processing fees are deducted from the refund. When you add credit, the payment processor takes a fee on that transaction, and the fee is not returned to us when the payment is reversed, so refunding the full amount you paid would mean paying the fee a second time. You are refunded the unused credit, less the processing fee attributable to it. We will tell you both figures before we send anything, so you can decide whether to go ahead or leave the credit where it is and spend it instead. Credit that stays on the account keeps its full value. The fee is only ever lost on the way back out. > **Not yet settled.** The mechanics of the deduction are not settled, and the figures depend on the processor. > > - The processor fee schedule, and whether it is a percentage, a fixed amount, or both > - How the fee is apportioned when only part of a top-up is being returned > - What happens when the fee is worth more than the balance being returned > - Which currency you are refunded in, and who bears any conversion loss > - Whether a minimum refundable amount applies ## Closing your account You can ask for a refund without closing your account, and close your account without asking for a refund. They are separate requests, and telling us both at once is the easiest way to have them handled together. > **Not yet settled.** Terms leaves this open, and this document does not close it on its own. > > - Whether unused credit survives account closure, and for how long > - Whether credit expires while an account is open > - What notice we give before either applies ## If you dispute a charge Come to us first. A dispute raised with your bank is slower than an email to us, costs both sides a fee, and usually suspends the account while it is open. If the charge was our mistake, we would rather simply fix it. Nothing in this policy affects a statutory right you hold as a consumer in your own country. Where the law gives you a stronger right than this document does, the law applies. > **Not yet settled.** This section states an intention, not a procedure. > > - How an account is treated while a dispute is open > - Which consumer protection regimes apply, and to which customers > - Whether a statutory cooling-off period overrides the fee deduction above --- # Acceptable Use Policy > What may not be generated through the Platform, what may not be done to it, and what you are responsible for. Effective date: To be confirmed. Canonical page: https://routehook.ai/acceptable-use ## Scope This policy applies to everyone using the Platform, and to everything sent through an API key issued on your account, including requests made by your own users, if you build on top of it. It forms part of the Terms of Service. Where a model provider's policy is stricter than this one, the stricter policy applies. ## Content you may not generate Do not use the Platform to generate, distribute or store: - Sexual content involving minors, in any form and without exception. - Intimate imagery of a real person created or shared without their consent. - Content that harasses, threatens or incites violence against a person or group. - Content designed to deceive about a real person or organisation. Impersonation, fabricated statements, or synthetic media presented as genuine. - Malware, exploit code, phishing material, or anything else built to compromise systems or credentials. - Material that is illegal in the place it is created, sent or received. > **Not yet settled.** Category boundaries need legal and policy input before enforcement can be consistent. > > - Where lines fall on adult content, political content and medical or legal advice > - Any permitted research, security or journalistic exceptions, and how they are approved > - Mandatory reporting obligations that apply to us, and to which categories ## Things you may not do to the Platform - Circumvent rate limits, quotas or billing, including by spreading load across accounts created for that purpose. - Share, sell or publish API keys, or give access to anyone who has not agreed to these terms. - Probe, scan or test the security of the Platform without written authorisation. - Interfere with service for other customers, whether deliberately or through uncapped automation. - Misrepresent the Platform's involvement in your product, or imply an endorsement that does not exist. > **Not yet settled.** Commercial boundaries are not settled. > > - Whether reselling API access is permitted, and on what terms > - Whether outputs may be used to train or evaluate competing models > - Whether authorised security testing is offered, and how to request it ## If you build on top of the API Exposing the API to your own users does not transfer responsibility for what they do with it. Traffic on your key is your traffic. - Apply your own controls to what your users can submit and receive. - Make it clear to your users when they are interacting with AI-generated output. - Have a route for your users to report problems, and act on it. - Keep your own terms consistent with this policy. ## Model provider policies Each model is run by a provider with its own acceptable use rules. A request that satisfies this policy can still be refused by the provider serving it, and repeated refusals may be treated as a breach here. > **Not yet settled.** Pass-through of provider policies needs to be documented. > > - Where the current provider list and their policies are published > - How you are notified when a provider policy changes ## Reporting abuse If you believe the Platform is being used in breach of this policy, tell us. Include the request identifier where you have one. It is the fastest route to the activity in question. > **Not yet settled.** No reporting channel exists yet. > > - Abuse reporting address and any web form > - What information a report should contain > - Acknowledgement and response expectations ## Enforcement Where this policy is breached we may restrict, suspend or terminate access. The response depends on what happened. Some breaches warrant a conversation, others do not. > **Not yet settled.** The enforcement process is not defined. > > - Which breaches lead to immediate suspension without notice > - Whether warnings are issued, and how > - Appeal route and who decides > - Effect of enforcement on a remaining credit balance > - When law enforcement or a regulator is involved