<!-- toc: Getting started -->
# Hyperouter API {#introduction}

*Version v1 · Updated October 25, 2026*

> These docs are available as Markdown for SI assistants. Start at [/llms.txt](https://hyperouter.app/llms.txt), or fetch [/llms-full.txt](https://hyperouter.app/llms-full.txt) for every page in one file. This page is [/docs.md](https://hyperouter.app/docs.md).

Hyperouter is one HTTP API in front of many model vendors. You send a request that names a model. Hyperouter picks a provider that can serve it, forwards the request, and charges your balance at that provider’s list price.

`BASE https://api.hyperouter.app/v1`

Every endpoint follows OpenAI’s request and response shapes. The OpenAI SDKs work when you change the base URL and the API key. Hyperouter adds a few fields to OpenAI’s bodies. They are marked as additions in this reference.

| Endpoint | Purpose | Auth |
| --- | --- | --- |
| `GET /v1/models` | List the models your account can call. | API key |
| `POST /v1/chat/completions` | Chat completion, with optional streaming. | API key |
| `POST /v1/images/generations` | Text to image. | API key |
| `POST /v1/images/edits` | Edit pictures. | API key |
| `POST /v1/videos` | Create a video from a prompt, with an optional picture. | API key |
| `POST /v1/videos/edits` | Edit or upscale a video. | API key |
| `POST /v1/videos/extensions` | Extend a video. | API key |
| `POST /v1/videos/{id}/remix` | Make a new version of a video with a new prompt. | API key |
| `GET /v1/videos`, `GET /v1/videos/{id}`, `GET /v1/videos/{id}/content`, `DELETE /v1/videos/{id}` | List, read, download and delete videos. | API key |
| `GET /v1/generations/{id}` | Read the record of any request: status, usage and charge. | API key |
| `GET /v1/credits` | Your balance, what open requests hold, and what is left to spend (`balance_usd`, `held_usd`, `available_usd`, decimal USD strings). | API key |

> [Sign in at dash.hyperouter.app](https://dash.hyperouter.app) with Google or GitHub to create an API key. `GET /v1/models` returns the models you can call at the time of the request.

## Quickstart

1. [Sign in](https://dash.hyperouter.app) and create an API key. Keys start with `sk-hr-v1-` and are shown once.
2. Store it in an environment variable, for example `HYPEROUTER_API_KEY`.
3. Add credits: open a top-up request in the dashboard. Staff credit your balance when the payment is confirmed.
4. Point any OpenAI SDK at the base URL above and pick a model.

```bash
curl https://api.hyperouter.app/v1/chat/completions \
  -H "Authorization: Bearer $HYPEROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "x-ai/grok-4.7",
    "messages": [{"role": "user", "content": "Hello"}]
  }'
```

```typescript
import OpenAI from "openai";

const client = new OpenAI({
  baseURL: "https://api.hyperouter.app/v1",
  apiKey: process.env.HYPEROUTER_API_KEY,
});

const res = await client.chat.completions.create({
  model: "x-ai/grok-4.7",
  messages: [{ role: "user", content: "Hello" }],
});
console.log(res.choices[0].message.content);
```

```python
import os
from openai import OpenAI

client = OpenAI(
    base_url="https://api.hyperouter.app/v1",
    api_key=os.environ["HYPEROUTER_API_KEY"],
)

res = client.chat.completions.create(
    model="x-ai/grok-4.7",
    messages=[{"role": "user", "content": "Hello"}],
)
print(res.choices[0].message.content)
```

## Authentication

Send your API key as a bearer token on every request.

```
Authorization: Bearer sk-hr-v1-...
```

Keys carry access to your balance. Keep them on your server. Never put them in browser or mobile code. Hyperouter stores only a hash of each key, so a lost key cannot be shown again: create a new one.

In the [dashboard](https://dash.hyperouter.app/keys) you can name each key, give it a credit limit, and disable it. A disabled key stops working at once. A request that would take a key past its limit is refused with `402 insufficient_credits`.

### Authentication errors

| Status | type | code | When | message |
| --- | --- | --- | --- | --- |
| `401` | `authentication_error` | `invalid_api_key` | The header is missing, is not `Bearer`, or the key does not start with `sk-hr-v1-`. | `The API key is missing or malformed. Send Authorization: Bearer sk-hr-v1-…` |
| `401` | `authentication_error` | `invalid_api_key` | The key is unknown or disabled. | `The API key is not valid.` |
| `403` | `permission_error` | `account_suspended` | The account is suspended. The check runs on every request. | `This account is suspended. Open a support ticket.` |
| `429` | `rate_limit_error` | `too_many_auth_failures` | More than 30 requests from your address in a minute used a missing or invalid key. Every `/v1` request from the address is refused for 15 minutes, a valid key included. `Retry-After` gives the seconds left. | `Too many failed API key checks from this address. Try again after the time in Retry-After.` |
| `500` | `server_error` | `internal` | Hyperouter could not check the key. | `An internal error occurred. Try again.` |

```bash
curl https://api.hyperouter.app/v1/generations/0194f2c4-7a1e-7b3c-9d02-5f6a1c0e8b21 \
  -H "Authorization: Bearer $HYPEROUTER_API_KEY"
```

```json
{ "error": { "message": "The API key is not valid.", "type": "authentication_error", "code": "invalid_api_key", "param": null } }
```

## Errors

Every error uses OpenAI’s error object and a matching HTTP status.

```json
{
  "error": {
    "message": "n must be an integer from 1 to 128",
    "type": "invalid_request_error",
    "code": "invalid_body",
    "param": "n"
  }
}
```

| Field | Type | Description |
| --- | --- | --- |
| `message` | string | A sentence that says what is wrong. Do not parse it. |
| `type` | string | The class of error. See the list below. |
| `code` | string | A stable identifier for the cause. Match on this field. |
| `param` | string \| null | The request field at fault, or `null`. |

### Types and statuses

| Status | type | Meaning |
| --- | --- | --- |
| `400` | `invalid_request_error` | The request is invalid. `code` names the cause and `param` the field. |
| `401` | `authentication_error` | The API key is missing or not valid. |
| `402` | `insufficient_credits` | Your balance, or the key’s limit, cannot cover the most the request could cost. |
| `403` | `permission_error` | The account is suspended. |
| `404` | `not_found` | No such id, the id belongs to another account, or a video has passed its `expires_at`. |
| `409` | `conflict` | The object is not in a state that allows the call (`video_not_finished`), or an `Idempotency-Key` conflict (see [Retries and idempotency](#idempotency)). |
| `422` | `invalid_request_error` | The request was valid but its answer was refused (`expansion_refused`). Not billed. |
| `429` | `rate_limit_error` | Every provider for the model is at capacity, your account is over its [rate limits](#rate-limits), or too many API key checks from your address failed. Retry after the time in the `Retry-After` header. |
| `500`, `501` | `server_error` | Hyperouter failed, or the endpoint is not implemented. Not billed. |
| `503` | `upstream_error` | A provider failed or did not answer in time: `code` says which (`all_venues_failed`, `upstream_error` or `timeout`). The header `X-Upstream-Status` carries `502` (failed) or `504` (timed out). |

Hyperouter never answers `502` or `504`: those would reach you as a bare page from our edge network, without the error body. Where a gateway would answer them, you get `503` with the body below and the gateway status in `X-Upstream-Status`.

### Codes you can get

| code | Status | When |
| --- | --- | --- |
| `invalid_body` | 400 | A body field or query parameter does not parse, has the wrong type, or is outside its allowed values. `param` names it. |
| `unsupported_content_type` | 400 | `/v1/images/generations` got a `multipart/form-data` body. Send JSON. `param` is `null`. |
| `request_too_large` | 413 | A `multipart/form-data` upload is over 50 MiB. Send large media as https URLs. |
| `unknown_model` | 400 | The model does not exist for this endpoint, or no provider can serve it with your `provider` preferences. |
| `unsupported_parameter` | 400 | A parameter that exists for other models is not available on this model. See [Model-specific parameters](#parameters). |
| `invalid_combination` | 400 | The vendor does not allow these parameters together, for example reference media with keyframes. |
| `invalid_size` | 400 | `size` is not `WIDTHxHEIGHT`, or is not allowed for the model. |
| `too_many_images` | 400 | `n` or the number of input pictures is over the limit. |
| `invalid_image`, `invalid_video` | 400 | A picture or video URL is not https to a public host: a `data:` URL, an `http://` URL, an IP address or an internal name. `param` names the field. |
| `image_required` | 400 | The model needs an input picture and you sent none (or it only edits, and you called generations). |
| `invalid_seconds` | 400 | `seconds` is not allowed for the model, or is missing for a video given by URL. |
| `unsupported_operation` | 400 | The model does not support the operation (edit, extend or remix, or picture editing). |
| `video_not_ready` | 400 | The source video has not completed. |
| `context_length_exceeded` | 400 | The chat prompt and `max_tokens` do not fit in the model’s context. |
| `content_rejected` | 400 | The vendor’s safety filter refused the input. |
| `vendor_rejected` | 400 | The model refused the request as invalid. Check its parameters and inputs. |
| `clip_too_long` | 400 | A reference clip plus the requested `seconds` run past the model’s limit (30 s). Not billed. |
| `prompt_rejected` | 400 | The prompt asks the prompt expansion model for its instructions. Not billed. See [Prompt expansion](#prompt-expansion-model). |
| `expansion_refused` | 422 | The prompt expansion model's rewrite was refused or came back empty. Not billed. See [Prompt expansion](#prompt-expansion-model). |
| `invalid_api_key` | 401 | See [Authentication](#authentication). |
| `insufficient_credits` | 402 | See [Billing & limits](#billing). |
| `account_suspended` | 403 | The account is suspended. |
| `moderation_not_enabled` | 403 | `moderation: "low"` is not enabled for the account. See [Moderation](#moderation). |
| `feature_not_enabled` | 403 | `prompt_expand` is not enabled for the account, on a model that offers it. Open a support ticket to request access. Nothing is held or sent to the provider. See [Prompt expansion](#prompt-expansion). |
| `model_not_enabled` | 403 | The model needs access granted to your account. Open a support ticket to request it. Nothing is held or sent to the provider. |
| `not_found` | 404 | No such object. |
| `video_not_finished` | 409 | You deleted a video that is still being made. |
| `venue_busy` | 429 | Every provider for the chat model is over its rate budget. |
| `rate_limited` | 429 | Your account is over its requests per minute or its concurrent requests. See [Rate limits](#rate-limits). |
| `too_many_auth_failures` | 429 | More than 30 requests from your address in a minute used an invalid API key. See [Rate limits](#rate-limits). |
| `all_venues_failed` | 503 | No provider is healthy, or all of them failed. |
| `upstream_error` | 503 | An image or video job failed at the model. You are not charged. |
| `internal`, `server_error`, `no_price` | 500 | Hyperouter failed (`no_price`: the model cannot be priced right now). Not billed. |
| `not_implemented` | 501 | `This endpoint is not implemented yet.` |
| `invalid_idempotency_key` | 400 | The `Idempotency-Key` header is longer than 255 characters or is not printable ASCII. See [Retries and idempotency](#idempotency). |
| `idempotency_key_reused` | 409 | The `Idempotency-Key` was already used with a different request. |
| `idempotency_key_in_use` | 409 | A request with this `Idempotency-Key` is still being started. Retry with the same key. |
| `idempotency_key_replayed` | 409 | A chat completion with this `Idempotency-Key` already ran. `error.generation_id` is its id. |
| `timeout` | 503 | A sync image was not ready within 90 seconds. It is still being made: `error.generation_id` is the id to poll. |

## Retries and idempotency {#idempotency}

A network error or a timeout does not tell you whether Hyperouter received the request. If you send it again, you may start a second job and pay twice. An `Idempotency-Key` makes a repeat safe: Hyperouter recognises the repeat, starts no second job, places no second hold, and answers from the first request.

**Send one on every request that costs money.** Generate a new random value, such as a UUID v4, for each new request. Keep it with the request, and send the same value every time you send that request again.

### Sending a key

The header is accepted on these endpoints:

| Endpoint | A repeat returns |
| --- | --- |
| `POST /v1/images/generations`, `POST /v1/images/edits` | the first request's image, or its `202` with the same `id` under `Prefer: respond-async` |
| `POST /v1/videos`, `/v1/videos/edits`, `/v1/videos/extensions`, `/v1/videos/{id}/remix` | the first request's Video object, as it is now |
| `POST /v1/chat/completions` | `409` `idempotency_key_replayed` with the first request's id (see [Chat completions](#idempotency-chat)) |

The OpenAI SDKs do not send this header on their own. Pass it as an extra header; the SDKs then re-send it unchanged on their automatic retries.

```bash
curl https://api.hyperouter.app/v1/images/generations \
  -H "Authorization: Bearer $HYPEROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7" \
  -d '{"model": "x-ai/grok-imagine-image", "prompt": "A lighthouse at dusk", "n": 1}'
```

```python
import uuid

key = str(uuid.uuid4())  # one per request; reuse it when you retry this request
img = client.images.generate(
    model="x-ai/grok-imagine-image",
    prompt="A lighthouse at dusk",
    extra_headers={"Idempotency-Key": key},
)
```

```typescript
import { randomUUID } from "node:crypto";

const key = randomUUID(); // one per request; reuse it when you retry this request
const img = await client.images.generate(
  { model: "x-ai/grok-imagine-image", prompt: "A lighthouse at dusk" },
  { headers: { "Idempotency-Key": key } },
);
```

### What a repeat returns

| You send | Hyperouter answers |
| --- | --- |
| A new key | Runs the request as usual. |
| The same key and the same request, after the first was accepted | The first request's result as in the table above. No second job, no second hold, no second charge. |
| The same key and the same request, while the first is still being accepted | Waits for the first, then answers as above. If the first has not started its job within 60 seconds: `409` `idempotency_key_in_use`. Retry with the same key. |
| The same key and a different request | `409` `idempotency_key_reused`. Nothing runs. Use a new key. |
| The same key, after the first was refused before any job started (a `400`, a `402`, or a `5xx` sent without `x-should-retry: false`) | Runs the request as new, so you can correct it and send it again with the same key. |
| The same key, more than 30 minutes after the first request | Runs the request as new. |
| A key longer than 255 characters, or not printable ASCII | `400` `invalid_idempotency_key`. Nothing runs. |

### Rules

- **Scope.** A key belongs to the API key that sent it. Two API keys can use the same value without affecting each other.
- **Window.** A key is honoured for 30 minutes after the first request. Retry within that time.
- **Same request.** Two requests are the same when the method, the path and the body match. JSON is compared after parsing, so field order and whitespace do not matter. A picture or video is a URL in the body, so the URL is compared, never the bytes it serves.
- **Billing.** A request made under a key is billed at most once, whatever the number of repeats. A repeat answered from the first request costs nothing.
- **Guarantee.** One key never starts more than one job, including when two repeats arrive at the same time.

### Chat completions {#idempotency-chat}

Chat replies are not replayed. A repeat with the same key answers `409` with code `idempotency_key_replayed`, and `error.generation_id` is the first request's id. The request is not run or billed again. Look the first request up with [GET /v1/generations/{id}](#generations) to see its status, usage and cost. If the first chat completion failed and was not billed, its key is freed, and a repeat runs as a new request.

```json
{
  "error": {
    "message": "A request with this Idempotency-Key already ran as generation 019a1f3c-5b7e-7d21-9c4a-2e8f6b0d3a17. Chat replies are not replayed: GET /v1/generations/019a1f3c-5b7e-7d21-9c4a-2e8f6b0d3a17 for its record.",
    "type": "conflict",
    "code": "idempotency_key_replayed",
    "param": null,
    "generation_id": "019a1f3c-5b7e-7d21-9c4a-2e8f6b0d3a17"
  }
}
```

### Automatic retries in the OpenAI SDKs

The OpenAI SDKs retry a `408`, `409`, `429` or `5xx` answer twice by default, unless the answer carries the response header `x-should-retry: false`. Hyperouter sends `x-should-retry: false` on every answer where a retry would start a second job or a second charge:

- the `503` `timeout` of a sync image, whose job goes on and is billed when it succeeds; poll [GET /v1/generations/{id}](#generations) with its `generation_id`;
- a `500` after the job was created, for example when a finished image cannot be read back;
- `409` `idempotency_key_reused` and `idempotency_key_replayed`, which a retry cannot change.

It never sends it on a `429`, on `409` `idempotency_key_in_use`, or on a `5xx` from before any job exists. Those are safe to retry. With an `Idempotency-Key`, every retry is safe.

<!-- toc: API reference -->
## Models

`GET /v1/models`

Lists every model your account can call right now, with its prices and what it supports. A model with no enabled provider is not listed. Some models need access granted to your account: they are listed once it is. This endpoint needs an API key, and lists the models that key's account can call. The answer can be cached for 60 seconds, by your client only.

### Parameters

None.

### Response

```json
{
  "data": [{
    "id": "x-ai/grok-4.7",
    "name": "Grok 4.7",
    "kind": "chat",
    "context_length": 500000,
    "architecture": { "input_modalities": ["text", "image"], "output_modalities": ["text"] },
    "pricing": {
      "prompt": "0.000002", "completion": "0.000006", "input_cache_read": "0.0000005",
      "request": "0", "image": "0", "video_second": "0"
    },
    "top_provider": { "context_length": 500000, "max_completion_tokens": 32768 },
    "response_modes": ["sync", "stream"],
    "supported_parameters": ["max_tokens", "temperature", "top_p", "stop", "stream", "tools", "tool_choice", "response_format", "seed", "frequency_penalty", "presence_penalty"],
    "extra_parameters": {}
  }]
}
```

| Field | Type | Description |
| --- | --- | --- |
| `id` | string | The model id, `<provider>/<name>`, for example `x-ai/grok-4.7` or `qwen/qwen-image-2.0`. Send it as `model`. A renamed model keeps answering to its old id. |
| `response_modes` | string[] | How the model answers: `sync` (one held answer), `async` (send `Prefer: respond-async`, get `202`, and poll) and `stream` (`"stream": true`). A model that lists only `async` needs the `Prefer` header, and `stream` works only where it is listed. Video is always asynchronous through the Videos API, with or without the header. |
| `name` | string | Display name. |
| `kind` | string | `chat`, `image` or `video`. |
| `context_length` | integer | Context window in tokens. `0` for image and video models. |
| `architecture` | object | `input_modalities` and `output_modalities`. Output is `["text"]`, `["image"]` or `["video"]`. |
| `pricing` | object of strings | USD per unit, as decimal strings, at the base tier: `prompt` and `completion` (per token), `input_cache_read` (per cached token), `request`, `image` (per image), `video_second` (per second of video). A video priced by resolution shows the lowest price. A unit that does not apply is `"0"`. |
| `top_provider` | object | `context_length` and `max_completion_tokens`. |
| `supported_parameters` | string[] | For chat, the OpenAI parameters the model takes. For image and video, the shared features it takes (`resolution`, `ratio`, `audio`, `seed`, `negative_prompt`, `first_frame`, `last_frame`, `reference_images`, `reference_videos`, `prompt_extend`). |
| `operations` | string[] | Video models only: `edit`, `extend` and `remix`, for each one the model supports. Absent when the model supports none. |
| `extra_parameters` | object | The model-specific top-level fields, by name. Each has `type`, `values` (allowed values; empty means any), `price_condition` (the field changes the price) and `description`. See [Model-specific parameters](#parameters). |

```bash
curl https://api.hyperouter.app/v1/models \
  -H "Authorization: Bearer $HYPEROUTER_API_KEY"
```

### Errors

None specific to this endpoint.

### Chat models

Prices per 1M tokens: input / cached input / output.

<!-- BEGIN GENERATED models-chat: sitemd writes the rows from models.json; edit the header row only. Run cd tools && go run ./sitemd -->
| Model | Context | Price |
| --- | --- | --- |
| `x-ai/grok-4.7` | 500K | $2.00 / $0.50 / $6.00 |
| `x-ai/grok-4.6` | 500K | $2.00 / $0.50 / $6.00 |
| `x-ai/grok-4.5` | 500K | $2.00 / $0.30 / $6.00 |
| `x-ai/grok-4.3` | 1M | $1.25 / $0.20 / $2.50 |
| `x-ai/grok-build-0.1` | 256K | $1.00 / $0.20 / $2.00 |
| `deepseek/deepseek-v4.1-flash` | 1M | $0.15 / $0.003 / $0.60 |
| `deepseek/deepseek-v4.1-flash-1017` | 1M | $0.15 / $0.003 / $0.60 |
| `openai/gpt-6-astra` | 272K | $10.00 / $1.00 / $50.00 |
| `openai/gpt-6.1-sol` | 272K | $2.00 / $0.10 / $10.00 |
| `z-ai/glm-5.3` | 1M | $1.40 / $0.26 / $4.40 |
| `lumina/face-detect` | 4K | $0.0013 per request |
<!-- END GENERATED models-chat -->

On the `x-ai/` models, a request with 200,000 prompt tokens or more is billed entirely at the long-context rates, which are twice these. The `deepseek/` models have one rate at every prompt length, reason before they answer by default (`reasoning_effort: "none"` turns it off), and count their reasoning in `max_tokens`, at most 384,000. `z-ai/glm-5.3` always reasons: `reasoning_effort` is `low`, `high` or `max`, and `none` is refused. The `openai/` models reason too and take prompts of at most 272,000 tokens.

## Chat completions

`POST /v1/chat/completions`

Creates a model response for a conversation. The body is OpenAI’s Chat Completions request. Hyperouter adds `models`, `provider` and `moderation`.

### Parameters

| Name | Type | Required | Allowed values | Default | Description |
| --- | --- | --- | --- | --- | --- |
| `model` | string | Yes | A chat model id from `GET /v1/models` |  | The model to use, for example `x-ai/grok-4.7`. |
| `messages` | object[] | Yes | Non-empty. Roles `system`, `user`, `assistant`, `tool` |  | The conversation so far. Pictures (`image_url` parts) are accepted where the model takes images. |
| `models` | string[] | No | At most 5 model ids |  | Addition. Fallback models, tried in order after every provider of `model` fails. |
| `provider` | object | No | See below |  | Addition. Routing preferences. |
| `moderation` | string | No | `auto`, `low` | `auto` | Addition. See [Moderation](#moderation). |
| `stream` | boolean | No |  | `false` | Return server-sent events. |
| `stream_options` | object | No | `{"include_usage": true}` |  | With `include_usage`, the stream ends with a chunk that carries `usage`. |
| `max_tokens`, `max_completion_tokens` | integer | No | 1 or more | The model’s maximum output | Upper bound on generated tokens. It also sets the most a request can cost, which Hyperouter holds before the call (see [Billing](#billing)). |
| `n` | integer | No | 1 to 128 | `1` | Number of choices. |
| `temperature`, `top_p`, `seed`, `frequency_penalty`, `presence_penalty`, `logit_bias` | number / integer / object | No | As OpenAI |  | Sampling controls, passed to the provider. |
| `stop` | string \| string[] | No |  |  | Sequences that end generation. |
| `tools`, `tool_choice`, `parallel_tool_calls` | object[], string \| object, boolean | No | OpenAI function-calling format |  | Function calling. Only tools of type `function`: any other type is refused with `400 unsupported_parameter`. |
| `response_format` | object | No | `{"type":"json_object"}` or a JSON schema |  | Structured output. |
| `reasoning_effort`, `logprobs`, `top_logprobs`, `user` | as OpenAI | No |  |  | Passed to the provider where the model takes them. |

Other fields are ignored. `supported_parameters` of each model in [GET /v1/models](#models) lists exactly what it takes. Grok 4.20 and newer ignore `logprobs` and `top_logprobs`, so they are not listed.

### The provider object {#routing}

By default Hyperouter sends each chat request to the healthy provider with the lowest price for that model, and breaks ties by the lowest recent time to first token. A provider that errors repeatedly is skipped until it recovers. The `provider` object follows OpenRouter’s request shape.

| Name | Type | Required | Allowed values | Default | Description |
| --- | --- | --- | --- | --- | --- |
| `sort` | string | No | `price`, `latency`, `throughput` | `price` | How to rank the providers. |
| `order` | string[] | No | Provider names |  | Providers to try first, in order, for example `["xai"]`. A provider name is its public name in any case (`xAI`, `Qwen`) or the first part of its model ids (`x-ai`, `qwen`). |
| `only` | string[] | No | Provider names |  | Route only to these providers. |
| `ignore` | string[] | No | Provider names |  | Never route to these providers. |

### Response

OpenAI’s chat completion. The `id` is the request id: look it up with [GET /v1/generations/{id}](#generations). Hyperouter adds `provider` (the public name of the provider that filled the request, for example `xAI`) and `usage.cost` (the charge, in USD, as a JSON number).

```json
{
  "id": "0194f2c4-7a1e-7b3c-9d02-5f6a1c0e8b21",
  "object": "chat.completion",
  "model": "x-ai/grok-4.7",
  "provider": "xAI",
  "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,
    "prompt_tokens_details": { "cached_tokens": 0 },
    "cost": 0.000066
  }
}
```

A prompt-rewrite model answers with one choice, a `usage` of zero tokens and a fixed `cost` per request.

### Streaming {#streaming}

With `"stream": true` the response is a stream of server-sent events in the OpenAI chunk format. It ends with `data: [DONE]`. When you set `stream_options.include_usage`, the last chunk before `[DONE]` has an empty `choices` array and a `usage` object that includes `cost`, and a `provider` field. Usage is always collected for billing, whether or not you ask for the chunk.

Until the first token arrives, the stream sends the comment line `: processing` every 15 seconds to keep the connection open. SSE clients ignore comments.

Fallback to another provider happens only before the first token is sent. If a provider fails mid-stream, the stream ends with `data: {"error": {...}}` followed by `data: [DONE]`, and you are billed for the tokens already produced.

You are billed for the tokens the provider produces, whether or not you read them. A stream you abort, or a reply you stop waiting for, is not stopped at the provider, which goes on and bills its whole answer: Hyperouter reads it to its end (for at most 5 more minutes) and bills that.

A provider that has accepted a request may be running it. If it then stops answering (5 minutes with no token on a stream, or longer than a reply of `max_tokens` takes) or its answer breaks off before the first token, the request is not sent to another provider: it answers `503` with code `timeout` or `upstream_error` with `error.generation_id`, and is billed on its hold, with `usage_estimated: true` on its record.

### Example

```bash
curl https://api.hyperouter.app/v1/chat/completions \
  -H "Authorization: Bearer $HYPEROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "x-ai/grok-4.7",
    "models": ["x-ai/grok-4.6"],
    "provider": { "sort": "latency" },
    "messages": [{ "role": "user", "content": "Hello" }]
  }'
```

### Errors

| Status | type | code | param | When |
| --- | --- | --- | --- | --- |
| `400` | `invalid_request_error` | `invalid_body` | the field | The body is not valid JSON or is over 32 MiB; `model` is missing; `models` has more than 5 entries; `n` is outside 1 to 128; `max_tokens` is not positive; `provider.sort` is not allowed; `moderation` is not `auto` or `low` (`moderation must be auto or low.`); `messages` is empty. Also when every provider refused the request as invalid. |
| `400` | `invalid_request_error` | `unknown_model` | `model` or `models` | The model is not a chat model, or it has no enabled provider. |
| `400` | `invalid_request_error` | `unsupported_parameter` | `tools`, `response_format`, `messages` or `max_tokens` | The model does not take tools, a response format, or pictures; a tool is not of type `function`; or no provider of the model can bound its output. |
| `400` | `invalid_request_error` | `context_length_exceeded` | `messages` | The prompt and `max_tokens` exceed the model’s context. |
| `402` | `insufficient_credits` | `insufficient_credits` | `max_tokens` | The balance or key limit cannot cover the most the request could cost. The message says how many tokens you can afford. |
| `429` | `rate_limit_error` | `venue_busy` |  | Every provider for the model is over its rate budget. |
| `429` | `rate_limit_error` | `rate_limited` |  | Your account is over its requests per minute or concurrent requests (see [Rate limits](#rate-limits)). Nothing is held or forwarded. |
| `409` | `conflict` | `idempotency_key_replayed`, `idempotency_key_reused`, `idempotency_key_in_use` |  | See [Retries and idempotency](#idempotency). |
| `503` | `upstream_error` | `all_venues_failed` |  | No provider is healthy, or all of them failed. Not billed. |
| `503` | `upstream_error` | `upstream_error` |  | The provider accepted the request and its answer broke off before the first token. Billed on the hold (see [Streaming](#streaming)). |
| `503` | `upstream_error` | `timeout` |  | The provider accepted the request and did not finish it in time. Billed on the hold (see [Streaming](#streaming)). |
| `401`, `403`, `500` |  |  |  | See [Authentication](#authentication). |

## Images

`POST /v1/images/generations`

`POST /v1/images/edits`

Both follow OpenAI’s Images API and take a JSON body. `/v1/images/edits` takes OpenAI’s JSON form: the pictures are `images`, 1 to 5 https URLs. The OpenAI SDKs' `images.edit` sends picture files as `multipart/form-data`, and that works too: Hyperouter's edge stores each file (PNG, JPEG or WebP, 20 MiB each, 50 MiB a request) for 3 days at an unguessable `https://api.hyperouter.app/u/...` URL that the provider fetches, then runs the request as JSON. A video file is refused (`400 invalid_video`): send a video as an https URL. Both take the shared features below and any [model-specific parameters](#parameters) as top-level fields.

### Parameters

| Name | Type | Required | Allowed values | Default | Description |
| --- | --- | --- | --- | --- | --- |
| `model` | string | Yes | An image model id |  | The model to use. |
| `prompt` | string | Generations: yes. Edits: as the model needs | Non-blank text |  | What to make. |
| `images` | object[] | Edits: yes | 1 to 5 objects `{"image_url": "https://..."}`, as many as the model takes. Each URL is https to a public host, never a `data:` URL, an IP address or an internal name. `file_id` is not accepted. |  | The pictures to edit. Each URL is passed to the provider as it is, so the provider must be able to fetch it; one it cannot fetch or read fails the job with `vendor_rejected`, and nothing is charged. A model that takes them in a set order says so in its notes in the model reference. |
| `n` | integer | No | 1 to 10, and at most the model’s limit | `1` | Number of images. |
| `size` | string | No | `WIDTHxHEIGHT`, for example `1024x1024`; the model may allow fewer sizes | `1024x1024` | Output size. |
| `quality` | string | No | As the model allows |  | Passed to the model. |
| `response_format` | string | No | `url` | `url` | How the image comes back: always a URL. `b64_json` is refused with `400 unsupported_parameter`. |
| `seed` | integer | No | If the model lists `seed` |  | Seed for repeatable output. |
| `negative_prompt` | string | No | If the model lists it |  | What to avoid. |
| `prompt_extend` | boolean | No | If the model lists it | Off unless you ask | Let the model expand the prompt. |
| `prompt_expand` | boolean | No | If the model lists it | `false` | Addition. Hyperouter rewrites the prompt for the model before it renders. See [Prompt expansion](#prompt-expansion). |
| `prompt_expand_model` | string | No | A chat model slug your account can call | `x-ai/grok-4.6` | Addition. With `prompt_expand`: the model that writes the rewrite. Without `prompt_expand: true` it is `400 invalid_body`. See [Prompt expansion](#prompt-expansion). |
| `locale` | string | No | A BCP 47 tag, for example `zh-CN` | English | Addition. With `prompt_expand`: the language spoken lines are written in, where the model speaks it. |
| `ratio` | string | No | If the model lists it |  | Aspect ratio. |
| `resolution` | string | No | `480p`, `720p`, `1080p`, `2K`, `4K`, as the model allows |  | Output resolution. |
| `models` | string[] | No | Image model ids |  | Addition. Fallback models. |
| `provider` | object | No | `only`, `ignore`, `order` (arrays of provider names) |  | Addition. Routing preferences. |
| `moderation` | string | No | `auto`, `low` | `auto` | Addition. See [Moderation](#moderation). |
| model-specific fields | as declared | As declared | As declared |  | See [Model-specific parameters](#parameters). |

A shared feature that the model does not list, such as `seed` on a model without it, is refused with `400 unsupported_parameter`. It is never dropped silently, because it would change the output and the price.

### Response

```json
{
  "created": 1730000000,
  "data": [{ "url": "https://..." }],
  "usage": { "cost": 0.02 },
  "id": "0194f2c4-7a1e-7b3c-9d02-5f6a1c0e8b21"
}
```

| Field | Type | Description |
| --- | --- | --- |
| `created` | integer | Unix time of the request. |
| `data[].url` | string | With `response_format: "url"`: a signed link, valid for 24 hours. `GET /v1/generations/{id}` signs a fresh one. |
| `usage.cost` | number | The charge in USD, as a JSON number. |
| `id` | string | The request id. Use it with [GET /v1/generations/{id}](#generations). |
| `expanded_prompt` | string \| null | Addition. With `prompt_expand`: the prompt that rendered. `null` when nothing was expanded. |

Hyperouter stores every result for 30 days, so a link never points at a vendor. After 30 days the record stays but its `outputs` is empty.

### Waiting for the result

- On a model whose `response_modes` include `sync`, the request waits for the image for up to 90 seconds and answers with its real status: `200` with the images, or the error. Validation, balance and capacity errors come first, with their own status.
- An image that is not ready after 90 seconds answers `503` with code `timeout` and the response header `x-should-retry: false`, so the OpenAI SDKs do not retry it and start a second image. The job goes on, and the error carries its id as `generation_id`: poll [GET /v1/generations/{id}](#generations) for the result. You are billed only if it succeeds. To retry safely yourself, send an [Idempotency-Key](#idempotency): a retry with the same key waits on the same job.
- A model whose `response_modes` are only `async` needs `Prefer: respond-async`. Without it the answer is `400` with code `async_required`.
- To avoid a held connection on any model that lists `async`, send `Prefer: respond-async` (RFC 7240). The header may repeat and may carry other preferences, for example `Prefer: wait=10, respond-async`. You get `202` at once with the response header `Preference-Applied: respond-async`:

  ```json
  { "id": "0194f2c4-7a1e-7b3c-9d02-5f6a1c0e8b21", "status": "queued" }
  ```

  Poll [GET /v1/generations/{id}](#generations), or receive an `image.completed` or `image.failed` [webhook](#webhooks). Webhooks fire for images only for jobs you sent with this header.

### Examples

```bash
curl https://api.hyperouter.app/v1/images/generations \
  -H "Authorization: Bearer $HYPEROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model": "x-ai/grok-imagine-image", "prompt": "A lighthouse at dusk", "n": 1}'
```

```bash
curl https://api.hyperouter.app/v1/images/edits \
  -H "Authorization: Bearer $HYPEROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model": "x-ai/grok-imagine-image", "prompt": "make it night",
       "images": [{"image_url": "https://example.com/photo.png"}]}'
```

### Errors

| Status | code | param | When |
| --- | --- | --- | --- |
| `400` | `unsupported_content_type` |  | The body is `multipart/form-data`: `Send a JSON body; pictures and videos are https URLs.` |
| `400` | `invalid_body` | the field | The body is not valid JSON; `images` is not an array of objects with `image_url`; `prompt` is missing on generations; `n` is below 1; `response_format` is not `url` (`response_format must be url.`; `b64_json` is `unsupported_parameter`); `resolution` is not allowed; a typed field has the wrong type; `moderation` is not `auto` or `low`. |
| `400` | `unknown_model` | `model` | The model does not exist for images, or no provider matches your `provider` preferences. |
| `400` | `invalid_size` | `size` | `size` does not look like `1024x1024`, or the model does not allow it. |
| `400` | `too_many_images` | `n` or `images` | `n` is over 10 or over the model’s limit; more than 5 pictures; more pictures than the model takes. |
| `400` | `image_required` | `images` | An edit has fewer pictures than the model needs, or you called generations on a model that only edits (use `/v1/images/edits`). |
| `400` | `unsupported_operation` | `images` | The model does not edit pictures. |
| `400` | `unsupported_parameter` | the field | The model does not support the field; `images[i].file_id`. |
| `400` | `invalid_combination` | the field | The vendor does not allow these fields together. |
| `400` | `async_required` |  | The model answers asynchronously only: send `Prefer: respond-async`. |
| `400` | `unsupported_parameter` | `stream` | `"stream": true` on a model whose `response_modes` do not include `stream`. |
| `400` | `invalid_image` | `images[i].image_url` | The URL is not https to a public host: `images[i].image_url must be an https URL.` for a `data:` or `http://` URL. |
| `400` | `content_rejected` |  | The vendor’s safety filter refused the input: `The request was rejected by a content safety filter.` |
| `400` | `vendor_rejected` |  | `The request was refused by the model. Check its parameters and inputs.` |
| `400` | `invalid_idempotency_key` |  | See [Retries and idempotency](#idempotency). |
| `402` | `insufficient_credits` |  | The balance or key limit cannot cover the most the request could cost. |
| `409` | `idempotency_key_reused`, `idempotency_key_in_use` |  | See [Retries and idempotency](#idempotency). |
| `429` | `rate_limit_error` |  | Every provider is at capacity (no `code`), or your account is over its requests per minute or concurrent requests (`code: rate_limited`, see [Rate limits](#rate-limits)). |
| `503` | `upstream_error` |  | `The model failed to produce a result. You were not charged.` |
| `503` | `provider_busy` |  | The provider stayed at capacity for about two minutes and did not take the job. Retry later. You were not charged. |
| `500` | `server_error`, `no_price` |  | Hyperouter failed, or the model cannot be priced right now. A `500` after the image was made carries `x-should-retry: false`. |
| `503` | `timeout` |  | The image was not ready within 90 seconds. It is still being made: `error.generation_id` is the id to poll. Carries `x-should-retry: false`. |

A failed job’s message ends with the provider’s own reason when it gave one, after `Provider:`, so a refused prompt, a busy provider and an outage read apart. URLs in it are replaced by `<url>`.

### Image models

<!-- BEGIN GENERATED models-image: sitemd writes the ids and prices from models.json; write each model's Does cell. Run cd tools && go run ./sitemd -->
| Model | Does | Price |
| --- | --- | --- |
| `x-ai/grok-imagine-image` | Generate and edit. Up to 10 images, up to 5 input images. | $0.02 per image |
| `x-ai/grok-imagine-image-2.0` | Generate and edit, as above. | From $0.04 per image |
| `qwen/qwen-image-2.0` | Generate and edit (1 to 3 input images). `n` 1 to 6. | $0.035 per image |
| `bytedance/seedream-5.0-flash` | Generate and edit (1 to 10 input images). One 2K image per request, no watermark. | $0.018 per image |
| `bytedance/seedream-5.0-lite` | Generate and edit, as above. | $0.035 per image |
<!-- END GENERATED models-image -->

Some models need access granted to your account. The [model reference](https://dash.hyperouter.app/docs/models) in the dashboard lists every model your account can call, with its parameters, prices and notes.

## Videos

Video follows OpenAI’s Videos API. A video is a job. A create, edit, extension or remix call answers `200` at once with the Video object in status `queued`. You poll `GET /v1/videos/{id}` until the status is `completed`, or receive a [webhook](#webhooks). `Prefer` changes nothing here: videos are always asynchronous.

| Endpoint | Does |
| --- | --- |
| `POST /v1/videos` | Create a video from a prompt, with an optional `input_reference`. |
| `POST /v1/videos/edits` | Edit or upscale a completed video. Only models that list `edit` in `operations`. |
| `POST /v1/videos/extensions` | Extend a completed video. Only models that list `extend`. |
| `POST /v1/videos/{id}/remix` | New prompt on a completed video, same model. Only models that list `remix`. |
| `GET /v1/videos` | List your videos. |
| `GET /v1/videos/{id}` | The Video object. |
| `GET /v1/videos/{id}/content` | The MP4 or the thumbnail. |
| `DELETE /v1/videos/{id}` | Delete a video. |

### Create a video

`POST /v1/videos`

The body is JSON, on every video endpoint; the OpenAI SDKs' `videos.create` with an `input_reference` picture file is also accepted, the file handled as for [image edits](#images). Every picture and video is an https URL to a public host, which is passed to the provider as it is: the provider must be able to fetch it, and one it cannot fetch or read fails the video with `vendor_rejected`, and nothing is charged. A `data:` URL, an `http://` URL, an IP address or an internal name is refused with `400 invalid_image` or `invalid_video`. A `multipart/form-data` body is refused with `400 unsupported_content_type`.

| Name | Type | Required | Allowed values | Default | Description |
| --- | --- | --- | --- | --- | --- |
| `model` | string | Yes | A video model id |  | The model to use. |
| `prompt` | string | Yes | Non-blank text |  | What to make. |
| `seconds` | string \| integer | No | As the model allows | The model’s first allowed value, else 4 s (or the model’s maximum if lower) | Length of the video. `"8"` and `8` are the same. |
| `size` | string | No | `WIDTHxHEIGHT` |  | The height picks the resolution unless you send `resolution`: up to 480 is `480p`, up to 720 is `720p`, up to 1080 is `1080p`, up to 1440 is `2K`, above is `4K`. The model must allow that resolution. |
| `input_reference` | object | If the model needs a picture | `{"image_url": "https://..."}`, an https URL |  | The first picture of the video. `file_id` is not accepted. |
| `resolution` | string | No | `480p`, `720p`, `1080p`, `2K`, `4K`, as the model allows | The model’s first resolution | Output resolution. It changes the price. |
| `ratio` | string | No | If the model lists it |  | Aspect ratio. |
| `audio` | boolean | No | If the model makes sound | `false` | Make sound. |
| `seed` | integer | No | If the model lists it |  | Seed for repeatable output. |
| `negative_prompt` | string | No | If the model lists it |  | What to avoid. |
| `prompt_extend` | boolean | No | If the model lists it | Off unless you ask | Let the model expand the prompt. |
| `prompt_expand`, `locale` | boolean, string | No | As for [images](#images) | `false` | Addition. See [Prompt expansion](#prompt-expansion). |
| `prompt_expand_model` | string | No | As for [images](#images) | `x-ai/grok-4.6` | Addition. With `prompt_expand`: the model that writes the rewrite. See [Prompt expansion](#prompt-expansion). |
| `first_frame`, `last_frame` | string | No | An https URL of a picture |  | Keyframes. |
| `reference_images`, `reference_videos` | string[] | No | https URLs |  | Reference media. They cannot be combined with keyframes, as the vendor sets. |
| `models`, `provider` | string[], object | No | As for [images](#images) |  | Addition. Fallback models and routing preferences. |
| `moderation` | string | No | `auto`, `low` | `auto` | Addition. See [Moderation](#moderation). |
| model-specific fields | as declared | As declared | As declared |  | See [Model-specific parameters](#parameters). |

### Edit, extend and remix

| Endpoint | Body (JSON) |
| --- | --- |
| `POST /v1/videos/edits` | `model`, `video` (required), `prompt`, `resolution` and the shared features. `video` is `{"id": "<your video id>"}` (a completed video of yours) or `{"url": "https://..."}`. `model` defaults to the source video’s model when you send an id. |
| `POST /v1/videos/extensions` | `model`, `video` (required), `prompt`, `seconds`. The new seconds follow the same rules as a create. |
| `POST /v1/videos/{id}/remix` | `prompt` (required). The model is the source video’s. |

The source video must have completed. A video given by URL is an https URL, passed to the provider as it is, like a picture. If you give a video by URL, `seconds` is needed on models that have no fixed maximum.

### List, read, download, delete

| Endpoint | Parameters | Answer |
| --- | --- | --- |
| `GET /v1/videos` | Query: `after` (a video id), `limit` (1 to 100, default 20), `order` (`desc` default, or `asc`) | `{"object":"list","data":[Video],"first_id","last_id","has_more"}`. Videos past their `expires_at` are left out. |
| `GET /v1/videos/{id}` |  | The Video object. |
| `GET /v1/videos/{id}/content` | Query: `variant` (`video` default, or `thumbnail`) | `302 Found` to a signed link to the bytes (`video/mp4`, or the thumbnail’s type), valid for 15 minutes. HTTP clients and the OpenAI SDKs follow it. `404 not_found` before completion or when there is no thumbnail. |
| `DELETE /v1/videos/{id}` |  | `{"id","object":"video.deleted","deleted":true}`. Answers `409 video_not_finished` while the job runs. A deleted video answers `404`. |

### The Video object

```json
{
  "id": "0194f2c4-7a1e-7b3c-9d02-5f6a1c0e8b21",
  "object": "video",
  "model": "x-ai/grok-imagine-video",
  "status": "completed",
  "progress": 100,
  "prompt": null,
  "seconds": "8",
  "size": "",
  "created_at": 1730000000,
  "completed_at": 1730000137,
  "expires_at": 1732592000,
  "error": null,
  "remixed_from_video_id": null,
  "usage": { "cost": 0.4, "seconds": 8 }
}
```

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `queued`, `in_progress`, `completed` or `failed`. |
| `progress` | integer | 0 to 100. |
| `prompt` | string \| null | The prompt is returned only while the job runs. The finished object returns `null`. |
| `seconds` | string | Length of the video, as a string, as OpenAI’s. |
| `size` | string | The `size` you sent, if any. |
| `expires_at` | integer | Unix time: `created_at` plus 30 days. After it, `GET /v1/videos/{id}` and `/content` answer `404 not_found` and the video is left out of the list. Download what you want to keep before then. |
| `error` | object \| null | On a failed job: `{"code", "message"}`, for example `content_rejected`. |
| `remixed_from_video_id` | string \| null | The source video of an edit, extension or remix. |
| `usage` | object \| null | Set once the job is settled: `cost` (USD, a JSON number) and `seconds` (integer). |
| `expanded_prompt` | string \| null | Addition. With `prompt_expand`: the prompt that rendered, once the job is submitted. Kept 30 days, then `null`. |

A video is billed on the seconds you request. The amount is held up front, and released in full if the job fails.

### Example: x-ai/grok-imagine-video

Model `x-ai/grok-imagine-video` makes a video from a prompt, with an optional picture to start from. `seconds` is 1 to 15. Price: $0.050 per second.

```bash
curl https://api.hyperouter.app/v1/videos \
  -H "Authorization: Bearer $HYPEROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "x-ai/grok-imagine-video",
    "prompt": "She turns to the camera and smiles",
    "input_reference": { "image_url": "https://example.com/portrait.png" },
    "seconds": 8
  }'
```

```json
{
  "id": "0194f2c4-7a1e-7b3c-9d02-5f6a1c0e8b21",
  "object": "video",
  "model": "x-ai/grok-imagine-video",
  "status": "queued",
  "progress": 0,
  "prompt": "She turns to the camera and smiles",
  "seconds": "8",
  "size": "",
  "created_at": 1730000000,
  "completed_at": null,
  "expires_at": 1732592000,
  "error": null,
  "remixed_from_video_id": null,
  "usage": null
}
```

This job is charged 8 × $0.050 = $0.40, so the settled object shows `"usage": {"cost": 0.4, "seconds": 8}`.

### Errors

| Status | code | param | When |
| --- | --- | --- | --- |
| `400` | `unsupported_content_type` |  | The body is `multipart/form-data`: `Send a JSON body; pictures and videos are https URLs.` |
| `400` | `invalid_body` | the field | The body is not valid JSON; `prompt` is blank; `video` is not an object with `id` or `url`; `input_reference` is not an object with `image_url`; `limit`, `after` or `variant` is not valid; a typed field has the wrong type; `resolution` is not allowed; `moderation` is not `auto` or `low`. |
| `400` | `unknown_model` | `model` | The model does not exist for videos. |
| `400` | `invalid_seconds` | `seconds` | `seconds` is not allowed for the model (the message lists the allowed values), or is needed for a video given by URL. |
| `400` | `invalid_size` | `size` | `size` does not look like `1280x720`. |
| `400` | `unsupported_parameter` | the field | The model does not support the field; the resolution is not available (`resolution` or `size`); `audio` on a model without sound; `input_reference.file_id`. |
| `400` | `unsupported_operation` | `model` | The model does not list the operation (edit, extend or remix). |
| `400` | `invalid_combination` | the field | Reference media together with keyframes, as the vendor sets. |
| `400` | `image_required` | `input_reference` | `This model needs an input_reference picture.` |
| `400` | `video_not_ready` | `video` | The source video has not completed. |
| `400` | `invalid_image`, `invalid_video` | the field | A picture URL (`invalid_image`) or video URL (`invalid_video`: `reference_videos[i]`, `video.url`) is not https to a public host, for example a `data:` or `http://` URL: `<field> must be an https URL.` `param` is the field, for example `input_reference.image_url`, `first_frame` or `reference_images[1]`. A URL is passed to the provider as it is: one the provider cannot fetch or read fails the video with `vendor_rejected`, and nothing is charged. |
| `400` | `content_rejected`, `vendor_rejected` |  | The job failed at the vendor. It shows in `error.code` of the Video object. |
| `400` | `clip_too_long` |  | The reference clip plus the requested `seconds` run past the model’s limit: `The reference clip plus the requested seconds run past the model's limit of 30 s. Send a shorter clip or fewer seconds. You were not charged.` It shows in `error.code` of the Video object. |
| `402` | `insufficient_credits` |  | The balance or key limit cannot cover the held amount. |
| `404` | `not_found` |  | No such video, it is not yours, it was deleted, or it is past `expires_at`; or the content is not ready. |
| `409` | `video_not_finished` |  | You deleted a video that is still being made. |
| `409` | `idempotency_key_reused`, `idempotency_key_in_use` |  | See [Retries and idempotency](#idempotency). |
| `429`, `503`, `500` |  |  | As for images. A `500` after the video was created carries `x-should-retry: false`. |

### Video models

<!-- BEGIN GENERATED models-video: sitemd writes the ids and prices from models.json; write each model's Notes cell. Run cd tools && go run ./sitemd -->
| Model | Price per second of video | Notes |
| --- | --- | --- |
| `x-ai/grok-imagine-video` | From $0.050 | Text, image or video input. 15 s at most. Supports `edit`. |
| `x-ai/grok-imagine-video-1.5` | From $0.080 | Text, image or reference input. 15 s at most. |
| `x-ai/grok-imagine-video-1.5-lite` | From $0.020 | As above, smaller and cheaper. |
| `bytedance/seedance-2.0` | From $0.0431892 | Text, first and last frame, or reference image input, optional audio. 4 to 15 s. 480p, 720p, 1080p or 4K; the rate shown is 480p's. |
| `bytedance/seedance-2.0-fast` | From $0.0331452 | Text, first and last frame, or reference image input, optional audio. 4 to 15 s. 480p or 720p; the rate shown is 480p's. |
| `bytedance/seedance-2.0-mini` | From $0.0210924 | Text, first and last frame, or reference image input, optional audio. 4 to 15 s. 480p or 720p; the rate shown is 480p's. |
| `bytedance/seedance-2.5` | From $0.0642816 | Text, first and last frame, or reference image input, optional audio. 4 to 15 s. 480p, 720p or 1080p; the rate shown is 480p's. |
| `lumina/berry-1.0` | From $0.050 | Text, image or reference input, optional audio. 30 s at most. 480p, 720p or 1080p; the rate shown is 480p's. |
| `minimax/minimax-h3` | From $0.080 | Text, first and last frame, or reference image input, no audio. 4 to 15 s, at 768p or 2K. |
| `alibaba/wan3.0` | From $0.050 | Text, image or reference input, optional audio. 30 s at most. 480p, 720p or 1080p; the rate shown is 480p's. |
<!-- END GENERATED models-video -->

The [model reference](https://dash.hyperouter.app/docs/models) in the dashboard lists every video model your account can call.

## Generations

`GET /v1/generations/{id}`

Returns the record of one of your requests, of any kind: its status, usage, charge, timing and, for images and videos, signed links to the outputs. Use it to poll an image request you sent with `Prefer: respond-async` and to reconcile spend. Prompt and completion text are never in the record. Hyperouter keeps them encrypted for 180 days only to meet legal obligations, and no API or dashboard page returns them ([Privacy Policy](privacy.md#p3)).

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string (UUID) | Yes | The `id` of a chat completion, image answer or Video object. |

### Response

```json
{
  "data": {
    "id": "0194f2c4-7a1e-7b3c-9d02-5f6a1c0e8b21",
    "kind": "chat",
    "model": "x-ai/grok-4.7",
    "provider_name": "xAI",
    "status": "done",
    "created_at": "2026-10-24T08:12:00Z",
    "finished_at": "2026-10-24T08:12:02Z",
    "usage": {
      "input_tokens": 120, "output_tokens": 300, "cache_read_tokens": 0,
      "image": 0, "video_seconds": 0, "cost": 0.00204
    },
    "usage_estimated": false,
    "ttft_ms": 320,
    "latency_ms": 2100,
    "error": null,
    "outputs": []
  }
}
```

| Field | Type | Description |
| --- | --- | --- |
| `kind` | string | `chat`, `image` or `video`. |
| `provider_name` | string \| null | The provider that served a finished chat request. `null` for images and videos. |
| `status` | string | `queued`, `submitting`, `running`, `done` or `failed`. |
| `created_at`, `finished_at` | string \| null | RFC 3339 UTC. `finished_at` is `null` until the request ends. |
| `usage` | object | `input_tokens`, `output_tokens`, `cache_read_tokens`, `image`, `video_seconds` and `cost`. `cost` is the charge in USD, a JSON number. Counts are `0` until the request is done. |
| `usage_estimated` | boolean | `true` when the provider sent no token counts and Hyperouter counted them. |
| `ttft_ms`, `latency_ms` | integer \| null | Time to first token and total time, in milliseconds. |
| `error` | object \| null | `{"code", "message"}` when the request failed. |
| `outputs` | object[] | For images and videos: `[{"url", "expires_at"}]`, signed links valid for 24 hours. Available for 30 days after the request; after that `outputs` is empty and the record stays. Always empty for chat. |
| `expanded_prompt` | string \| null | For images and videos sent with `prompt_expand`: the prompt that rendered. Kept 30 days after the request, then `null`. |

```bash
curl https://api.hyperouter.app/v1/generations/0194f2c4-7a1e-7b3c-9d02-5f6a1c0e8b21 \
  -H "Authorization: Bearer $HYPEROUTER_API_KEY"
```

### Errors

| Status | type | code | param | When |
| --- | --- | --- | --- | --- |
| `404` | `not_found` | `not_found` | `id` | `No generation with that id.` The id is not a UUID, does not exist, belongs to another account, or was deleted. |
| `500` | `server_error` | `server_error` |  | Hyperouter could not read or sign the record. |
| `401`, `403` |  |  | See [Authentication](#authentication). |

<!-- toc: Concepts -->
## Model-specific parameters {#parameters}

Some models have fields that other models do not. These are *extra parameters*. You send them as top-level fields of the request body, like any other field. Every OpenAI SDK can send them (`extra_body` in Python, extra properties in JavaScript).

`GET /v1/models` lists each model’s extra parameters in `extra_parameters`. Each entry has:

| Field | Type | Description |
| --- | --- | --- |
| `type` | string | One of `string`, `integer`, `number`, `boolean`, `array`, `object`. |
| `values` | array | The allowed values. Empty means any value of the type. |
| `price_condition` | boolean | `true` when the field changes the price. |
| `description` | string | What the field does. |

The [model reference](https://dash.hyperouter.app/docs/models) in the dashboard describes the extra parameters of each model your account can call.

### Rules

- A known parameter that the model does not have is refused: `400 invalid_request_error` with code `unsupported_parameter`, `param` set to the field name (for example `loras`) and the message `This model does not support "loras".` A parameter is known when some model in the catalog declares it. The same rule covers the shared features (`resolution`, `ratio`, `audio`, `seed`, `negative_prompt`, `first_frame`, `last_frame`, `reference_images`, `reference_videos`, `prompt_extend`). Nothing is dropped silently, because it would change the output and the price.
- A value of the wrong type is refused with `400 invalid_body`, `param` set to the name and a message such as `loras must be of type array.`
- A value outside `values` is refused with `400 invalid_body` and the message `<name> must be one of [...]`.
- A field that no model declares is ignored, as OpenAI’s API ignores unknown fields.
- Every model checks its parameters against its own schema: allowed values, minimum and maximum, required fields and list lengths. A value outside it answers `400 invalid_body` with `param` naming the field, for example `seconds must be one of 5, 8.`
- A combination a vendor does not allow, such as reference media together with keyframes, answers `400 invalid_combination` with the reason. The checks run before anything is charged.
- Fields that change the price, such as `resolution` and `audio`, are priced in the up-front hold, so the hold is exact.

### Shared features

These fields have the same name on every model that has them. A model lists the ones it takes in `supported_parameters`.

| Field | Meaning |
| --- | --- |
| `resolution` | `480p`, `720p`, `1080p`, `2K` or `4K`, as the model allows. |
| `ratio`, `seed`, `negative_prompt`, `prompt_extend`, `audio` | As named. `audio` and `prompt_extend` are off unless you ask. |
| `first_frame`, `last_frame` | Keyframes for video. |
| `reference_images`, `reference_videos` | Reference media for video and image edits. |

### Prompt expansion {#prompt-expansion}

With `"prompt_expand": true`, Hyperouter rewrites your prompt for the model before it renders: a fuller description for a picture, a second-by-second script for a video. It is off by default, so a prompt goes as written. Only a model whose `supported_parameters` lists `prompt_expand` takes it, and only to create an image or a video; on any other model, and on a video edit, extension or remix, it is refused with `400 unsupported_parameter`, and it cannot be combined with `prompt_extend: true` (`400 invalid_body`).

Prompt expansion is available to an account once Hyperouter has enabled it; open a support ticket to request access. Until then `GET /v1/models` does not list `prompt_expand` for any model, and a request with `prompt_expand: true` on a model that offers it answers `403 permission_error`, code `feature_not_enabled`, `param` `prompt_expand`, with nothing held or sent to the provider.

- **What rendered.** The image answer, the video object and `GET /v1/generations/{id}` carry `expanded_prompt`, the prompt the model received. It is kept for 30 days after the request, then answered as `null`. `prompt` stays your own text.
- **Writer.** `prompt_expand_model` names the chat model that writes the rewrite; the default is `x-ai/grok-4.6`. It is valid only with `prompt_expand: true` (else `400 invalid_body`). A model that does not exist or is not a chat model is `400 unknown_model`, and one your account cannot call is `403 model_not_enabled`, both before anything is held. A writer that reads images is shown the request's pictures; one that does not writes from the text alone.
- **Price.** The rewrite is billed on the same request, at the token prices of the model that wrote it (`prompt_expand_model`, else `x-ai/grok-4.6`) for the tokens it used, and its most is held up front with the render. The model reference gives each model's details.
- **When it does not run.** A rewrite that fails, times out or cannot be used renders your prompt as sent, answers `expanded_prompt: null` and costs nothing.
- **Language.** Spoken lines in a video follow `locale` (else English) where the model speaks that language.

#### The rewrite as a chat model {#prompt-expansion-model}

An account with prompt expansion also gets a chat model that answers the rewrite instead of rendering it; your [model reference](#models) names it. Call it on `POST /v1/chat/completions` with exactly one `user` message: its text is the prompt, and its `image_url` parts (https URLs, at most 5) are the pictures; a `data:` or `http://` URL is `400 invalid_image` with `param` `messages`. `target_model` is required: a model that lists `prompt_expand`. `seconds`, `locale` and `negative_prompt` are optional. A video target with a picture is written from that first frame. It does not stream (`stream: true` is `400 unsupported_parameter`), and any other message, a system message included, is `400 invalid_body`.

This chat model always uses `x-ai/grok-4.6`; it does not take `prompt_expand_model`. `choices[0].message.content` is a JSON string, `{"prompt": "...", "negative_prompt": "..."}`, plus `"beats"` (one line per second) for a video target. It is billed at `x-ai/grok-4.6`'s token prices, shown in `usage`. A prompt that asks for the model's instructions answers `400 prompt_rejected`; a rewrite that is refused or comes back empty answers `422 expansion_refused`, and one that fails `503 upstream_error`. None of these is charged.

## Moderation

The `moderation` field is OpenAI’s. It works on `/v1/chat/completions`, `/v1/images/generations`, `/v1/images/edits` and `/v1/videos` with its edit, extension and remix calls.

| Value | Meaning |
| --- | --- |
| `auto` (default) | The vendor’s own screening applies. Hyperouter sends nothing extra. |
| `low` | Hyperouter sends the provider’s opt-out from its screening, on models where the provider has one. On other models `low` is ignored without an error, as OpenAI does. Each model’s entry in your [model reference](https://dash.hyperouter.app/docs/models) says whether it has one. Send `moderation` in the body: a provider’s own screening header or field is not forwarded on these endpoints. |

Any other value is refused: `400 invalid_request_error`, code `invalid_body`, `param` `moderation`, message `moderation must be auto or low.`

`low` is available to an account once Hyperouter has enabled it; open a support ticket to request it. Until then a request with `low` answers `403 permission_error`, code `moderation_not_enabled`, `param` `moderation`, and is not sent to the provider.

```json
{
  "model": "qwen/qwen-image-2.0",
  "prompt": "...",
  "moderation": "low"
}
```

The response does not say whether the opt-out was used. The policies in the [Terms of Service](terms.md) still apply. A request that the vendor’s filter refuses fails with `400 content_rejected` and is not charged.

## Webhooks

Instead of polling, an endpoint of yours can receive a request when a job ends. Add endpoints in the [dashboard](https://dash.hyperouter.app/webhooks). The shape is OpenAI’s webhooks, signed with the Standard Webhooks scheme, so the OpenAI SDKs verify them.

| Event | Sent when |
| --- | --- |
| `video.completed`, `video.failed` | A video job ends. |
| `image.completed`, `image.failed` | An image job that you sent with `Prefer: respond-async` ends. |
| `raw.completed`, `raw.failed` | A raw call’s task ends (accounts with raw calls): charged, or its hold released. A raw call answered inline sends none. |

The payload carries an id, never a result, a prompt or a charge. Fetch the object with your API key: `data.id` is the id that [GET /v1/videos/{id}](#videos) and [GET /v1/generations/{id}](#generations) take. For a raw task it is the `X-Generation-Id` that the create answered.

```json
{
  "id": "evt_0194f2c4-7a1e-7b3c-9d02-5f6a1c0e8b21",
  "object": "event",
  "type": "video.completed",
  "created_at": 1761300000,
  "data": { "id": "0194f2c4-7a1e-7b3c-9d02-5f6a1c0e8b21" }
}
```

### Headers and verification

Each delivery is a `POST` with `content-type: application/json` and these headers:

- `webhook-id`: the event id. It is the same on every retry, so use it to de-duplicate.
- `webhook-timestamp`: Unix seconds.
- `webhook-signature`: `v1,` and the base64 HMAC-SHA256 of `id.timestamp.body`, keyed with the endpoint’s secret.

The signing secret starts with `whsec_`. It is shown once, when you create the endpoint and when you rotate it. Verify the raw request body, before you parse it:

```js
// Node, with the OpenAI SDK
const event = client.webhooks.unwrap(rawBody, req.headers, process.env.HYPEROUTER_WEBHOOK_SECRET);
if (event.type === "video.completed") {
  const video = await client.videos.retrieve(event.data.id);
}
```

`unwrap` throws when the signature does not verify; answer such a request with an error status. The follow-up call uses the same base URL as the rest of this API.

### Endpoints, retries and limits

- An endpoint must be `https` on port 443 (or no port) and a host name, not an IP address. The host must resolve to a public address; Hyperouter checks again when it connects. Redirects are not followed.
- An account has at most 10 endpoints. Each endpoint picks the event types it wants.
- A `2xx` within 10 seconds is success. Anything else is retried after about 5 seconds, 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours, then the delivery is marked failed.
- An endpoint whose deliveries all fail for 3 days is disabled, and the dashboard says why. Enable it again after you fix the receiver.
- The dashboard lists each endpoint’s deliveries with status, attempts, last HTTP status and last error. It has Resend (same event id) and Send test event, which delivers a `webhook.test` event whose `data.id` is the endpoint’s id.

## MCP server {#mcp}

`POST https://api.hyperouter.app/mcp`

An SI assistant can work with your Hyperouter account through the Model Context Protocol (MCP). The server manages the dashboard: API keys, activity, credits, tickets, webhooks and the model list. It does not run models. Model calls still go to `/v1` with an API key.

The server speaks Streamable HTTP and JSON-RPC 2.0. It supports MCP revisions `2025-11-25` and `2025-06-18`. It is stateless: there are no sessions, and `GET /mcp` answers `405`.

### Access tokens

An MCP client authenticates with a dashboard access token, not with an API key.

- Create a token in the [dashboard](https://dash.hyperouter.app/tokens). Give it a name, a scope and, optionally, an expiry.
- A token starts with `hr_dt_`. It is shown once. Hyperouter stores only a hash of it.
- An account user can hold at most 25 unexpired, unrevoked tokens. Revoke a token in the dashboard to stop it at once.
- A token does not work on `/v1`, and an API key does not work on `/mcp`.
- A token acts as the user who created it, with that user's permissions. The permissions are checked on every message, so a role change applies on the next call.

| Scope | Tools the token can call |
| --- | --- |
| `read` | Tools that only read data. |
| `write` | Every tool that the user has permission to use. |

Send the token as a bearer token:

```
Authorization: Bearer hr_dt_...
```

### Protocol

| Method | Answer |
| --- | --- |
| `initialize` | The protocol version (the client's, if supported; else `2025-11-25`), the `tools` capability and server information. |
| `ping` | An empty result. |
| `tools/list` | The tools that the token's user and scope allow. |
| `tools/call` | The result of one tool. |

A notification, or a response to the server, answers `202` with no body. A JSON-RPC batch is refused with error `-32600`. A message must not exceed 1 MiB. An unknown `MCP-Protocol-Version` header answers `400`. A browser request must come from `https://dash.hyperouter.app`; a client that sends no `Origin` header is not affected.

```bash
curl https://api.hyperouter.app/mcp \
  -H "Authorization: Bearer $HYPEROUTER_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}'
```

### Tools

`tools/list` returns the current tool set. Each tool maps to one dashboard route and carries a JSON Schema for its arguments. The tools cover:

- Account and keys: `get_me`, `list_api_keys`, `create_api_key`, `update_api_key`.
- Activity and credits: `list_activity`, `get_activity`, `get_activity_summary`, `get_credits`, `get_topup_fee`.
- Tickets: `list_tickets`, `create_ticket`, `get_ticket`, `reply_to_ticket`, `close_ticket`.
- Webhooks: `list_webhooks`, `create_webhook`, `update_webhook`, `rotate_webhook_secret`, `delete_webhook`, `list_webhook_deliveries`, `resend_webhook_delivery`, `send_test_webhook`.
- Models: `list_models`.
- Staff tools, with the `admin_` prefix, appear only for users who hold the staff permission.

A tool answers with one text content item that holds the JSON body of the route. When the route fails, the result has `isError: true` and the text `<message> (<code>, HTTP <status>)`. An unknown tool, or one that your token cannot see, is JSON-RPC error `-32602`.

### Approvals

A sensitive write, such as crediting an account, is not run at once. The tool does not execute it. It answers with `status` set to `approval_required` and an `approval_url` on the dashboard. The user opens the link, confirms with a second factor and approves the action. An approval expires after 24 hours. The user can also reject it.

<!-- toc: Account -->
## Billing & limits {#billing}

- Requests are paid from a prepaid USD balance, billed at the list price of the provider that served them, with no markup.
- **Hold, then settle.** Before a request runs, Hyperouter holds the most it can cost: your input plus `max_tokens` for chat, the requested count for images and the seconds for video. When it finishes you are charged for what was used and the rest of the hold is released. The balance never goes negative.
- A failed request, or a failed image or video job, costs nothing. A fallback attempt that fails costs nothing.
- Every response carries its charge as `usage.cost`, a JSON number of USD: on chat, on images, on a settled video, and in `GET /v1/generations/{id}`. The dashboard shows every request and your spend by day, model and key.
- Amounts are exact: money is counted in billionths of a dollar and never rounded through a float. Prices in `GET /v1/models` are decimal strings.
- Top-ups are manual for now. Open a top-up request in the [dashboard](https://dash.hyperouter.app/credits) with the amount, the payment reference and a screenshot of the payment. Staff check the payment and credit your balance, and reply in the same ticket.
- **Top-up fee.** Each top-up carries a fee of 5.5% of the amount paid, with a minimum of $0.80, deducted when the top-up is credited: a $100 payment credits $94.50, and a $10 payment credits $9.20. The fee in force is shown on the [Credits](https://dash.hyperouter.app/credits) page before you request a top-up, and each top-up in your history shows the amount paid and the fee. Requests themselves carry no fee.

### Rate limits {#rate-limits}

Each account has a limit on requests per minute and on requests in flight at once. Both count every API key of the account together, and apply to every request that starts work: chat completions, image and video creates, and raw creates. Reading a result (`GET /v1/generations/{id}`, `GET /v1/videos…`, a raw task poll) does not count; raw polls have their own count, per provider, with the same requests-per-minute limit.

Your tier follows the total you have topped up, before the top-up fee:

| Tier | Topped up | Requests per minute | Concurrent requests |
| --- | --- | --- | --- |
| 1 | Under $10 | 60 | 10 |
| 2 | $10 or more | 300 | 30 |
| 3 | $100 or more | 1,200 | 100 |
| 4 | $1,000 or more | 6,000 | 300 |

Your current limits are on the [Credits](https://dash.hyperouter.app/credits) page. These are the defaults; staff can change them for the platform or for one account, and a change applies within a minute.

Over either limit, the request is refused before anything is held or forwarded, with `429`, `type: rate_limit_error`, `code: rate_limited` and a `Retry-After` header in seconds. The message names the limit you reached. A request counts toward the minute even when it is refused. A request in flight, a stream included, counts until it ends.

As a guard against key guessing, Hyperouter counts failed API key checks per client address. More than 30 in a minute blocks that address from every `/v1` route for 15 minutes: `429`, `rate_limit_error`, `too_many_auth_failures`, with `Retry-After`. A valid key does not lift the block.

## Support

Open a ticket in the [dashboard](https://dash.hyperouter.app/tickets) for billing, top-ups and API questions; staff reply in the same thread. You can also write to [account@hyperouter.app](mailto:account@hyperouter.app). Please include the `id` from the response when you report a specific request.
