Home › Guides › How to call Seedance

Calling Seedance 2.5 from curl and Python, step by step

Updated 2026-10-02

Seedance 2.5 is called through one endpoint, POST https://videorouter.sh/api/v1/videos, using the model id bytedance/seedance-2.5. Video generation is asynchronous: you create a job, poll it, and read the file URL when it completes. This page is the shortest path from an API key to a downloaded clip, plus the parts that bite in production: host pinning and error handling.

Step 1: create the job with curl

curl https://videorouter.sh/api/v1/videos \
  -H "Authorization: Bearer llmr_sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "model": "bytedance/seedance-2.5",
    "prompt": "a paper airplane gliding over a city at dusk, slow tracking shot",
    "duration_secs": 5,
    "resolution": "720p",
    "aspect_ratio": "16:9"
  }'

The response is immediate and carries a job, not a video: an id, a status of queued, and the provider that accepted it. The full cost is billed once at this moment, from the requested (snapped) duration. Nothing after this point charges you.

Step 2: poll until it finishes

curl https://videorouter.sh/api/v1/videos/video_abc123 \
  -H "Authorization: Bearer llmr_sk_live_..."

Status moves queued, in_progress, then completed or failed. Polling is free, so poll every few seconds without worrying about cost. On completion the file URL is at data[0].url; on failure error carries the provider's message and data is null. A job that fails upstream is not billed.

The same flow in Python

import time
import requests

BASE = "https://videorouter.sh/api/v1"
HEADERS = {"Authorization": "Bearer llmr_sk_live_...", "Content-Type": "application/json"}

def generate(prompt, model="bytedance/seedance-2.5", timeout=900, **fields):
    payload = {"model": model, "prompt": prompt, **fields}
    for _ in range(3):
        r = requests.post(f"{BASE}/videos", headers=HEADERS, json=payload)
        if r.status_code == 429:
            time.sleep(int(r.headers.get("Retry-After", 5)))
            continue
        r.raise_for_status()
        job = r.json()
        break
    else:
        raise RuntimeError("rate limited on every attempt")

    deadline = time.time() + timeout
    while job["status"] not in ("completed", "failed"):
        if time.time() > deadline:
            raise TimeoutError(f"job {job['id']} still running; do not resubmit")
        time.sleep(5)
        job = requests.get(f"{BASE}/videos/{job['id']}", headers=HEADERS).json()

    if job["status"] == "failed":
        raise RuntimeError(job["error"])
    return job["data"][0]["url"]

url = generate("a paper airplane gliding over a city", duration_secs=5, resolution="720p")

Two details in that wrapper matter. It never re-submits on a timeout, because a second submission is a second charge and polling costs nothing. And it stores nothing about the file: copy the finished video to your own storage rather than treating the returned URL as permanent.

Choosing a variant

The variants share one request shape; only the model string changes. The ids to know are bytedance/seedance-2.5, bytedance/seedance-2.0, bytedance/seedance-2.0-fast and bytedance/seedance-2.0-mini. That makes a draft-then-final pipeline a config change: iterate on a lighter variant, then re-run approved prompts on 2.5. See the variant guide for the trade-offs. Some variants accept modes that others do not (start images, reference arrays), so confirm on the model pages before building on one.

Pinning a host

Seedance is sold by many hosts at different prices. With no preference, VideoRouter picks the cheapest healthy host and walks down the list if a submission is rejected. To prefer one host, suffix the id: bytedance/seedance-2.5/fal or bytedance/seedance-2.5/machgen. The host slugs are listed in the host table on each model page.

The suffix is a soft preference. If that host rejects the request, the router still falls back to others. For a hard pin with no fallback, use the provider object instead:

{
  "model": "bytedance/seedance-2.5",
  "prompt": "...",
  "provider": {"only": ["fal"], "allow_fallbacks": false}
}

With allow_fallbacks false you get exactly one attempt and an immediate error if it fails, so you give up failover in exchange for certainty. Pin when a capability exists only on certain hosts (reference video and audio, for example) or when you have a policy reason; otherwise let it float. The live table shows how wide the price gap between hosts is right now:

ModelCheapest hostPriciest hostCheapest isHosts
bytedance/seedance-2.5 (480p)OpenSand
$0.0525 / second
Fal-US
$0.2646 / second
80% lower9
bytedance/seedance-2.0 (2160p)MachGen
$0.59 / second
Fal
$1.5552 / second
62% lower9
bytedance/seedance-2.0-fast (480p)Atlas Cloud
$0.027 / second
Fal
$0.2419 / second
89% lower9
bytedance/seedance-2.0-mini (480p)OpenSand
$0.0104 / second
Fal
$0.0721 / second
86% lower8
seedance-2-mini-unrestricted (480p)OpenSand
$0.0114 / second
SandBase
$0.0721 / second
84% lower3
seedance-2-5-unrestricted (1080p)OpenSand
$0.3482 / second
TOAPIS
$0.5881 / second
41% lower2
seedance-2.0-fast-unrestricted (480p)OpenSand
$0.0344 / second
SandBase
$0.0448 / second
23% lower2
seedance-2-unrestricted (2160p)OpenSand
$0.661 / second
TOAPIS
$0.7966 / second
17% lower2

Per second, before VideoRouter's 2% platform fee. For tiered models each row compares the resolution tier with the widest host-to-host gap. Built 2026-10-02 from the live catalog.

Errors you will actually see

Every error uses the OpenAI-style envelope {"error": {"message", "type", "code"}}, regardless of which host was involved.

StatusMeaningWhat to do
400 invalid_request_errorMissing prompt, unknown model id, or an input field the model does not acceptFix the request; retrying will not help
401 invalid_api_keyKey missing, malformed, revoked or expiredCheck the key
402Monthly key cap reached, or prepaid balance exhaustedTop up or raise the cap
403 model_not_allowedModel is outside this key's allow-listAdjust the key's allow-list
429Per-key rate limitWait for the Retry-After header, then retry
500, 502, 503, 504 upstream_errorEvery candidate host failed for this requestSafe to retry; failed attempts are not billed

Distinguish a failed creation call (above) from a job that is accepted and later reports failed. The second still arrives as a normal 200 response with status: "failed", so check the status field, not just the HTTP code. For jobs that stall after acceptance, failover.on_timeout_sec can hedge to a second host, but both attempts are billed if both finish, so use it deliberately.

Common first-call mistakes

For the full parameter list continue with Seedance 2.5 parameters explained, or create an API key and run the curl above. The quickstart has the same flow for other models.

Frequently asked questions

What is the model id for Seedance 2.5?

It is bytedance/seedance-2.5. The 2.0 variants are bytedance/seedance-2.0, -fast and -mini, and all use the same request shape.

Is the Seedance API synchronous?

No. POST /videos returns a job, and you poll GET /videos/{id} through queued and in_progress until completed or failed. Polling is free.

How do I force a specific host?

Suffix the model id, for example bytedance/seedance-2.5/fal, for a soft preference. For a hard pin with no fallback use provider: {only: [...], allow_fallbacks: false}.

Am I charged if the job fails?

Jobs that fail upstream are not billed. A successful job is billed once at creation from the requested duration, plus a 2% platform fee.

Keep reading

Using Seedance is one part of the job.

VideoRouter puts it next to dozens of other video and image models behind one API key, so you can compare providers, prices and fail over automatically. Compare providers on VideoRouter →