Multimodal
Async, job-based — create a job with a webhook_url and we
POST you the finished video, so there's nothing to poll (polling GET /v1/videos/{id} still works as a fallback). Billed per
requested second at creation time; polling for status is free. There's no OpenRouter backstop for this endpoint: each model is served direct by the
provider that owns it. Three generation modes share this one endpoint — plain
text-to-video (below),
image-to-video (animate a starting
frame), and reference-to-video
(guide generation from multiple images, videos, and audio clips at once) — which one you get depends
only on which model you pass.
Text-to-video
import hashlib, hmac, requests
from flask import Flask, request, abort
# 1. create the job -- returns immediately with an id; webhook_url is where we send the result
requests.post(
"https://videorouter.sh/api/v1/videos",
headers={"Authorization": "Bearer llmr_sk_live_..."},
json={
"model": "minimax/h3/fal",
"prompt": "a paper airplane gliding over a city",
"duration_secs": 8,
"webhook_url": "https://your-app.com/hooks/video",
},
)
# 2. when the job finishes we POST it to that URL (the same object GET /v1/videos/{id} returns)
app = Flask(__name__)
SECRET = "whsec_..." # Org admin > Webhooks
@app.post("/hooks/video")
def video_done():
body = request.get_data()
expected = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers["X-VideoRouter-Signature"]):
abort(401)
job = request.get_json()
if request.headers["X-VideoRouter-Event"] == "video.completed":
video_url = job["data"][0]["url"] # the finished video
cost = job["cost_usd"]
else:
print(job["error"])
return "", 204 # any 2xx acknowledges the delivery
The sample above uses a webhook (recommended): the job id comes back immediately and your server is called when the video is ready, signed so you can verify it. See Webhooks for the payload, signature, and retry rules.
Or poll instead
No public URL to receive a webhook (a script, a notebook, a local app)? Poll GET /v1/videos/{id} until
status leaves the queue. Same job object, same cost_usd on the finished job — some hosts report the final price a few seconds after the clip is ready, so on a completed job without it, poll again.
import time
import requests
# create
resp = requests.post(
"https://videorouter.sh/api/v1/videos",
headers={"Authorization": "Bearer llmr_sk_live_...", "Content-Type": "application/json"},
json={
"model": "minimax/h3/fal",
"prompt": "a paper airplane gliding over a city",
"duration_secs": 8,
},
)
job = resp.json()
# poll until it leaves the queue ("queued" -> "in_progress" -> "completed"/"failed")
while job["status"] not in ("completed", "failed"):
time.sleep(5)
job = requests.get(
f"https://videorouter.sh/api/v1/videos/{job['id']}",
headers={"Authorization": "Bearer llmr_sk_live_..."},
).json()
if job["status"] == "completed":
video_url = job["data"][0]["url"] # the finished video
else:
raise RuntimeError(job["error"])
The OpenAI SDK doesn't have first-class video methods yet — use its low-level
client.post/client.get,
or plain requests, against the same
base_url.
model is
"<creator>/<model>/<host>" —
"minimax/h3/fal" above is a
soft preference for Fal, not a hard pin: it still falls back across every other host
confirmed to serve that checkpoint if Fal itself errors. Drop the host segment (just
"minimax/h3") to route automatically
to whichever host is cheapest right now instead. For a hard pin (no fallback at all), use
"provider": {"only": ["fal"], "allow_fallbacks": false}.
Full mechanics: Provider selection.
Resolution & aspect ratio
Pass resolution (a tier the
model supports, e.g. "720p") and
aspect_ratio (one of
16:9/9:16/1:1/4:3/3:4/21:9)
to request a size together — same shape as
/v1/images. Each model only
supports some combinations; an unrecognized or unsupported one never 400s — it's ignored and the
model's default resolution/orientation is used instead, same as omitting both. Seedance 2.5 is the exception for 21:9: it renders a true ultrawide clip at 480p/720p/1080p, and a 21:9 request is only routed to hosts that support it (or returns a 400 if you pin one that doesn't). 21:9 applies to text-to-video and reference-to-video; image-to-video and video-edit keep the input media's own ratio, so combining them with 21:9 returns a 400. Explicit
height/width
pixel integers are still accepted too, and override
resolution/aspect_ratio
when given — but for most callers resolution/aspect_ratio
is simpler than computing exact pixel dimensions by hand.
import requests
job = requests.post(
"https://videorouter.sh/api/v1/videos",
headers={"Authorization": "Bearer llmr_sk_live_...", "Content-Type": "application/json"},
json={
"model": "minimax/h3/fal",
"prompt": "a paper airplane gliding over a city",
"resolution": "720p",
"aspect_ratio": "16:9",
"duration_secs": 8,
},
).json()
For google/veo-3.1/google/veo-3.1-fast
resolution/aspect_ratio
select one of a small set of confirmed tiers — the DeepInfra and SiliconFlow rows ignore both. For
google/veo-3.1-fast, the tier also changes the
price.
Image-to-video
Pass start_image_url — a public
https:// URL or an inline
data:image/...;base64,... URI — to
animate a starting frame. Add end_image_url
(same formats) to pin the last frame too — first-and-last-frame / keyframe generation, supported on
a subset of models listed below the example. On most models this is the same public model id you'd use for
text-to-video — omit start_image_url and
it's plain text-to-video, pass it and the model animates that frame instead: Fal
(bytedance/seedance-2.0/fal,
minimax/h3/fal), Atlas Cloud (e.g.
minimax/h3/atlas-cloud), Replicate
(minimax/h3/replicate), WaveSpeedAI (e.g.
minimax/h3/wavespeed-resell), and MachGen
(minimax/h3/machgen). A few models have
no text-to-video mode at all and require start_image_url
(400 without it): alibaba/happy-horse/fal,
shengshu/vidu-q3-pro-fast/machgen, and
xai/grok-imagine-video-1.5/machgen. Models
with no image-to-video mode reject start_image_url with
a 400.
import requests
job = requests.post(
"https://videorouter.sh/api/v1/videos",
headers={"Authorization": "Bearer llmr_sk_live_...", "Content-Type": "application/json"},
json={
"model": "bytedance/seedance-2.0/fal",
"prompt": "the subject turns and smiles, gentle camera push-in",
"start_image_url": "https://example.com/photo.jpg",
"end_image_url": "https://example.com/photo-end.jpg", # optional: pin the last frame too
"duration_secs": 5,
"resolution": "720p",
"aspect_ratio": "16:9",
},
).json()
alibaba/happy-horse/fal's
prompt is optional — omit it and the
image is animated with no text guidance at all.
end_image_url (last frame) works on
Fal (bytedance/seedance-2.0/fal,
bytedance/seedance-2.5/fal,
alibaba/wan-3.0/fal,
minimax/h3/fal), Replicate
(bytedance/seedance-2.0/replicate,
bytedance/seedance-2.5/replicate,
google/veo-3.1/replicate,
minimax/h3/replicate), OpenSand
(bytedance/seedance-2.0-unrestricted/opensand, bytedance/seedance-2.5-unrestricted/opensand, bytedance/seedance-2.5/opensand), MuleRouter
(alibaba/wan-3.0/mulerouter), SoCheap
(bytedance/seedance-2.5/socheap), and
kuaishou/kling-v4.0/wavespeedai. Those are examples: most image-to-video rows take one, across Fal, Atlas Cloud, WaveSpeed, SandBase, OpenRouter, ToAPIs,
MachGen, Kie, Novita, DashScope, Pika, Replicate and the direct MiniMax / LTX / Together / BFL / Runware / SoCheap hosts
(not every model on them — Veo 3, HappyHorse, Grok Imagine, HeyGen, PixVerse and a few others have no last-frame input).
A model whose default host has no last-frame input fails over to one that does; pinning a host that lacks it, or
allow_fallbacks: false on one, is rejected with a 400 naming the hosts that do take it.
Check before you submit: each type=video row of
GET /v1/models carries
capabilities.end_image_url, and
POST /v1/pricing/estimate applies the same rules
(including provider.only /
allow_fallbacks: false when quoting). Check before you submit: each type=video row of
GET /v1/models carries
capabilities.end_image_url, and
POST /v1/pricing/estimate refuses the same combinations
(and honours provider.only /
allow_fallbacks: false when quoting).
Uploading a file
Every image/video/audio field above — start_image_url
and every input_references /
input_video_references /
input_audio_references entry below —
takes either a public https:// URL or an
inline data:image/...;base64,... URI, so
if you already have the file hosted somewhere, or you're fine base64-encoding it inline, you don't need
anything below this point. If you'd rather not do either — the file is only local, or base64 would
inflate a large image/video past a reasonable request size — POST /v1/uploads
stages it in storage for you and hands back a URL to pass into the field instead.
import requests
# 1. upload the file, get back a URL
with open("photo.jpg", "rb") as f:
upload = requests.post(
"https://videorouter.sh/api/v1/uploads",
headers={"Authorization": "Bearer llmr_sk_live_..."},
files={"file": f},
).json()
# -> {"url": "https://...", "expires_in": 1800}
# 2. use that URL right away as start_image_url (or an input_references entry)
job = requests.post(
"https://videorouter.sh/api/v1/videos",
headers={"Authorization": "Bearer llmr_sk_live_...", "Content-Type": "application/json"},
json={
"model": "bytedance/seedance-2.0/fal",
"prompt": "the subject turns and smiles, gentle camera push-in",
"start_image_url": upload["url"],
},
).json()
file is a
multipart/form-data field — any
image/*,
video/*, or
audio/* content type, up to 50 MB.
The returned URL is presigned and expires in 30 minutes — plenty of time to pass it straight into your
very next /v1/videos or
/v1/images call, but it's scratch
space for that one call, not somewhere to keep a file — upload again for each new job.
Reference-to-video
Distinct from image-to-video above — not just a bigger version of it. Some models
(bytedance/seedance-2.0,
minimax/h3/fal,
minimax/h3/atlas-cloud) can
composite multiple reference files of three different kinds in one
call: up to 9 images, 3 videos, and 3 audio clips (12 combined) — same model id as
text/image-to-video above, mode picked automatically by which fields you send, no separate
"-reference" id to look up. Two new request fields extend the same
{"type", "<kind>_url": {"url"}}
convention input_references already
uses, just for video and audio:
import requests
job = requests.post(
"https://videorouter.sh/api/v1/videos",
headers={"Authorization": "Bearer llmr_sk_live_...", "Content-Type": "application/json"},
json={
"model": "bytedance/seedance-2.0/fal",
"prompt": "the cat from the reference image walks across the scene",
"input_references": [
{"type": "image_url", "image_url": {"url": "https://example.com/cat.jpg"}}
],
"input_video_references": [
{"type": "video_url", "video_url": {"url": "https://example.com/motion-ref.mp4"}}
],
"input_audio_references": [
{"type": "audio_url", "audio_url": {"url": "https://example.com/ambience.mp3"}}
],
"duration_secs": 5,
},
).json()
All three arrays are optional individually, but at least one reference across all
three is required — a call with none is rejected with a 400 (pass a plain
prompt with no references to
bytedance/seedance-2.0/fal instead for
text-to-video). Exceeding any per-kind cap, or 12 combined, is also rejected with a 400.
MachGen's reference-to-video rows (shengshu/vidu-q3/machgen,
minimax/h3/machgen) are
narrower — images only, up to 7, no input_video_references/input_audio_references
support at all. Pass input_references
the same way, just omit the other two arrays.
Referring to references in the prompt
Each reference is named by its 1-based position in its own array — the first
input_references entry is image 1, the first input_video_references entry is
video 1, and so on. Write that name in the prompt in the syntax the model family reads:
| Model family | Tag in the prompt |
|---|---|
Seedance 2.x (seedance-2.0, -fast, -mini, seedance-2.5) | @Image1 @Video1 @Audio1 |
| MiniMax H3 | @image1 @video1 @audio1 |
| Wan 3.0 | Image 1 Video 1 (no @) |
| HappyHorse | [Image 1] [Video 1] |
Example: "@Image1 walks into the room from @Image2, to the music of @Audio1" with two entries in
input_references and one in input_audio_references. Other model families read plain prose
("the cat from the reference image") and need no tag.
MachGen's Seedance 2.x rows treat an @ in the prompt as a directive that must point at a
named reference, so for those rows VideoRouter binds the positional tags above to the matching
array entry for you — you don't send any name fields. Any other @ — a custom handle such as
@Alice, a tag beyond the number of references you sent (such as @Image9), or a lone
@ — has no reference to point at, so it is sent as plain text with the @ removed.
Custom names are not supported; use the positional tags.
Video editing
Different from reference-to-video above — this modifies an existing clip instead of
guiding a new generation. Pass the clip as input_video_url
(a single URL, not an array) alongside a prompt
describing the edit — it's mutually exclusive with
start_image_url/input_references/input_video_references
in the same call.
import requests
job = requests.post(
"https://videorouter.sh/api/v1/videos",
headers={"Authorization": "Bearer llmr_sk_live_...", "Content-Type": "application/json"},
json={
"model": "xai/grok-imagine-video-edit/atlas-cloud",
"prompt": "Adjust the video style to an American comic book style.",
"input_video_url": "https://example.com/source-clip.mp4",
},
).json()
Currently wired: xai/grok-imagine-video-edit/atlas-cloud
(xAI Grok Imagine, billed per second of the input clip — output length always matches input,
capped at 8.7s), kuaishou/kling-v3-omni-video/replicate
(send input_video_url instead of
input_video_references to switch this
model from reference/style mode into true editing mode),
runway/aleph-2/runway (Runway's Aleph 2.0,
input capped at 30s), and pika/pikadditions/pika
(flat $0.03/request, object-insertion-style edits), and
black-forest-labs/flux-3-edit-video (Black Forest Labs'
FLUX Video Edit, direct — $0.03 per second of output, which always matches the input length; also
available via Fal as black-forest-labs/flux-3-edit-video/fal).
To continue a clip instead of modifying it, see Video extension.
Seedance 2.5
Everything specific to Seedance 2.5 now lives on its own page: Seedance 2.5.
- Reference videos: task_type — reference, edit, extend and auto, and what the default is.
- Which hosts support what
- Examples
- “Seedance read this prompt as a video edit” and other errors
- Draft mode — 480p previews, then a 1080p final of the same take.
Retention & deleting videos
A finished video is copied into our storage and served from a stable link, then deleted automatically after 30 days. You can end that sooner, per video or per request:
DELETE /v1/videos/{id}removes the stored file now. The job then answers404. It returns409while the video is still rendering, and403(delete_not_allowed_yet) during the first 24 hours after generation."store": falseon the create request deletes the output 24 hours after the job completes. It is a short lifetime, not "never stored": the result has to be stored to be delivered to you."output_ttl_secs"sets that lifetime explicitly: 86400 (24 hours) to 2592000 (30 days). A smaller value is raised to 24 hours.
# keep the output for 2 days after it completes
curl https://videorouter.sh/v1/videos \
-H "Authorization: Bearer $VIDEOROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "bytedance/seedance-2.0/fal", "prompt": "A paper boat drifting down a rainy street", "output_ttl_secs": 172800}'
# delete one now (needs to be at least 24h old)
curl -X DELETE https://videorouter.sh/v1/videos/$VIDEO_ID \
-H "Authorization: Bearer $VIDEOROUTER_API_KEY"
Once the job has completed it reports output_expires_at (Unix seconds), and after that time
GET /v1/videos/{id} answers 410 output_expired. Deleting never changes what you
were charged, and the request-log entry is kept for billing. Every lifetime, status code and what deletion does not
reach (the model provider’s own copy) is on Data retention.
Webhooks
Add webhook_url to POST /v1/videos (or save it once as your default in Org admin → Webhooks) and we POST the finished job to that URL the moment it completes or fails — the
identical object GET /v1/videos/{id} would return at that point (status, data, error, cost_usd). It works the same on
POST /v1/lipsync and POST /v1/face-swap. Polling is never switched off: a webhook is a push on top of it, so a missed delivery
never strands a job.
X-VideoRouter-Eventvideo.completed or video.failed. Sent once, when the job reaches that state.X-VideoRouter-SignatureHex HMAC-SHA256 of the raw request body, keyed with your organization's webhook secret (Org admin → Webhooks, the same secret as Async Jobs). Verify it before trusting the payload, against the raw bytes — not re-serialized JSON.X-VideoRouter-DeliveryThe job id. Use it to de-duplicate: delivery is at-least-once.- Acknowledge with any 2xx. Anything else (or a timeout after 10s) is retried with exponential backoff, 6 attempts in total, up to about 30 minutes apart. Every retry carries identical bytes and an identical signature.
cost_usdis what the job was charged (0for a failed, refunded job). A completed job is held back for up to two minutes if the host's final price hasn't landed yet, then sent without it — poll for it afterwards.- The URL must be
https://and resolve to a public address. Private, loopback, and link-local targets are rejected with400 invalid_webhook_urlbefore anything is billed, and checked again before every delivery. Redirects are not followed. - Set it once. Save a default URL under Org admin → Webhooks and every video, lip-sync and face-swap job made with an API key uses it, so you can leave
webhook_urlout of the request. Awebhook_urlon the request wins over the default;"webhook_url": nullskips webhooks for that one request. Creator Studio generations never use it. - One endpoint for everything. The same default is used by Async Jobs. Route on the
X-VideoRouter-Eventheader:video.completed/video.failedhere,job.*for Jobs. - See what happened. The last 25 deliveries, with their status and failure reason, are listed on the same Org admin → Webhooks page.
- If a job's output is deleted (
store: false,output_ttl_secs, or an explicit delete) before it finishes, no webhook is sent.
Billing
The full cost is billed once, at job creation, from the requested duration_secs —
GET /v1/videos/{id} status polling has
no incremental provider cost and is unbilled; once the job has finished, the response (and the webhook) carries
cost_usd, what it was charged. Like every other mode, this endpoint carries the flat
2.5% platform fee on top of provider cost — see Pricing & billing.
Exception: SiliconFlow's two siliconflow/-prefixed
models bill a flat $0.29 per clip regardless of any duration_secs
you request — SiliconFlow's own API ignores duration and always renders a fixed ~5s clip, so any
duration_secs in the request body for
these two models is ignored on our side too.
Fallback & failover
A rejected submission automatically fails over to the next-cheapest host still serving the same
checkpoint — free, on by default, no parameter needed. For a job that's already been accepted and then
stalls or fails mid-generation, pass failover.on_timeout_sec to
hedge to a second attempt (both attempts get billed). Full details, triggers, and request examples:
Provider selection.
Falling back to a genuinely different model instead of another host of the same one:
Model fallbacks.
Not yet supported
Video editing is wired for 5 models only (see Video editing above), and video extension for one model. Video as chat input isn't supported either.
Image-to-video is wired for Fal, Atlas Cloud, Replicate, WaveSpeedAI, Novita, and MachGen. Reference-to-video (multiple image/video/audio inputs at once) is wired for Fal, Atlas Cloud, and MachGen. Kling and Luma each have their own image-to-video capability upstream, but it isn't wired into this endpoint yet.