2026-09-27 · 9 min read

Video captioning API in Python: caption a folder of clips with one script

The setup is common: raw clips keep landing in a folder or a bucket - from a clipping tool, a UGC pipeline, an agent that edits - and captioned versions need to come out the other side. No editor, no manual step. A script runs, and MP4s with burned-in captions come out. This post is that script in Python, plus the details that keep it working past the demo.

The shape of the API

caption.sh is an async job API. You POST a video - by URL, or by uploading bytes to a signed URL - and get back a 202 with a job id. You poll the job until it is done, then download the captioned MP4. Billing is prepaid at $0.10 per minute of video and only successful renders are charged. A 402 response means the balance is empty: top up in the dashboard before starting more jobs.

The script

import os, sys, time, requests

API = "https://api.caption.sh"
HEADERS = {"Authorization": f"Bearer {os.environ['CAPTION_API_KEY']}"}

def caption_one(source_url, out_path, options=None):
    r = requests.post(f"{API}/v1/videos", headers=HEADERS,
                      json={"source_url": source_url, "options": options or {}},
                      timeout=30)
    if r.status_code == 402:
        raise SystemExit("out of credits - top up in the dashboard")
    r.raise_for_status()
    job_id = r.json()["job_id"]
    while True:
        time.sleep(5)
        s = requests.get(f"{API}/v1/jobs/{job_id}", headers=HEADERS, timeout=30).json()
        if s["status"] == "done":
            mp4 = requests.get(f"{API}/v1/jobs/{job_id}/result",
                               headers=HEADERS, timeout=300).content
            open(out_path, "wb").write(mp4)
            return
        if s["status"] == "error":
            raise RuntimeError(f"render failed: {s}")

if __name__ == "__main__":
    caption_one(sys.argv[1], sys.argv[2], {"max_words": 3})

Run it with CAPTION_API_KEY set and two arguments - a video URL and an output path - and it captions one clip end to end. Point it at a list of URLs from your storage layer and it captions all of them. requests follows the redirect on the result download by default, so the MP4 lands directly.

The details that save you later

  • Job states are awaiting_upload, queued, running, done, and error. Poll until done or error - treat anything past a generous timeout as an error and retry the job, not the poll.
  • A 402 is not a failure, it is a signal: stop the batch, top up the balance, resume. Credits never get charged for failed renders.
  • A 422 means the request itself was rejected: the video is over 10 minutes, too high-resolution, unreadable, or an option failed validation. Fix the input instead of retrying.
  • Pass an Idempotency-Key header when a retry might double-submit: the same key returns the same job instead of starting a second render.
  • Uploading bytes instead of passing a URL is a two-step flow: the POST returns a signed upload URL, you PUT the file to it (no auth header - the URL is the credential), and the job starts when the upload lands.
  • Every style field is optional. The zero-config call looks decent; max_words, highlight_color, decoration, and position are the knobs most pipelines touch first.

Why not Whisper and FFmpeg yourself

For one video, the DIY stack is fine. The friction appears at volume: Whisper gives you segments, not word timing, so karaoke-style highlighting needs a forced-alignment step; ASS styling is its own language; fonts have to be installed and licensed on every worker; and you end up building a render queue with retries, timeouts, and billing logic. That is the API you did not want to build. At $0.10 per minute, a thousand one-minute clips cost $100 - less than the engineering week the DIY version burns before its first correct render.

If the caller is an agent

caption.sh also ships a hosted MCP server at https://mcp.caption.sh/mcp (streamable HTTP, same API key as the bearer token). Agents in Claude, Cursor, and other MCP clients get caption_video, get_caption_job, list_caption_styles, list_caption_jobs, and get_caption_balance as native tools, plus nine ready-made style recipes. An agent with access to a drive of raw clips can caption all of them without anyone writing the script above.

Run it

Grab an API key from the caption.sh dashboard, test the look in the playground, and the script above is the whole integration. The full reference - endpoints, status values, every option field - lives at caption.sh/docs.

Back to all articles