Video Generation API Polling: 202, polling_url, Wait Times (2026)

POST returns 202 in 0.8s, then nothing for 83 to 253 seconds. Eight identical 5s jobs, a 3x spread. Cancel fails once it runs, and the result URL dies in 24h.

Video Generation API Polling: 202, polling_url, Wait Times (2026)

The 202 arrives in under a second. The video takes another two to four minutes, and how long is not something you can predict from the request. Everything hard about video generation APIs lives in the gap between those two facts.

Submit:        POST /v1/videos -> 202 in 0.82s
Response body: {id, status: "queued", polling_url}   three fields, nothing else
Wait, 5s clip: 83.3s to 253.2s across 8 identical jobs, median 104.6s
Poll limit:    5 req/s per key, burst 20, 429 + Retry-After: 1 over it
Terminal:      completed | failed | cancelled | expired   (all four, or you spin)
Cancel:        400 cancel_failed once upstream is running
Result URL:    unsigned_urls signed for 24h. mirror_urls absent on Seedance.
Measured:      2026-08-24, 13 jobs through POST /v1/videos

Last updated 2026-08-24. Timings are from one afternoon on one route and will not match yours; the shape of the distribution is the transferable part, not the seconds.

What Does POST /v1/videos Actually Return?

A 202 with three fields. No video, no percentage, no estimate:

{
  "id": "5e6f69b1-8ffe-430c-a687-8241366a90f5",
  "status": "queued",
  "polling_url": "https://api.ofox.ai/v1/videos/5e6f69b1-8ffe-430c-a687-8241366a90f5"
}

That call took 0.82 seconds. The polling_url is a convenience: it is the same GET /v1/videos/{id} you would build yourself, and the Create Video reference says as much. Use the field rather than string-building the URL, because the id format is a UUID today and there is no promise it stays one.

While a job is in flight, the status body is deliberately thin:

{"id": "...", "status": "in_progress", "model": "bytedance/seedance-2.0-mini",
 "prompt": "...", "created_at": 1787567608, "updated_at": 1787567608}

There is no progress field to render a bar from. If your UI needs one, it has to be a fake based on elapsed time against a historical median, and after reading the next section you will understand why that bar has to be honest about being a guess.

When the job completes, two keys appear: unsigned_urls and usage.

Terminal session showing a POST to the ofox video endpoint returning HTTP 202 in 0.797 seconds, a poll loop printing queued at 1.2 seconds, in_progress at 15.2 seconds and completed at 105.9 seconds, the completed body with usage video_cost 0.08, then a 404 for an unknown id and a 400 for a private-network callback URL

One 4-second 480p clip, start to finish. The 8-job table below is a separate set of 5-second clips, so read the 105.9 seconds here as one more sample rather than a row of that table.

How Long Does a Video Generation Job Actually Take?

83 to 253 seconds for the same request. Eight jobs, all 5-second 480p clips on bytedance/seedance-2.0-mini, all through one key on one afternoon:

RunWhat it wasSeconds to terminal
1text-to-video, 16:983.3
2text-to-video, 9:16, one of three submitted together85.1
3two reference images95.6
4first and last frame, ratio set103.9
5first and last frame, no ratio105.3
6text-to-video, 9:16, one of three submitted together126.7
7text-to-video, 16:9139.9
8text-to-video, 9:16, one of three submitted together253.2

Median 104.6 seconds. Slowest over fastest: 3.0x. The three slowest and the three fastest are not separated by anything in the request; runs 2, 6 and 8 are the same model, the same duration, the same resolution and the same aspect ratio, submitted in the same second, and they finished 85s, 127s and 253s later.

Two practical consequences.

Set the timeout at the tail. A 120-second client timeout would have killed run 8 with the job still generating upstream and still billing. We use 900 seconds as a hard ceiling and treat anything past 300 as worth logging.

Do not promise an ETA. A queue of clips finishes when it finishes. If your product shows a countdown, base it on a rolling median of your own recent jobs and let it overrun rather than lie.

Larger models are not automatically slower, which surprises people. On the same afternoon, a 5-second 480p job on bytedance/seedance-2.5 finished in 53.1 seconds, faster than every Mini run above, while its first-and-last-frame variant took 212.9 seconds. Mode moves the number more than model tier does. The first and last frame walkthrough has the rest of that comparison.

