2026-11-07 · 4 min read
How to Add Captions to Twitch VODs
A three-hour stream produces a VOD, and the VOD produces clips, highlights, and YouTube uploads. A lot of that secondary viewing starts with the sound off. If the VOD or clip has no captions, viewers bounce before they ever hear the moment that made it worth clipping. Captioning streams by hand is not realistic at broadcast length, so the practical route is an API.
Why VODs and stream clips need captions
- Clips get discovered in feeds that may autoplay muted, depending on the platform and the viewer's settings.
- Stream audio is chaotic: game sound, crosstalk, and alerts bury speech.
- Viewers skimming a long VOD read ahead to find the segment they want.
- Captioned highlights stay watchable in places where audio is not an option.
Submit a VOD for captioning
caption.sh takes a JSON POST with the recording's source_url and returns a job_id. Export or download the VOD, put it somewhere reachable by URL, and the API fetches it from there. A two-step signed-URL upload path is also documented for files that are not publicly reachable. Captioning is $0.10 per minute of video, prepaid, and each job is capped at 10 minutes, so the practical unit is the highlight clip: a batch of clips costs cents to a few dollars.
curl -X POST https://api.caption.sh/v1/videos \
-H "Authorization: Bearer $CAPTION_API_KEY" \
-H "Content-Type: application/json" \
-d '{"source_url": "https://example.com/video.mp4"}'
Poll until the render is done
Jobs are asynchronous. Submit once, keep the job_id, and poll the status endpoint until the job reports done or failed, with a timeout so a bad job fails loudly instead of hanging your upload schedule. A 429 means the transcription throughput ceiling is reached; wait and retry.
import time
import requests
KEY = "your_api_key"
H = {"Authorization": f"Bearer {KEY}"}
job = requests.post(
"https://api.caption.sh/v1/videos",
headers=H,
json={"source_url": "https://example.com/video.mp4"},
).json()
job_id = job["job_id"]
# A 429 means the transcription throughput ceiling is reached. Wait and retry.
deadline = time.time() + 1800
status = None
while time.time() < deadline:
r = requests.get(f"https://api.caption.sh/v1/jobs/{job_id}", headers=H)
status = r.json()["status"]
if status in ("done", "error"):
break
time.sleep(10)
if status == "done":
r = requests.get(f"https://api.caption.sh/v1/jobs/{job_id}/result", headers=H, allow_redirects=False)
print(r.headers["Location"]) # signed URL; results are stored for 24 hours, then deleted
elif status == "error":
raise SystemExit("captioning failed")
else:
raise SystemExit("timed out waiting for the job")
The result endpoint redirects to a signed download URL, and results are stored for 24 hours, then deleted. Pull the captioned file into your own storage in the completion step. The API documents no webhook surface, so polling is the supported pattern.
A realistic streamer workflow
The version that actually sticks: after the stream, your highlight clips get cut from the VOD, and each clip gets submitted for captioning. Every clip then carries burned-in captions with it wherever it gets posted, with no editor and no per-platform caption setup.