How to Call the Seedance API (Python + curl, 3 Providers)
This is a working integration, not pseudocode — every request shape below is the real shape VideoRouter's /v1/videos endpoint accepts for ByteDance's Seedance models. Video generation is async and job-based across every provider that hosts it: you submit a job, poll for its status, and pull the result once it completes. There's no synchronous "generate and get the video back in one call" mode for any video model, Seedance included, because generation genuinely takes longer than an HTTP request timeout would tolerate.
Text-to-video
import time
import requests
resp = requests.post(
"https://videorouter.sh/api/v1/videos",
headers={"Authorization": "Bearer llmr_sk_live_...", "Content-Type": "application/json"},
json={
"model": "bytedance/seedance-2.0",
"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"]
else:
raise RuntimeError(job["error"])
Equivalent curl:
job=$(curl -s https://videorouter.sh/api/v1/videos \
-H "Authorization: Bearer llmr_sk_..." \
-H "Content-Type: application/json" \
-d '{"model": "bytedance/seedance-2.0", "prompt": "a paper airplane gliding over a city", "duration_secs": 8}')
id=$(echo "$job" | jq -r .id)
status=$(echo "$job" | jq -r .status)
while [ "$status" != "completed" ] && [ "$status" != "failed" ]; do
sleep 5
job=$(curl -s "https://videorouter.sh/api/v1/videos/$id" -H "Authorization: Bearer llmr_sk_...")
status=$(echo "$job" | jq -r .status)
done
echo "$job" | jq -r 'if .status == "completed" then .data[0].url else .error end'
Resolution and 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 specific size:
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",
"prompt": "a paper airplane gliding over a city",
"resolution": "720p",
"aspect_ratio": "16:9",
"duration_secs": 8,
},
).json()
An unrecognized or unsupported resolution/aspect-ratio combination never 400s — it's silently ignored and the model's default is used instead, same as omitting both. If you need to guarantee a specific size, check the model's supported tiers against Seedance 2.5 API Pricing: Every Provider Compared or Where to Get the Cheapest Seedance API first, since not every host supports every tier.
Image-to-video: animating a starting frame
Pass start_image_url — a public https:// URL or an inline data:image/...;base64,... URI — to animate a starting frame instead of generating from a blank prompt. Omit it and you get plain text-to-video; pass it and Seedance animates that frame:
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",
"prompt": "the subject turns and smiles, gentle camera push-in",
"start_image_url": "https://example.com/photo.jpg",
"duration_secs": 5,
"resolution": "720p",
"aspect_ratio": "16:9",
},
).json()
If you don't already have the source image hosted somewhere public, POST /v1/uploads stages a local file and hands back a URL to use directly:
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}
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",
"prompt": "the subject turns and smiles, gentle camera push-in",
"start_image_url": upload["url"],
},
).json()
Pinning to a specific provider
By default, a request with no provider field routes automatically to a healthy deployment, weighted toward cheaper hosts. If you want to guarantee a specific host — say, MachGen, currently the cheapest verified host for Seedance (see Seedance 2.5 API Pricing) — suffix the model string with the provider name:
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/machgen",
"prompt": "a paper airplane gliding over a city",
"duration_secs": 8,
},
).json()
The confirmed provider suffixes for Seedance are fal, machgen, atlascloud, replicate, wavespeed, and openrouter — matching the hosts in the pricing table linked above. Pinning trades away automatic failover to a second host if your chosen one has an outage; the default (no pin) retries across healthy deployments automatically, which is usually the better default unless you have a specific reason to force one host.
Handling errors correctly
Every error VideoRouter returns follows the same envelope shape as the OpenAI API — {"error": {"message", "type", "code"}} — regardless of which underlying provider Seedance routed to. The status codes that actually come up in a Seedance integration:
| Status | type / code | What it means |
|---|---|---|
| 400 | invalid_request_error |
Missing prompt, or a model id that doesn't exist in the catalog |
| 401 | invalid_api_key |
Key is missing, malformed, revoked, or expired |
| 402 | spend_cap_exceeded / insufficient_credits |
This key's monthly cap is hit, or your org's prepaid balance is ≤ $0 |
| 403 | model_not_allowed |
The requested model isn't in this key's model_allowlist |
| 429 | rpm_limit / tpm_limit |
This key's rate limit is exceeded — a Retry-After header tells you exactly how many seconds to wait |
| 500 / 502 / 503 / 504 | upstream_error |
Every candidate host in the fallback chain failed — you are not billed for this |
The 429 case is worth handling explicitly rather than retrying blindly:
import time
import requests
def submit_seedance_job(payload, max_retries=3):
for attempt in range(max_retries):
resp = requests.post(
"https://videorouter.sh/api/v1/videos",
headers={"Authorization": "Bearer llmr_sk_live_...", "Content-Type": "application/json"},
json=payload,
)
if resp.status_code == 429:
wait = int(resp.headers.get("Retry-After", 5))
time.sleep(wait)
continue
resp.raise_for_status()
return resp.json()
raise RuntimeError("exceeded retry budget")
Note that a 500/502/503/504 on the creation call means every host in Seedance's fallback chain failed for that specific request — not that Seedance is universally down. Given how many hosts serve Seedance (see Seedance 2.5 API Pricing and Where to Get the Cheapest Seedance API), this is rare in practice; when it happens, VideoRouter does not bill you for the failed attempt, so a straightforward retry of the same request is the correct response rather than treating it as a hard failure to surface to a user.
Rate limits
RPM and TPM limits are enforced per API key as a smooth token-bucket rate rather than a hard per-minute cliff, and video generation counts against the same key-level limits as your other traffic. If you're running a batch of Seedance generations — say, regenerating drafts across a large prompt set — submitting all of them as fast as possible and handling 429s with the Retry-After-driven backoff above is more efficient than pre-emptively rate-limiting yourself to a guessed-safe rate, since the token bucket already tells you exactly how long to wait when you do hit it.
Putting it together: a production-ready wrapper
The snippets above are correct individually, but a real integration needs them combined into one function that handles retries, polling, and failure without the caller needing to reimplement any of it:
import time
import requests
API_BASE = "https://videorouter.sh/api/v1"
API_KEY = "llmr_sk_live_..."
def generate_seedance_video(prompt, model="bytedance/seedance-2.0", **kwargs):
headers = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
payload = {"model": model, "prompt": prompt, **kwargs}
for attempt in range(3):
resp = requests.post(f"{API_BASE}/videos", headers=headers, json=payload)
if resp.status_code == 429:
time.sleep(int(resp.headers.get("Retry-After", 5)))
continue
resp.raise_for_status()
job = resp.json()
break
else:
raise RuntimeError("exceeded retry budget on job creation")
deadline = time.time() + 600 # 10-minute safety timeout
while job["status"] not in ("completed", "failed"):
if time.time() > deadline:
raise TimeoutError(f"job {job['id']} did not complete within 10 minutes")
time.sleep(5)
job = requests.get(f"{API_BASE}/videos/{job['id']}", headers=headers).json()
if job["status"] == "failed":
raise RuntimeError(f"generation failed: {job['error']}")
return job["data"][0]["url"]
# usage
url = generate_seedance_video(
"a paper airplane gliding over a city",
resolution="720p",
aspect_ratio="16:9",
duration_secs=8,
)
The 10-minute safety timeout matters in production even though most Seedance jobs complete far faster than that — without it, a job that gets stuck in in_progress (rare, but not impossible across eleven-plus hosts and their own upstream infrastructure) polls forever and silently blocks whatever code path called this function.
Common mistakes
Forgetting to poll with a status check on both "completed" and "failed". A job that fails still returns 200 from the status endpoint with status: "failed" and an error field — checking only for "completed" in a while loop without an exit condition on failure will poll forever against a job that's never going to complete.
Assuming resolution/aspect_ratio always applies. As noted above, unsupported combinations are silently ignored rather than rejected — always verify the video you got back matches what you asked for if the exact size matters to your pipeline.
Not handling the OpenAI SDK's lack of video support. If you're already using the OpenAI Python SDK for chat completions elsewhere in the same codebase, it doesn't have first-class video generation methods yet — use its low-level client.post/client.get, or plain requests, against the same base_url rather than looking for a client.videos.generate() method that doesn't exist.
See /docs/video-generation for the complete reference, including reference-to-video (guiding generation from multiple images, videos, and audio clips at once).
Frequently asked questions
Can I get the video back synchronously, without polling? No — every host serving Seedance generates asynchronously, because generation genuinely takes longer than a single HTTP request should stay open for. There's no stream: true equivalent for video the way there is for chat completions; the job/poll pattern above is the only supported flow.
How long does a Seedance generation actually take? It varies by host, resolution, and duration, and isn't something tracked in VideoRouter's own registry the way price is — don't take a fixed number from any article (including this one) as a guarantee. In practice, budget for anywhere from tens of seconds to a few minutes for an 8-second clip, and poll on a 5-10 second interval rather than assuming a fixed completion time.
What happens if I don't pass a resolution? The model's default resolution is used — check the specific host's listing if you need a guaranteed size, since defaults aren't necessarily identical across every host serving Seedance.
Can I cancel a job after submitting it? Check /docs/video-generation and /docs/async-jobs for the current state of job cancellation — this is the kind of operational detail that's worth verifying against the live docs rather than a blog post, since it's more likely to change than pricing.
Does pinning a provider change the price I'm quoted? Yes — the whole reason to pin explicitly is that different hosts charge different rates for the identical model (see Seedance 2.5 API Pricing: Every Provider Compared). Pinning doesn't change VideoRouter's own fee structure, only which upstream host's rate applies to the request.