# POST /v1/videos

Generate a video from a text prompt. This is the OpenAI Videos API surface: point
`client.videos.create` at `https://api.routerplus.com/v1` and the call works unchanged.

A video takes from ten seconds to several minutes to make, longer than any edge keeps a
request open. So a video is a **job**. `POST /v1/videos` answers at once with the job;
you read the job with `GET /v1/videos/{id}` until it is `completed`; then you download the
MP4 from `GET /v1/videos/{id}/content`.

To try it without writing code, pick a video model in the [playground](/docs/playground).

Today every video model is served through OpenRouter, on the marketplace's own account: a
key bound to your own provider connection cannot make videos yet. Text-to-video only:
image-to-video and reference images are not offered yet, and a request that sends one is
refused.

## Authentication

| Header | Format |
|---|---|
| `Authorization` | `Bearer tm_vk_...` (the standard OpenAI-style header) |
| `x-api-key` | `tm_vk_...` — also accepted, same key |

A missing or invalid key returns 401 with `error_type: "auth"`.

## Create a video

`POST /v1/videos` with a JSON body, capped at 1 MB. Every rule below is checked **before**
anything is reserved or sent to a provider, so a refused request has no ledger row and no
charge.

| Parameter | Type | Behavior |
|---|---|---|
| `model` | string, required | A video model id — see [Model discovery](#model-discovery). A chat model here is a 400 naming the chat routes; an image model is a 400 naming `/v1/images/generations`; an id that is not a video model anywhere is a 404. |
| `prompt` | string, required | Non-empty, at most 32 000 characters. |
| `seconds` | integer, or a string of digits | The length. OpenAI's SDKs send a string (`"8"`), and both forms are read the same way. Each model accepts a range (below). **Omitted = the model's default length**, which is always sent, so the job is billed for the length that was reserved. |
| `duration` | integer | OpenRouter's name for `seconds`. Send one of the two; if you send both and they differ, it is a 400. |
| `resolution` | string | Per model, for example `480p`, `720p`, `1080p`. |
| `aspect_ratio` | string | Per model, for example `16:9`, `9:16`, `1:1`. |
| `size` | string | Exact pixels, for example `1280x720`, on the models that declare sizes (Seedance). Elsewhere it is dropped and recorded. |
| `generate_audio` | boolean | On the models that make sound. |
| `seed` | integer | On the models that declare one. |
| `user` | string | Your end-user id, at most 256 characters. Kept with the job and returned on it; never sent to the provider. |
| `provider` | object | Routing controls read by the gateway and never forwarded — `require_parameters` and the rest, see [Routing policies](/docs/routing-policies). |

The accepted values are **per model** and published in `/api/models.json` under
`supported_parameters`. A value outside a model's declared set is a 400 whose message
starts with the field name and lists what that model accepts. An explicit `null` means
"not provided", as on the other routes.

**Refused, never dropped:** `input_reference`, `frame_images` and `input_references`. To
drop an image would make, and bill, a different video from the one asked for.

**Dropped and recorded:** any other key, including `callback_url` — the gateway does not
send provider webhooks to your URL. The names are in the `x-tm-dropped-params` header and
on the ledger row. Send `"provider": {"require_parameters": true}` to turn a drop into a
400 instead.

The answer is HTTP 200 with the video object, `status: "queued"`. It carries the same
headers as an image response: `x-request-id`, `x-tm-provider`, `x-tm-attempts`,
`x-tm-upstream-status`, and `x-tm-dropped-params` when something was dropped. The provider
has 60 seconds to accept the job; past that the request is a 504 `upstream_error`.

## The video object

```json
{
  "id": "video_7f3c2a9b1e4d4c6a8b0f2e1d3c5a7b9e",
  "object": "video",
  "model": "wan-3.0",
  "status": "completed",
  "progress": 100,
  "created_at": 1790000000,
  "completed_at": 1790000094,
  "expires_at": null,
  "seconds": "5",
  "size": null,
  "resolution": "720p",
  "aspect_ratio": "16:9",
  "error": null,
  "usage": { "cost": 0.5 }
}
```

| Field | Meaning |
|---|---|
| `id` | Ours, `video_` and 32 hex digits. It is never the provider's id. |
| `status` | `queued` → `in_progress` → `completed` or `failed`. |
| `progress` | `0` until the video is done, then `100`. The providers report no progress, and the gateway does not invent one. |
| `seconds` | A string, as in OpenAI's object. |
| `error` | On a failed job: `{code, message}`. The code is one of `video_generation_failed`, `content_policy_violation`, `video_cancelled`, `video_expired`, `video_timeout`. The provider's own words are never kept. |
| `usage.cost` | USD, once the job has ended: the charge. Absent while the job runs, and on static dev keys. |
| `user` | Your `user`, when you sent one. |

## Read, list and download

| Call | Answer |
|---|---|
| `GET /v1/videos/{id}` | The video object. Ask every few seconds; a job takes minutes. |
| `GET /v1/videos` | Your organization's videos, newest first: `{object: "list", data, first_id, last_id, has_more}`. Query: `limit` (1–100, default 20), `order` (`desc` or `asc`), `after` (a video id). |
| `GET /v1/videos/{id}/content` | The MP4, streamed from the provider. `?variant=video` is the only variant (anything else is a 400). A `Range` header is passed on, so a player can seek; a range the provider cannot satisfy is a 416. Before the job is `completed` it is a 409. |

The gateway follows a job itself: it asks the provider every 5 seconds for the first two
minutes, then every 10 seconds, then every 20. A read between two of those asks shows the
last status the gateway knows. A video of another organization is a 404, exactly like a
video that does not exist. The file is streamed through as it arrives, with the provider's
`content-type` (else `video/mp4`), `content-length`, `content-range` and `accept-ranges`,
and is **never stored by the marketplace** — save it when you download it.

## Billing

Video models are billed at **the cost the provider reports** for the job (OpenRouter's
`usage.cost`), pass-through, with no margin. See
[Pricing & billing](/docs/pricing#models-billed-at-the-providers-reported-cost).

- **Reserve**, before the job is submitted: `seconds` × the model's per-second ceiling.
  The models page shows that ceiling as **Per second ≤**. Your balance must cover it.
- **Settle**, when the job ends: the reported cost, exactly. The hold is then released.
- **A failed, cancelled or expired job costs nothing.** You get no video, so you pay
  nothing.
- **A completed job without a reported cost** is charged its reservation, marked
  `estimated` in [GET /v1/generation](/docs/api-usage).
- **If the gateway restarts while a job runs**, the job is charged its reservation, as
  for any request a crash leaves open. The job itself continues, and `GET /v1/videos/{id}`
  still answers for it. A job the gateway follows for two hours without an end is also
  charged its reservation, and ends `failed` with the code `video_timeout`.

Worked numbers on `wan-3.0` (per-second ceiling $0.20): a 5-second request reserves
$1.00 (5 × $0.20). At 480p the job reported $0.2125 when measured on 2026-09-23; that is
the charge, and the rest of the hold is released.

## Models

| Model id | Made by | Length | Resolutions | Sound | Listed price | Per second ≤ |
|---|---|---|---|---|---|---|
| `seedance-2.5` | ByteDance | 4–30 s (default 5) | 480p, 720p; 12 exact sizes | yes | $10.70 per million video tokens (about $0.10/s at 480p, $0.23/s at 720p) | $0.30 |
| `hailuo-3-max` | MiniMax (H3 Max) | 5–15 s (default 5) | 480p, 768p | no | $0.05/s at 480p, $0.08/s at 768p | $0.08 |
| `wan-3.0` | Alibaba | 5–30 s (default 5) | 480p, 720p, 1080p | yes | $0.05/s at 480p, $0.10/s at 720p, $0.20/s at 1080p | $0.20 |

OpenRouter bills Wan 3.0 for at least 5 seconds, so shorter lengths are not offered: a
2-second video would cost as much as a 5-second one. Measured on 2026-09-23, Wan 3.0
charged 85% of its listed rate (a 5-second 480p video: $0.2125).

Times measured on 2026-09-23: H3 Max finished a 5-second video in about 13 seconds;
Seedance 2.5 and Wan 3.0 took about two minutes at 480p. A provider's queue can hold a job
much longer — one Seedance job at 720p waited more than 15 minutes.

## Errors

| Case | Status | `error_type` | Billed |
|---|---|---|---|
| A field outside the model's accepted values, an image input, a chat or image model on this route | 400 | `invalid_request` | no row |
| The model is listed but has no price row | 400 | `model_not_priced` | no row |
| Not a video model in the catalog | 404 | `model_unavailable` | no row |
| Request body over 1 MB | 413 | `request_too_large` | no row |
| Unknown id, or another organization's video | 404 | `not_found` | — |
| The file before the job is completed | 409 | `invalid_request` | — |
| Key RPM or a shared limit, balance too low for the reservation, or a monthly spend cap | 429 | `rate_limit` / `insufficient_quota` | no row |
| The provider refused the submit | the provider's | `upstream_error` | $0 |
| The provider did not accept the job within 60 s | 504 | `upstream_error` | $0 |
| The provider accepted the job but named no job id, or its answer could not be read | 502 | `upstream_error` | $0 |
| Every deployment serving the model is cooling down | 503 | `gateway_error` | nothing dispatched; `retry-after: 5` |
| The provider did not send the file on a download | 502 | `upstream_error` | — |
| The provider accepted the job but it failed | 200 on the read; `status: "failed"` | — | $0 |

Bodies follow the OpenAI error envelope with the canonical class in
`error.metadata.error_type`; a provider's own error body is never relayed. The reverse
refusal also holds: a video model sent to `POST /v1/chat/completions`,
`POST /v1/messages` or `POST /v1/images/generations` is a 400 naming `/v1/videos`.

## Examples

curl — create, read, download:

```bash
curl -s https://api.routerplus.com/v1/videos \
  -H "Authorization: Bearer $TM_API_KEY" -H "content-type: application/json" \
  -d '{"model":"hailuo-3-max","prompt":"a paper boat drifting down a rainy street","seconds":"5"}'
# → {"id":"video_…","status":"queued",…}
curl -s https://api.routerplus.com/v1/videos/video_… -H "Authorization: Bearer $TM_API_KEY"
curl -s https://api.routerplus.com/v1/videos/video_…/content -H "Authorization: Bearer $TM_API_KEY" -o video.mp4
```

Python — the official `openai` SDK with the base URL swapped:

```python
import os
import time
from openai import OpenAI

client = OpenAI(base_url="https://api.routerplus.com/v1", api_key=os.environ["TM_API_KEY"])

video = client.videos.create(model="wan-3.0", prompt="a lighthouse at dusk", seconds="5")
while video.status in ("queued", "in_progress"):
    time.sleep(5)
    video = client.videos.retrieve(video.id)
if video.status == "completed":
    client.videos.download_content(video.id).write_to_file("video.mp4")
```

TypeScript — the official `openai` package:

```typescript
import { writeFileSync } from "node:fs";
import OpenAI from "openai";

const client = new OpenAI({ baseURL: "https://api.routerplus.com/v1", apiKey: process.env.TM_API_KEY });

let video = await client.videos.create({ model: "seedance-2.5", prompt: "a fox leaping into snow", seconds: "5" });
while (video.status === "queued" || video.status === "in_progress") {
  await new Promise((r) => setTimeout(r, 5000));
  video = await client.videos.retrieve(video.id);
}
if (video.status === "completed") {
  const file = await client.videos.downloadContent(video.id);
  writeFileSync("video.mp4", Buffer.from(await file.arrayBuffer()));
}
```

The SDKs' types list OpenAI's own models and lengths; the gateway reads any model id and
any whole number of seconds the model accepts.

## Content-free

A prompt and a video are content, and the marketplace never stores content. The gateway
keeps a job's id, model, length, shape, status and charge; never the prompt, never the
provider's error text, never the file. See [Data policy](/docs/data-policy).

## Model discovery

```bash
curl -s "https://api.routerplus.com/v1/models?output_modalities=video" \
  -H "Authorization: Bearer $TM_API_KEY"
```

Every row of `GET /v1/models` carries `architecture.output_modalities`: `["text"]`,
`["image"]` or `["video"]`. The public feed `https://app.routerplus.com/api/models.json` carries
`supported_parameters` for each video model, `max_output_tokens` as its per-second ceiling
in cost units (one micro-dollar each), and `price_card`, the provider's listed prices.

See also: [POST /v1/images/generations](/docs/api-images) ·
[Pricing & billing](/docs/pricing) · [Errors](/docs/errors)
