# 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": "fal/veo3", "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 | | 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.