Discord

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 familyTag in the prompt
Seedance 2.x (seedance-2.0, -fast, -mini, seedance-2.5)@Image1 @Video1 @Audio1
MiniMax H3@image1 @video1 @audio1
Wan 3.0Image 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_secs and aspect_ratio are 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: -1 is treated as edit.
  • On hosts that can't pin a task (TOAPIS, OpenSand including the -unrestricted rows, and SoCheap Official) a reference video always drives length and ratio: we send the “match the source” value ourselves, so your duration_secs/aspect_ratio are 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_secs and aspect_ratio; if you send them they are ignored.
  • Pinning edit is 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_secs and aspect_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 returns 400 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 extend is reliable on Atlas Cloud, Fal, MachGen, TOAPIS and WaveSpeed.
  • Don't send duration_secs: -1 (it means “match the clip”): it returns 400 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 answers 404. It returns 409 while the video is still rendering, and 403 (delete_not_allowed_yet) during the first 24 hours after generation.
  • "store": false on 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_usd is what the job was charged (0 for 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 with 400 invalid_webhook_url before 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_url out of the request. A webhook_url on the request wins over the default; "webhook_url": null skips 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-Event header: video.completed / video.failed here, 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.