Console
API reference/POST /v1/videos

POST /v1/videos

OpenAI Videos API: a job, then the file; billed at the provider's cost.

/llms.txt

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

HeaderFormat
AuthorizationBearer tm_vk_... (the standard OpenAI-style header)
x-api-keytm_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.

ParameterTypeBehavior
modelstring, requiredA 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.
promptstring, requiredNon-empty, at most 32 000 characters.
secondsinteger, or a string of digitsThe 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.
durationintegerOpenRouter's name for seconds. Send one of the two; if you send both and they differ, it is a 400.
resolutionstringPer model, for example 480p, 720p, 1080p.
aspect_ratiostringPer model, for example 16:9, 9:16, 1:1.
sizestringExact pixels, for example 1280x720, on the models that declare sizes (Seedance). Elsewhere it is dropped and recorded.
generate_audiobooleanOn the models that make sound.
seedintegerOn the models that declare one.
userstringYour end-user id, at most 256 characters. Kept with the job and returned on it; never sent to the provider.
providerobjectRouting 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

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 }
}
FieldMeaning
idOurs, video_ and 32 hex digits. It is never the provider's id.
statusqueued → in_progress → completed or failed.
progress0 until the video is done, then 100. The providers report no progress, and the gateway does not invent one.
secondsA string, as in OpenAI's object.
errorOn 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.costUSD, once the job has ended: the charge. Absent while the job runs, and on static dev keys.
userYour user, when you sent one.

Read, list and download

CallAnswer
GET /v1/videos/{id}The video object. Ask every few seconds; a job takes minutes.
GET /v1/videosYour 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}/contentThe 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 estimated in 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 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 idMade byLengthResolutionsSoundListed pricePer second ≤
seedance-2.5ByteDance4–30 s (default 5)480p, 720p; 12 exact sizesyes$10.70 per million video tokens (about $0.10/s at 480p, $0.23/s at 720p)$0.30
hailuo-3-maxMiniMax (H3 Max)5–15 s (default 5)480p, 768pno$0.05/s at 480p, $0.08/s at 768p$0.08
wan-3.0Alibaba5–30 s (default 5)480p, 720p, 1080pyes$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

CaseStatuserror_typeBilled
A field outside the model's accepted values, an image input, a chat or image model on this route400invalid_requestno row
The model is listed but has no price row400model_not_pricedno row
Not a video model in the catalog404model_unavailableno row
Request body over 1 MB413request_too_largeno row
Unknown id, or another organization's video404not_found—
The file before the job is completed409invalid_request—
Key RPM or a shared limit, balance too low for the reservation, or a monthly spend cap429rate_limit / insufficient_quotano row
The provider refused the submitthe provider'supstream_error$0
The provider did not accept the job within 60 s504upstream_error$0
The provider accepted the job but named no job id, or its answer could not be read502upstream_error$0
Every deployment serving the model is cooling down503gateway_errornothing dispatched; retry-after: 5
The provider did not send the file on a download502upstream_error—
The provider accepted the job but it failed200 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
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
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
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.

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 · Pricing & billing · Errors

Markdown source for agents: /docs/api-videos.md · index at /llms.txt