2026-11-06 · 4 min read

A Caption API for Marketing Agencies

If you run a marketing agency, client video arrives in batches: a campaign's worth of ads, a quarter of social clips, a product launch. Every one of those assets will be watched on mute somewhere, which means captions are not optional. But captioning by hand does not scale across clients, and editors become the bottleneck exactly when deadlines bunch up. A caption API makes captioned output the default, not the exception.

What agencies get from API captioning

  • Every client deliverable can ship captioned without extra editor hours.
  • Styling stays consistent across a client's whole campaign.
  • Burned-in captions survive every repost, embed, and download.
  • Captioning becomes a line item with a predictable cost instead of absorbed labor.

How the API works

caption.sh accepts a JSON POST with a source_url for the video and returns a job_id. There is no upload form: the API fetches the video from the URL you provide, and a two-step signed-URL upload path is documented for files that are not publicly reachable. Captioning costs $0.10 per minute, prepaid.

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"}'

Running it at campaign volume

Each video is its own job with its own job_id. Submit jobs as assets are finalized, then poll each one until it is done or failed, with a timeout. The transcription throughput ceiling is shared, so when a whole campaign goes out the door at once, a 429 means wait and retry rather than a failure.

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")

When a job finishes, the result endpoint redirects to a signed download URL, and results are stored for 24 hours, then deleted, so your completion step should pull the captioned file into your asset storage. The API documents no webhook surface, which is why polling is the pattern.

Fitting it into client delivery

A common setup is a caption step between edit approval and delivery: the approved cut is submitted, the captioned master comes back, and both versions go into the client folder. Clients notice when every video you hand over is watchable on mute, and it costs you minutes of automation rather than hours of editing.

Back to all articles