How Often Should You Poll?

Every 2 to 5 seconds. The status endpoint documents a per-key limit of 5 requests per second with a burst of 20; over it you get 429 rate_limited with a Retry-After: 1 header. Creating and cancelling are exempt. The docs also ask for no faster than once per second, so there is a comfortable band between “polite” and “throttled”.

At 2 to 3 second intervals across the runs in this article, no poll returned an error.

import time, requests

H = {"Authorization": "Bearer YOUR_OFOX_API_KEY"}
TERMINAL = {"completed", "failed", "cancelled", "expired"}

def wait(job, timeout=900, interval=3):
    t0 = time.time()
    while True:
        s = requests.get(job["polling_url"], headers=H).json()
        if s["status"] in TERMINAL:
            return s
        if time.time() - t0 > timeout:
            raise TimeoutError(f"{job['id']} still {s['status']} after {timeout}s")
        time.sleep(interval)

Three things that loop gets right and most published examples get wrong. It breaks on all four terminal states. It has a ceiling, so a stuck job cannot hang a worker forever. And it reads polling_url from the submit response instead of rebuilding it.

Which Statuses Are Terminal?

Four out of seven. The documented state machine:

StatusTerminalMeaning
pendingAccepted, not yet submitted upstream
queuedSubmitted upstream, waiting in a queue
in_progressGenerating
completedVideo URLs available
failedFailed, including timeouts, which arrive as error.code: "expired"
cancelledCancelled
expiredExpired

There is no processing. Loops copied from other vendors’ SDKs often check for it and then wait forever.

In practice we never observed pending. How long queued lasts moves around: in most of our runs the first poll, 0.3 seconds after submit, already said in_progress, while the run in the screenshot above sat in queued for 14 seconds first. Neither is a bug, it is queue depth. Handle pending anyway; the state you never see in testing is the one that turns up the week you scale.

A note on the timeout case. A generation that times out arrives as failed with error.code: "expired", not as the standalone expired status, so both spellings of the same word exist and mean different things. Branch on error.code, which the error reference describes as the stable field to match on, rather than on message text.

Can You Cancel a Running Video Job?

Usually not, and the refusal is the honest answer. We submitted a job, waited six seconds, and sent DELETE /v1/videos/{id}:

