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_..." # Settings > 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.
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. On any other model
it is rejected with a 400 (naming the models that do support it) rather than silently ignored.
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.
Draft mode (Seedance 2.5)
Iterate cheaply, pay for 1080p once. Send "draft": true and Seedance 2.5 renders a
480p preview at roughly a quarter of the 1080p price. Re-roll as many drafts as you like;
when one is right, send its id as draft_id and the same take is
re-rendered at 1080p — same prompt, references, seed and audio setting, so the final
keeps the exact composition and motion you approved.
# 1. 480p draft
curl https://videorouter.sh/api/v1/videos \
-H "Authorization: Bearer llmr_sk_live_..." -H "Content-Type: application/json" \
-d '{"model": "bytedance/seedance-2.5/atlas-cloud", "prompt": "A red balloon drifts up against a blue sky",
"duration_secs": 5, "draft": true}'
# -> {"id": "atlascloud:abc...", "draft": true, "draft_expires_at": 1791659164, ...}
# 2. poll GET /v1/videos/{id} until "completed", watch the 480p preview, then:
curl https://videorouter.sh/api/v1/videos \
-H "Authorization: Bearer llmr_sk_live_..." -H "Content-Type: application/json" \
-d '{"draft_id": "atlascloud:abc..."}'
# -> a NEW job id (with "from_draft"); poll it for the 1080p video
- Billing: the draft bills as a normal 480p render and the final as a normal 1080p render (each at the host's real cost + the platform fee). Drafting pays off when you would otherwise generate several 1080p takes to find a good one — a single draft + final costs a little more than rendering 1080p directly.
- Window: a draft can be finalized within 7 days (
draft_expires_at), once, and only after it has completed. A failed final can be retried. - Final request: carries only
draft_id— no prompt, images, duration or resolution (they're fixed by the draft). - Host pinning: a draft only exists on the host that rendered it, so the final always runs there and never fails over. Drafts route only to hosts that support draft mode; text-to-video and image-to-video only (no reference/edit inputs).
Seedance specific
Notes that apply to the Seedance family (seedance-2.0, seedance-2.5),
on top of the sections above. Draft mode has its own section; this one covers reference videos.
Reference videos: task_type
When a call includes input_video_references, Seedance has to decide what the job is:
a new video guided by your clip, an edit of the clip, or an
extension of it. Set task_type to choose, or leave it on auto
and let the prompt decide. If no available host can honor the task_type you pin, the call returns
400 task_type_not_supported.
task_type: "auto" default, when task_type is not sent
- Seedance reads your prompt and picks new video, edit or extension.
- Your
duration_secsandaspect_ratioare sent as you gave them. If the prompt reads as an edit, Seedance rejects them (an edit has no length or ratio of its own) and the job fails, sometimes only after a few minutes. Failed jobs are not charged. - Exception: a single reference video with
duration_secs: -1is treated asedit. - On hosts that can't pin a task (TOAPIS, OpenSand including the
-unrestrictedrows, and SoCheap Official) a reference video always drives length and ratio: we send the “match the source” value ourselves, so yourduration_secs/aspect_ratioare not used there. On Atlas Cloud, Fal, MachGen and WaveSpeed they are sent as you gave them. - Use it when you don't care which of the three you get.
task_type: "edit" modify the clip you send
- Changes the clip itself: replace a person, restyle, swap an object. Exactly one reference clip is required.
- The output follows the clip: same length, same aspect ratio. Leave out
duration_secsandaspect_ratio; if you send them they are ignored. - Pinning
editis reliable on Atlas Cloud, Fal, MachGen, TOAPIS and WaveSpeed. - Billing: the clip's seconds (rounded up) plus the output seconds, at the host's real cost plus the platform fee. The estimate uses the probed length of your clip (30 s if it can't be probed); the final charge is the host's actual cost.
task_type: "reference" generate a new video
- A new video guided by your clip, images and audio. The clip is not changed.
- You choose
duration_secsandaspect_ratio. - Write the prompt as “use @Video1 as a motion reference, generate…”. Don't ask to change, replace or recreate what is in the clip.
- Best-effort: on some hosts (for example MachGen) the model can still read the prompt as an edit. TOAPIS, WaveSpeed and OpenSand can't pin it at all.
- Don't send
duration_secs: -1(it means “match the clip”): it returns400 duration_task_type_conflict.
task_type: "extend" continue the clip
- Continues the clip. Exactly one reference clip is required.
- Aspect ratio follows the clip; you choose how long to generate with
duration_secs. - Pinning
extendis reliable on Atlas Cloud, Fal, MachGen, TOAPIS and WaveSpeed. - Don't send
duration_secs: -1(it means “match the clip”): it returns400 duration_task_type_conflict.
Example: edit a clip.
curl https://videorouter.sh/api/v1/videos \
-H "Authorization: Bearer llmr_sk_live_..." -H "Content-Type: application/json" \
-d '{"model": "bytedance/seedance-2.5/atlas-cloud",
"prompt": "@Video1 is the reference. Replace the man with the character in @Image1.",
"task_type": "edit",
"input_references": [{"type": "image_url", "image_url": {"url": "https://example.com/hero.jpg"}}],
"input_video_references": [{"type": "video_url", "video_url": {"url": "https://example.com/clip.mp4"}}]}'
"Seedance read this prompt as a video edit"
If you send a fixed duration_secs / aspect_ratio and the prompt reads as an
edit, Seedance rejects the job, sometimes only after a few minutes. Either resend with
"task_type": "edit" and without those two fields, or reword the prompt so the clip is only a
reference. A failed job is not charged.
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 Settings → 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 (Settings → 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 Settings → 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 Settings → 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.