POST /v1/videos
OpenAI Videos API: a job, then the file; billed at the provider's cost.
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.
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. 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. |
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
{
"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.
- 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
estimatedin GET /v1/generation. - 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 endsfailedwith the codevideo_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 -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.mp4openai SDK with the base URL swappedimport 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")openai packageimport { 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.
Model discovery
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 · Pricing & billing · Errors
Markdown source for agents: /docs/api-videos.md · index at /llms.txt