2026-11-07 · 4 min read

How to Add Captions to Keynote Recordings

A keynote or conference talk has a shelf life far beyond the event: it goes on YouTube, into recap posts, into the sales deck. The feed and social views among them often start muted. If the talk has no captions, the best lines never land for the people scrolling past it. Events produce talks in batches, so captioning them one by one in an editor does not scale.

Why talk recordings benefit from captions

  • Recap clips in social feeds can autoplay muted, so the first caption line is the hook.
  • Talks are quotable, and captions make quotes readable and shareable.
  • Long recordings become skimmable when viewers can read ahead.
  • Captions help non-native speakers follow the talk; translation itself is a separate step.

Submit a talk for captioning

caption.sh takes a JSON POST with the recording's source_url and returns a job_id. Hand the API a reachable URL for the exported recording and it fetches the file itself; a two-step signed-URL upload is documented for non-public files. Captioning is $0.10 per minute of video, prepaid. Each job is capped at 10 minutes, so a 30-minute talk is split into segments first and still totals about $3.

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 for the captioned talk

Jobs are asynchronous. Submit, keep the job_id, and poll the status endpoint until done or failed, with a timeout so a stuck job surfaces. When you are processing a whole conference's talks at once, 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, so the completion step should copy the captioned file to your own storage. The API documents no webhook surface, which is why the poll loop is the integration pattern.

A post-event pipeline that holds up

A workable flow: talks are exported after the event and split into segments under the 10-minute job cap, each segment is submitted for captioning as it lands, and the captioned segments go to YouTube and the recap page. Clips for social get cut from the captioned segments, so every highlight already has the text burned in.

Back to all articles