Async Video Generation API — How to Poll for Results (2026)
You POST to a video generation endpoint, wait for a response, and get a 504 Gateway Timeout after 30 seconds. Your load balancer cut the connection before the model finished. Video generation takes 30 seconds on the fast end and 3–5 minutes for high-resolution outputs — far longer than any HTTP timeout your infrastructure will tolerate.
Every video generation API on GenRelay uses an async job pattern: you submit a job and receive a job ID immediately, then poll a separate status endpoint until the job reaches completed or failed. This guide explains how to implement that pattern reliably for Veo 3.1, Grok Imagine, and Gemini Omni Flash.
Why do video APIs use async job patterns?
Direct answer: video generation takes too long for synchronous HTTP requests to survive.
A typical generation timeline:
- Grok Imagine 1.0 (t2v): 30–60 seconds
- Gemini Omni Flash (720p): 45–90 seconds
- Veo 3.1 Lite (720p): 60–120 seconds
- Veo 3.1 Lite (1080p): 90–180 seconds
Standard HTTP client and proxy timeouts sit at 30–60 seconds. Even if your client timeout is generous, most cloud load balancers and API gateways enforce a maximum connection lifetime that video generation will routinely exceed.
The async pattern solves this cleanly: the initial POST returns a 202 Accepted with a job ID in under one second. You poll a GET /v1/videos/jobs/{id} endpoint on a timer until the status flips to completed, then retrieve the video URL from the response.
How do I submit a video generation job?
The endpoint is POST /v1/videos/generations. Required fields: model and prompt. Optional but important: duration, resolution, and aspect_ratio.
import os
import requests
API_KEY = os.environ["GENRELAY_API_KEY"]
headers = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
}
# Submit the job
response = requests.post(
"https://genrelay.ai/v1/videos/generations",
headers=headers,
json={
"model": "veo-3.1-lite",
"prompt": "A timelapse of storm clouds forming over a mountain range, cinematic, 4K",
"duration": 8,
"resolution": "720p",
"aspect_ratio": "16:9",
},
)
response.raise_for_status()
job = response.json()
job_id = job["id"]
print(f"Job submitted: {job_id}")
print(f"Estimated wait: {job.get('estimated_seconds', 'unknown')}s")
The response body includes id (the job ID you'll poll with) and, where available, an estimated_seconds field that helps you set an appropriate initial wait before your first poll.
How do I write a polling loop that doesn't break?
A polling loop has three failure modes you need to handle explicitly:
1. Premature polling — hammering the status endpoint wastes quota and adds noise
2. Infinite loops — a hung job will poll forever without a timeout ceiling
3. Silent failures — status failed needs an explicit check, not just "not completed yet"
Here is a production-ready polling function:
import time
def poll_video_job(job_id: str, poll_interval: int = 10, timeout: int = 600) -> str:
"""
Poll until job completes. Returns video URL.
Raises TimeoutError after `timeout` seconds.
Raises RuntimeError on job failure.
"""
status_url = f"https://genrelay.ai/v1/videos/jobs/{job_id}"
deadline = time.time() + timeout
elapsed = 0
while time.time() < deadline:
resp = requests.get(status_url, headers=headers)
resp.raise_for_status()
data = resp.json()
status = data["status"]
print(f"[{elapsed}s] Status: {status}")
if status == "completed":
return data["output"]["url"]
if status == "failed":
raise RuntimeError(f"Job {job_id} failed: {data.get('error', 'unknown error')}")
time.sleep(poll_interval)
elapsed += poll_interval
raise TimeoutError(f"Job {job_id} did not complete within {timeout}s")
# Full flow
job_id = "YOUR_JOB_ID"
try:
video_url = poll_video_job(job_id, poll_interval=15, timeout=300)
print(f"Video ready: {video_url}")
except TimeoutError as e:
print(f"Timed out: {e}")
except RuntimeError as e:
print(f"Job failed: {e}")
Key decisions in this implementation:
poll_interval: how often to check. Set this based on the model's typical generation time — polling every 2 seconds for a job that takes 2 minutes is wasteful.timeout: the ceiling beyond which you give up. Set this to 2–3× the model's worst-case generation time.- Status values:
pending,processing,completed,failed. Onlycompletedgives you a URL;failedgives you an error detail.
What poll interval should I use for each model?
Different models have different generation speeds. Using the same poll interval for all models either wastes requests (polling too fast) or delays delivery to your users (polling too slow).
| Model | Typical generation time | Recommended first wait | Poll interval | Max timeout |
|---|---|---|---|---|
| Grok Imagine 1.0 | 30–60s | 25s | 10s | 120s |
| Grok Imagine 1.5 (i2v) | 45–90s | 35s | 15s | 180s |
| Gemini Omni Flash (720p) | 45–90s | 40s | 15s | 180s |
| Gemini Omni Flash (1080p) | 60–120s | 50s | 20s | 240s |
| Veo 3.1 Lite (720p) | 60–120s | 55s | 15s | 300s |
| Veo 3.1 Lite (1080p) | 90–180s | 80s | 20s | 360s |
A practical approach: sleep for the "first wait" duration before making any poll request at all, then poll on the recommended interval until done. This eliminates early-round wasted requests.
import time
def smart_poll(job_id: str, model: str) -> str:
model_config = {
"grok-imagine-1.0": {"first_wait": 25, "interval": 10, "timeout": 120},
"grok-imagine-1.5": {"first_wait": 35, "interval": 15, "timeout": 180},
"omni-flash": {"first_wait": 40, "interval": 15, "timeout": 240},
"veo-3.1-lite": {"first_wait": 55, "interval": 15, "timeout": 300},
}
cfg = model_config.get(model, {"first_wait": 30, "interval": 15, "timeout": 300})
print(f"Waiting {cfg['first_wait']}s before first poll...")
time.sleep(cfg["first_wait"])
return poll_video_job(job_id, poll_interval=cfg["interval"], timeout=cfg["timeout"])
How do I handle failures and partial results gracefully?
Three scenarios to handle explicitly:
1. Job fails mid-generation
The status field becomes "failed" and data["error"] contains a reason string. Common failure reasons: prompt safety filter triggered, upstream model capacity exhausted, or unsupported parameter combination. Check the error message before deciding whether to retry.
2. Job times out in your polling loop
This doesn't mean the job failed — the model may still be working. You can resume polling later using the same job_id. Store job IDs in your database when you submit, so you can re-poll any job that your process lost track of due to a crash or restart.
3. Network error during polling
The requests.get() call itself can fail. Wrap the poll call in a try-except that catches connection errors and retries with a short backoff:
import requests
from requests.exceptions import RequestException
def safe_get_status(job_id: str) -> dict:
for attempt in range(3):
try:
resp = requests.get(
f"https://genrelay.ai/v1/videos/jobs/{job_id}",
headers=headers,
timeout=10,
)
resp.raise_for_status()
return resp.json()
except RequestException as e:
if attempt == 2:
raise
time.sleep(2 ** attempt)
What does async video generation cost per model?
Billing is charged when the job completes, not when it's submitted. A failed job is not billed.
| Model | 720p price | 1080p price | Billing unit |
|---|---|---|---|
| Veo 3.1 Lite | $0.060/s | $0.120/s | Per second of output |
| Grok Imagine 1.5 | $0.022/s | $0.022/s | Per second of output |
| Grok Imagine 1.0 | $0.010/s | — | Per second of output |
| Gemini Omni Flash | $0.10 | $0.15 | Per generation (flat) |
Example: 8-second video at different models and resolutions
| Model | Resolution | Cost per video |
|---|---|---|
| Grok Imagine 1.0 (t2v) | — | $0.080 |
| Gemini Omni Flash | 720p | $0.100 |
| Grok Imagine 1.5 | 720p | $0.176 |
| Gemini Omni Flash | 1080p | $0.150 |
| Veo 3.1 Lite | 720p | $0.480 |
| Veo 3.1 Lite | 1080p | $0.960 |
Omni Flash's flat-rate billing makes it predictable for SaaS products where you want per-generation pricing. Veo 3.1 Lite's per-second billing means longer videos cost proportionally more — account for this if users can choose duration. See the Veo 3 API pricing breakdown for more detailed workload math.
FAQ
Can I cancel a running video job?
Yes. Send DELETE /v1/videos/jobs/{id} to cancel a job that hasn't completed. Cancelled jobs are not billed.
What happens if I lose my job ID?
You can list recent jobs via GET /v1/videos/jobs?limit=20. Jobs are retained for 7 days in your account history.
Is there a webhook option instead of polling?
GenRelay supports webhook callbacks for video jobs. Set the webhook_url field in your generation request and GenRelay will POST the completed job data to your endpoint when done — no polling required. See the Veo 3.1 integration guide at /blog/veo-3-1-api-tutorial for the webhook payload schema.
Are video URLs permanent?
Generated video URLs expire after 24 hours. Download the video file to your own storage (S3, Cloudflare R2) after the job completes. The output.url field in the completed job response is a direct download link.
What's the rate limit on video generation jobs?
Concurrent job limits depend on your plan. On the $14/mo plan, you can run multiple jobs in parallel. If you hit a 429 on job submission, the job queue is full — retry the submission after a short delay.
Does the estimated_seconds field in the job submission response update during generation?
No. estimated_seconds is a static estimate based on model, resolution, and duration at submission time. Actual generation time can vary based on current model load.
The complete async pattern is: POST to submit, store the job ID, sleep the first-wait duration, then poll on an appropriate interval until completed. Handle failed status explicitly, persist job IDs so you can resume polling after a restart, and download the video URL before the 24-hour expiry. For Veo 3.1 model details and request parameters, start with the Veo 3.1 API guide.