Problem: Creating B-Roll at Scale is Slow and Expensive
Stock footage licensing is expensive, and shooting custom B-roll takes time you don't have. OpenAI's Sora API lets you generate professional-quality video clips programmatically — but the API is async, has quirks around polling, and the docs skip the production-ready patterns.
You'll learn:
- How to authenticate and call the Sora video generation endpoint
- How to handle async job polling correctly (without hammering the API)
- How to download and save finished clips to disk
Time: 20 min | Level: Intermediate
Why This Happens
Sora doesn't return video immediately. You submit a generation job and get back a job_id. You then poll a status endpoint until the job state is "succeeded", at which point a signed download URL is available. Most errors come from polling too aggressively (rate limits) or not checking the status field before trying to fetch the download URL.
Common symptoms:
429 Too Many Requestswhen pollingKeyError: 'download_url'because the job hasn't finished- Saved files that are 0 bytes because the download URL expired
Solution
Step 1: Install Dependencies and Set Up Auth
pip install openai httpx python-dotenv
Store your key in .env — never hardcode it:
# .env
OPENAI_API_KEY=sk-...
# sora_client.py
import os
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
# The client reads OPENAI_API_KEY from env automatically
client = OpenAI()
Expected: No errors on import. If you see AuthenticationError, double-check your .env path.
Step 2: Submit a Video Generation Job
import time
import httpx
from pathlib import Path
def generate_broll(prompt: str, output_path: str, duration: int = 5) -> str:
"""
Submit a Sora generation job and return the job ID.
duration: seconds of video (5 or 10 supported)
"""
response = client.videos.generate(
model="sora",
prompt=prompt,
duration=duration, # 5 or 10 seconds
resolution="1080p", # 720p | 1080p
n=1, # number of variants to generate
)
# Response contains a job ID, not the video itself
job_id = response.id
print(f"Job submitted: {job_id}")
return job_id
Expected: A string like vg-abc123xyz printed to stdout.
If it fails:
InvalidRequestError: duration: Sora only supports 5 or 10 second clips as of Feb 2026model_not_found: Ensure your API key has Sora access (currently requires Tier 4+ or waitlist approval)
Step 3: Poll for Completion with Exponential Backoff
def poll_until_done(job_id: str, max_wait: int = 300) -> dict:
"""
Poll the job status endpoint until done or timeout.
Uses exponential backoff to avoid rate limiting.
"""
interval = 5 # Start polling every 5 seconds
elapsed = 0
while elapsed < max_wait:
job = client.videos.retrieve(job_id)
if job.status == "succeeded":
return job # Has download_url at this point
if job.status == "failed":
raise RuntimeError(f"Generation failed: {job.error}")
# Still running — wait before next poll
time.sleep(interval)
elapsed += interval
interval = min(interval * 1.5, 30) # Cap backoff at 30s
raise TimeoutError(f"Job {job_id} did not complete within {max_wait}s")
Why exponential backoff matters: Sora jobs take 30–120 seconds. Polling every second burns through rate limits fast. Starting at 5s intervals and capping at 30s keeps you well under the limit.
Step 4: Download the Finished Clip
def download_clip(job: dict, output_path: str) -> Path:
"""
Download the finished video to disk.
The signed URL expires in ~1 hour — download immediately.
"""
url = job.download_url # Only present when status == "succeeded"
output = Path(output_path)
output.parent.mkdir(parents=True, exist_ok=True)
# Stream download to avoid loading entire file into memory
with httpx.stream("GET", url) as r:
r.raise_for_status()
with open(output, "wb") as f:
for chunk in r.iter_bytes(chunk_size=8192):
f.write(chunk)
print(f"Saved: {output} ({output.stat().st_size / 1024:.1f} KB)")
return output
If it fails:
- 0-byte file: The signed URL expired. Re-retrieve the job (
client.videos.retrieve(job_id)) to get a fresh URL — it regenerates on each call 403 Forbidden: Same cause — URL expired. Don't store URLs, store job IDs
Step 5: Putting It Together
def create_broll(prompt: str, filename: str, duration: int = 5) -> Path:
"""End-to-end: prompt in, MP4 file out."""
job_id = generate_broll(prompt, filename, duration)
job = poll_until_done(job_id)
return download_clip(job, f"./broll/{filename}.mp4")
if __name__ == "__main__":
clips = [
("Aerial drone shot of a city skyline at golden hour", "city-skyline"),
("Close-up of hands typing on a mechanical keyboard", "keyboard-typing"),
("Coffee being poured into a white ceramic mug, slow motion", "coffee-pour"),
]
for prompt, name in clips:
path = create_broll(prompt, name)
print(f"Done: {path}")
Verification
python sora_client.py
You should see:
Job submitted: vg-abc123xyz
Job submitted: vg-def456uvw
Job submitted: vg-ghi789rst
Saved: ./broll/city-skyline.mp4 (18432.0 KB)
Saved: ./broll/keyboard-typing.mp4 (12288.0 KB)
Saved: ./broll/coffee-pour.mp4 (15360.0 KB)
Done: broll/city-skyline.mp4
Check the files play correctly:
ls -lh ./broll/
# Should show 3 non-zero .mp4 files
Three clips generated and saved — all non-zero file sizes confirm successful download
Generating Batches Efficiently
For larger workflows, submit all jobs first, then poll them together instead of waiting serially:
import concurrent.futures
def batch_generate(clips: list[tuple[str, str]]) -> list[Path]:
# Submit all jobs immediately
jobs = [(name, generate_broll(prompt, name)) for prompt, name in clips]
# Poll all in parallel
results = []
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
futures = {
executor.submit(poll_until_done, job_id): (name, job_id)
for name, job_id in jobs
}
for future in concurrent.futures.as_completed(futures):
name, _ = futures[future]
job = future.result()
results.append(download_clip(job, f"./broll/{name}.mp4"))
return results
This cuts total wait time from n * avg_generation_time to roughly 1 * avg_generation_time for batches.
What You Learned
- Sora is async — always poll by
job_id, never assume immediate results - Exponential backoff prevents rate limiting on longer jobs
- Signed download URLs expire; re-retrieve the job object to get a fresh one rather than storing URLs
- Batch-submitting jobs then polling in parallel is dramatically faster than serial generation
Limitation: As of Feb 2026, Sora clips max out at 10 seconds and 1080p. For longer sequences, generate multiple clips and stitch with ffmpeg. API access still requires Tier 4 OpenAI account or explicit waitlist approval.
When NOT to use this: If you need 4K footage or clips longer than 30 seconds, Sora isn't the right tool yet. Runway ML Gen-3 handles longer-form generation.
Tested on Python 3.12, openai SDK 1.x, macOS & Ubuntu 24.04