Generating a video from a prompt and polling it to completion
Submit, poll status, read the finished job. The three states that matter, the progress fields worth showing a user, and a webhook that removes the polling.
Video generation on Infrai is asynchronous and the shape is always the same: POST /v1/video/generate returns a job_id, GET /v1/video/status/{id} reports progress, and GET /v1/video/get/{id} returns the finished job with its video_url and actual cost_usd. A six-second clip takes minutes, not milliseconds, so the interesting part is what you do while you wait.
Two options: poll, or pass webhook_url and don’t.
Submit
curl -sS -X POST "https://api.infrai.cc/v1/video/generate" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"prompt": "an overhead shot of espresso being poured into a glass, slow motion",
"model": "Kling:2.5",
"resolution": "720p",
"duration_seconds": 5,
"aspect_ratio": "9:16",
"negative_prompt": "text, watermark, distorted hands",
"store": true
}'
{
"ok": true,
"data": {
"job_id": "vid_2fVc8nRqLmT4xBzY",
"state": "queued",
"model": "Kling:2.5",
"vendor": "tencent_vod",
"duration_seconds": 5,
"aspect_ratio": "9:16",
"video_url": null,
"cost_usd": null,
"created_at": "2026-09-21T03:30:00Z",
"retention_days": 7
}
}
Four fields in the request earn their place. negative_prompt is the cheapest quality lever available — listing what you don’t want removes most of the artefacts you’d otherwise regenerate for. store: true keeps the output on the platform instead of handing you a URL that expires. aspect_ratio: "9:16" is vertical, because that’s what most of this output is for. And duration_seconds is what you’re billed on, so it’s the number to keep honest.
Poll the status, not the job
curl -sS "https://api.infrai.cc/v1/video/status/vid_2fVc8nRqLmT4xBzY" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"job_id": "vid_2fVc8nRqLmT4xBzY",
"state": "running",
"progress_pct": 42,
"eta_seconds": 95,
"current_step": "denoising"
}
}
progress_pct, eta_seconds and current_step exist so your UI can say something true. A progress bar driven by a real percentage and an ETA is a different experience from a spinner, and current_step is the difference between “still working” and “queued behind other jobs”.
The status endpoint is the light read; GET /v1/video/get/{id} is the full record. Poll the first, fetch the second once.
The loop
import os
import time
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
TERMINAL = {"succeeded", "failed", "cancelled"}
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
def submit(prompt: str, seconds: int = 5, model: str = "Kling:2.5") -> str:
resp = SESSION.post(
f"{API}/v1/video/generate",
json={"prompt": prompt, "model": model, "resolution": "720p",
"duration_seconds": seconds, "aspect_ratio": "9:16", "store": True},
timeout=60,
)
body = resp.json()
if not body.get("ok"):
raise RuntimeError(body["error"]["code"])
return body["data"]["job_id"]
def poll(job_id: str, timeout_seconds: int = 1200) -> dict:
"""Back off as the job runs: a generation that takes three minutes does not
need to be asked forty times. Start at 5s, ease toward 20s, give up at the
deadline rather than looping forever."""
deadline = time.monotonic() + timeout_seconds
interval = 5
while time.monotonic() < deadline:
status = SESSION.get(f"{API}/v1/video/status/{job_id}", timeout=25)
status.raise_for_status()
state = status.json()["data"]
if state.get("state") in TERMINAL:
final = SESSION.get(f"{API}/v1/video/get/{job_id}", timeout=25)
final.raise_for_status()
return final.json()["data"]
print(f"{state.get('state')} {state.get('progress_pct')}% "
f"eta {state.get('eta_seconds')}s ({state.get('current_step')})")
time.sleep(interval)
interval = min(20, interval + 2)
raise TimeoutError(f"{job_id} still running after {timeout_seconds}s")
if __name__ == "__main__":
job = poll(submit("an overhead shot of espresso being poured into a glass, slow motion"))
print({"state": job["state"], "cost_usd": job.get("cost_usd"), "url": job.get("video_url")})
The escalating interval matters at scale. Fifty concurrent jobs polled every second is 3,000 requests a minute to learn something that changes every twenty seconds.
Or skip polling entirely
curl -sS -X POST "https://api.infrai.cc/v1/video/generate" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"prompt": "a paper boat drifting down a gutter after rain",
"model": "wan2.7-t2v",
"duration_seconds": 5,
"webhook_url": "https://ops.example.com/hooks/video-done",
"store": true
}'
webhook_url on the submit call means the platform tells you when it’s done. Pair it with the account-level video.job.completed and video.job.failed events from POST /v1/account/webhooks/register if you’d rather have one endpoint for all jobs than a URL per job.
For a user-facing flow, the shape that behaves best is both: a webhook to record completion and a light status poll for the progress bar while the user is watching.
The three states that matter
state | Meaning | What to do |
|---|---|---|
queued | accepted, not started | show “queued”, keep polling |
running | generating | show progress_pct and eta_seconds |
succeeded | done | read video_url and cost_usd from get |
failed | did not produce output | read error, decide whether to retry |
cancelled | you cancelled it | nothing |
Read cost_usd from the finished job rather than computing it from the rate. It’s the actual figure, and it accounts for resolution multipliers and vendor differences you’d otherwise have to model yourself.
Limitations
Clips are short — twelve to fifteen seconds depending on the model, per GET /v1/video/capabilities — so anything longer is a stitching job you build. There’s no editing surface, no timeline, no frame-level control, and no way to continue a clip from its last frame as a first-class operation.
Generation is also probabilistic: the same prompt and seed gets you closer to a repeat, not a guarantee. If you need deterministic renders, this isn’t a good fit and a traditional rendering pipeline is.
Going direct to a model’s own provider gets you their full parameter surface and their roadmap commitments — worth it if one particular model is your product. Runway’s API exposes controls this generic endpoint doesn’t, and if you’re building specifically on Kling or Seedance features, their own interfaces will always be ahead of a common shape across twelve models.
What’s easier on one credential is the rest of the pipeline. The submitted job rides on POST /v1/queue/publish, the finished file is archived with PUT /v1/storage/object/put/{bucket}/{key} before retention_days runs out, a failure lands in POST /v1/errors/capture, and the whole cost appears in one GET /v1/account/usage. Generation bills per second of output (the live rate and the $2 trial allowance are in GET /v1/discovery/video.generate, verified 2026-09-21) and platform rates drift downward as vendor contracts improve.