# POST /v1/videos

Generate a video from a text prompt. The route follows the OpenAI Videos API, so
`client.videos.create` works unchanged with the base URL `https://demo-api.routerplus.com/v1`.

A video takes from ten seconds to several minutes to make. That is longer than any edge keeps
a request open, so each video is a job:

1. `POST /v1/videos` answers at once with the job.
2. `GET /v1/videos/{id}` reads the job. Repeat until it is `completed`.
3. `GET /v1/videos/{id}/content` downloads the MP4.

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

Limits:

- Replicate serves every video model, on the RouterPlus account. A key bound to your own
  provider connection cannot make videos.
- Text-to-video only. RouterPlus refuses a request that sends an image input or reference
  images.

## Examples

Each example creates a job, polls it and downloads the MP4.

curl:

```bash
curl -s https://demo-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://demo-api.routerplus.com/v1/videos/video_… -H "Authorization: Bearer $TM_API_KEY"
curl -s https://demo-api.routerplus.com/v1/videos/video_…/content -H "Authorization: Bearer $TM_API_KEY" -o video.mp4
```

Python:

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

client = OpenAI(base_url="https://demo-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:

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

const client = new OpenAI({ baseURL: "https://demo-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 SDK types list OpenAI's own models and lengths. RouterPlus accepts any model id, and any
whole number of seconds that the model accepts.

Send your key as `Authorization: Bearer tm_vk_...` or as `x-api-key: tm_vk_...`. A missing or
invalid key returns 401 with `error_type: "auth"`.

## Create a video

`POST /v1/videos` takes a JSON body of at most 1 MB. RouterPlus checks every rule below before
it reserves money or sends anything to a provider. 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 is a 400 that names the chat routes. An image model is a 400 that names `/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"`). RouterPlus reads both forms the same way. Each model accepts a range (see [Models](#models)). If you omit it, RouterPlus sends the model's default length, and the job is billed for the length that was reserved. |
| `duration` | integer | Another name for `seconds`. Send one of the two. Both with different values 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). Other models drop and record it. |
| `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, including `require_parameters`. RouterPlus reads them and never forwards them. See [Routing policies](/docs/routing-policies). |

Each model publishes its accepted values in `/api/models.json` under `supported_parameters`.
A value outside the set of a model is a 400. The 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`. A dropped
image would make, and bill, a different video from the one you asked for.

**Dropped and recorded:** any other key, including `callback_url`. RouterPlus does not send
provider webhooks to your URL. The `x-tm-dropped-params` header and the ledger row name the
dropped fields. Send `"provider": {"require_parameters": true}` to get a 400 instead.

The answer is HTTP 200 with the video object and `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 a field was dropped. The provider has
60 seconds to accept the job. After 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` | The RouterPlus id: `video_` and 32 hex digits. 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 RouterPlus does not estimate 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`. RouterPlus never keeps the provider's own words. |
| `usage.cost` | The charge in USD, once the job has ended. 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. Poll 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 to 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. Any other value is a 400. RouterPlus passes a `Range` header on, so a player can seek. A range the provider cannot satisfy is a 416. Before the job is `completed`, the call is a 409. |

- RouterPlus polls the provider for you: every 5 seconds for the first two minutes, then every
  10 seconds, then every 20. A read between two polls shows the last status RouterPlus has.
- A video of another organization is a 404, the same as a video that does not exist.
- The file streams through as it arrives, with the provider's `content-type` (else
  `video/mp4`), `content-length`, `content-range` and `accept-ranges`.
- RouterPlus never stores the file. Save it when you download it.

## Billing

RouterPlus bills a video at the provider's listed price for the job: Replicate's published
price per second of video, for the seconds Replicate says it billed. That is never more than 5%
past the length you asked for. The price passes through with no margin. See
[Pricing & billing](/docs/pricing#models-billed-at-the-providers-reported-cost).

| Case | Charge |
|---|---|
| Before the job is submitted | RouterPlus reserves `seconds` × the per-second ceiling of the model. The models page shows that ceiling as **Per second ≤**. Your balance must cover it. |
| The job ends | The reported cost. RouterPlus releases the rest of the hold. |
| A failed, cancelled or expired job | Nothing. You get no video. |
| A completed job without a reported cost | The reservation, marked `estimated` in [GET /v1/generation](/docs/api-usage). |
| The gateway restarts while a job runs | The reservation, as for any request a crash leaves open. The job continues, and `GET /v1/videos/{id}` still answers for it. |
| No end after two hours of polling | The reservation. The job ends `failed` with the code `video_timeout`. |

Example 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. RouterPlus
releases the rest of the hold.

## Models

| Model id | Made by | Length | Resolutions | Sound | Listed price | Per second ≤ |
|---|---|---|---|---|---|---|
| `wan-3` | Alibaba | 2 to 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 to 15 s (default 5) | 480p, 720p | provider default; no audio toggle | $0.05/s | $0.0525 |
| `seedance-2.0` | ByteDance | 4 to 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 to 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 to 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 |

- [Wan 3](https://replicate.com/alibaba/wan-3) and
  [Grok Imagine Video](https://replicate.com/xai/grok-imagine-video) follow the provider's
  audio defaults. This API has no audio toggle for them.
- Prices are Replicate's published prices for each model, read on 2026-10-12.
- Replicate bills the video it returned. That can run a fraction of a second past the length
  you asked for (5.04 s for 5 s). The fraction is billed, up to 5% of the length.
- Sora 2 was retired on September 24, 2026. Existing video and usage records stay readable. See
  [OpenAI’s discontinuation notice](https://help.openai.com/en/articles/20001152-what-to-know-about-the-sora-discontinuation).

## 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` | n/a |
| The file before the job is completed | 409 | `invalid_request` | n/a |
| 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` | n/a |
| The provider accepted the job but it failed | 200 on the read; `status: "failed"` | n/a | $0 |

Error bodies use the OpenAI error envelope, with the canonical class in
`error.metadata.error_type`. RouterPlus never relays a provider's own error body. A video model
sent to `POST /v1/chat/completions`, `POST /v1/messages` or `POST /v1/images/generations` is a
400 that names `/v1/videos`.

## Data policy

RouterPlus keeps the id, model, length, shape, status and charge of a job, so you can retrieve
it later. The video file streams from the provider when you download it. See
[Data policy](/docs/data-policy).

## Model discovery

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

Every row of `GET /v1/models` carries `architecture.output_modalities`, for example
`["video"]`. For each video model, the public feed `https://demo.routerplus.com/api/models.json` carries:

- `supported_parameters`
- `max_output_tokens`: the per-second ceiling in cost units (one micro-dollar each)
- `price_card`: the provider's listed prices

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