# POST /v1/videos

Generate a video from a text prompt. This is the OpenAI Videos API surface: point
`client.videos.create` at `https://dev-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 Replicate, 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 | Another 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",
  "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 provider's listed price** for the job: Replicate's
published price per second of video, for the seconds Replicate says it billed (never more
than 5% past the length you asked for), 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` (per-second ceiling $0.105): a 5-second request reserves
$0.525 (5 × $0.105). At 480p Replicate bills $0.025 a second, so the charge is $0.125 and
the rest of the hold is released.

## Models

| Model id | Made by | Length | Resolutions | Sound | Listed price | Per second ≤ |
|---|---|---|---|---|---|---|
| `wan-3` | Alibaba | 2–30 s (default 5) | 480p, 720p, 1080p | provider default; no audio toggle | $0.025/s at 480p, $0.05/s at 720p, $0.10/s at 1080p | $0.105 |
| `grok-imagine-video` | xAI | 1–15 s (default 5) | 480p, 720p | provider default; no audio toggle | $0.05/s | $0.0525 |
| `seedance-2.0` | ByteDance | 4–15 s (default 5) | 480p, 720p, 1080p | yes, at no extra cost | $0.08/s at 480p, $0.18/s at 720p, $0.45/s at 1080p | $0.48 |
| `seedance-2.0-fast` | ByteDance | 4–15 s (default 5) | 480p, 720p | yes, at no extra cost | $0.07/s at 480p, $0.15/s at 720p | $0.16 |
| `kling-v3-video` | Kuaishou | 3–15 s (default 5) | 720p, 1080p, 4k | `generate_audio`, off by default | $0.168/s at 720p ($0.252 with sound), $0.224/s at 1080p ($0.336 with sound), $0.42/s at 4k | $0.441 |
| `veo-3.1-fast` | Google | 4, 6 or 8 s (default 8) | 720p, 1080p | on by default; `generate_audio: false` turns it off | $0.15/s with sound, $0.10/s without | $0.158 |

Audio follows the provider defaults for [Wan 3](https://replicate.com/alibaba/wan-3)
and [Grok Imagine Video](https://replicate.com/xai/grok-imagine-video); this API exposes
no audio toggle for those models.

Sora 2 was retired on September 24, 2026 and is no longer offered. Existing video and
usage records remain readable. See [OpenAI’s discontinuation notice](https://help.openai.com/en/articles/20001152-what-to-know-about-the-sora-discontinuation).

Prices are Replicate's published prices for each model, read on 2026-10-12. Replicate
bills the video it returned, which can run a fraction of a second past the length asked
for (5.04 s for 5 s); that fraction is billed, up to 5% of the length.

## 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://dev-api.routerplus.com/v1/videos \
  -H "Authorization: Bearer $TM_API_KEY" -H "content-type: application/json" \
  -d '{"model":"seedance-2.0-fast","prompt":"a paper boat drifting down a rainy street","seconds":"5"}'
# → {"id":"video_…","status":"queued",…}
curl -s https://dev-api.routerplus.com/v1/videos/video_… -H "Authorization: Bearer $TM_API_KEY"
curl -s https://dev-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://dev-api.routerplus.com/v1", api_key=os.environ["TM_API_KEY"])

video = client.videos.create(model="wan-3", 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://dev-api.routerplus.com/v1", apiKey: process.env.TM_API_KEY });

let video = await client.videos.create({ model: "veo-3.1-fast", prompt: "a fox leaping into snow", seconds: "4" });
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.

## Data policy

The gateway keeps a job's id, model, length, shape, status and charge so you can
retrieve it later. The video file streams from the provider when downloaded.
See [Data policy](/docs/data-policy) for how request data is handled.

## Model discovery

```bash
curl -s "https://dev-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://dev.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)