{"error": {"code": "cancel_failed",
  "message": "upstream cancel failed: cancel failed: status 409, body:
    {\"error\":{\"code\":\"InvalidAction.RunningTaskDeletion\",
     \"message\":\"Cannot delete task `cgt-...` because it is currently running.\"}}"}}

Two things worth knowing before you build a stop button.

The docs describe cancel_failed as the code for a job already in a terminal state, and cancel_not_supported for providers that cannot interrupt. What we hit was neither: a running job whose provider refused the deletion, surfaced as cancel_failed carrying the upstream 409. If you branch on that code, allow for both meanings.

And at that exact moment, GET still reported the job as queued. So queued in the status body does not imply the job is cancellable, because the upstream had already started. There is no status you can read that reliably tells you a cancel will work. Try it, check for 204, and if you get a 400 assume you are paying for the clip.

The design conclusion is unpopular but simple: treat submission as the commit point. Validate the prompt, the reference images and the duration before you POST, because the moment you have an id you have probably bought the clip.

Why Did My Video URL Stop Working?

Because unsigned_urls is a signed upstream address with a lifetime. Ours came back with X-Tos-Expires=86400 in the query string, which is 24 hours, and the docs describe the field as temporary and expiring in about 24 hours.

There is a second field, mirror_urls, described as persistent and preferred, present when the provider has CDN mirroring enabled. On every Seedance response we pulled today the completed body had exactly these keys:

created_at, id, model, prompt, status, unsigned_urls, updated_at, usage

No mirror_urls. So on this model family, “prefer mirror_urls” resolves to “there is only one URL and it expires”. Download the bytes in the same worker that observed completed and put them in your own storage. Do not persist the URL to a database and call the job done, which is how a content pipeline ends up with a table of dead links a day later.

While you are there, read usage:

"usage": {"video_seconds": 5, "video_cost": "0.1000000000"}

video_cost is a string, not a number, and deliberately so: it is documented as a fixed-point 10-decimal string to avoid precision loss. Parse it as a decimal, not a float, and bill from video_seconds rather than from the duration you asked for, because a 5-second request comes back as a 5.04-second file.

How Do You Write One Polling Loop for Every Video Model?

The loop above is about 15 lines, and the reason it is worth writing carefully once is that every video vendor has invented its own version of it. One returns a job object and a separate results endpoint, one wants you to poll a URL from a header, one has a status enum with a different set of names, and one bills for the cancel you thought worked. Supporting three video models natively means three loops, three sets of terminal states and three billing edge cases, none of which is interesting work.

The clips in this post all came back through the same POST /v1/videos and the same GET /v1/videos/{id} regardless of which model generated them, which is why the wait-time table can put Seedance 2.5 and 2.0 Mini in the same column. That normalisation is what a video gateway is for; ofox’s video endpoint is the one we run on, and the property to check on any of them is that the status enum and the usage shape stay identical when you change the model field. If they do not, you still have three loops, just hidden behind one hostname.

For picking the model that goes in the loop, our guide to choosing a video generation API by use case covers the quality and price axes, and the fal against Replicate against ofox pricing comparison has the per-second numbers.

Should You Use a Webhook Instead?

If you have a public HTTPS endpoint, yes. Pass callback_url at creation and one POST arrives per task when it settles, carrying the full task object, an X-Ofox-Signature HMAC-SHA256 header and an X-Ofox-Idempotency-Key. The events map one-to-one onto the terminal states.

The validation happens at submit time, not at delivery time. We sent a private-network HTTP address and got this back immediately:

400 invalid_callback_url
"callback_url must be a public HTTPS URL: ssrf blocked: target is private,
 reserved, or scheme not allowed: scheme must be https"

That is worth knowing during development, because the natural thing to try, a localhost or LAN address, is exactly what the SSRF check rejects. Use a tunnel with a real HTTPS hostname, or poll while developing and switch to webhooks in production. Belt and braces is fine too: register the webhook and keep a slow sweeper that polls anything still open after ten minutes, since a webhook you never received is indistinguishable from a job that never finished.

References

Frequently Asked Questions

What does POST /v1/videos return?
HTTP 202 with three fields and nothing else: id, status set to queued, and polling_url. Our submit call took 0.82 seconds. There is no video, no progress figure and no estimated completion in that response, which is the point of a 202: the work has been accepted, not done.
How long does a video generation job take?
Longer than you expect and less predictably. Eight identical 5-second 480p jobs on Seedance 2.0 Mini, all on the same afternoon through the same key, finished between 83.3 and 253.2 seconds, a 3x spread with a median of 104.6. Design the timeout for the tail, not the median.
How often should you poll a video generation job?
Once every 2 to 5 seconds is plenty. The endpoint is rate-limited per API key at 5 requests per second with a burst of 20, and the docs ask you to poll no faster than once per second. Over the limit you get 429 rate_limited with a Retry-After: 1 header. Creating and cancelling are not subject to that limit.
Which video job statuses are terminal?
Four: completed, failed, cancelled and expired. The full state machine has seven states, with pending, queued and in_progress as the non-terminal ones. A polling loop that only breaks on completed and failed will spin forever on a job that was cancelled or expired.
Can I cancel a running video generation job?
Often not. Our DELETE on a job that had been running for six seconds returned 400 cancel_failed, wrapping an upstream 409 saying the task cannot be deleted because it is currently running. Cancellation depends on whether the upstream provider supports interruption, and the gateway refuses to fake a local cancel while the upstream keeps generating and billing.
Why did my generated video URL stop working?
Because unsigned_urls is a temporary signed upstream address. The one we got back carried X-Tos-Expires=86400 in its query string, so it dies 24 hours after signing. Download the file, or use mirror_urls when the provider has CDN mirroring enabled. On the Seedance responses we pulled, mirror_urls was absent entirely.
Do failed video jobs cost money?
The usage object is documented as present only when a job completes, and failed jobs we have run returned usage null. A timeout arrives as status failed with error.code set to expired, not as a distinct status.
Should I use a webhook instead of polling?
If you have a public HTTPS endpoint, yes. Pass callback_url at creation and you get one POST per task at its terminal state, with an HMAC-SHA256 signature and an idempotency key header. The URL is validated at creation time: our http:// address on a private IP was rejected immediately with 400 invalid_callback_url.