Docs
API reference
An async job API. One call to POST /v1/videos creates a job: upload a file or pull a source_url : then poll for status and download the captioned MP4. Every styling knob is optional and has a good-looking default, so the simplest call produces a clean result with zero configuration.
Reference
API
All endpoints live under https://api.caption.sh/v1. Every request is authenticated (see Authentication), and errors share one JSON shape: { error, detail, request_id }.
Authentication
Sign in with your account (Clerk hosted login) to manage keys, usage, and billing in the dashboard. For programmatic access, create a per-user API key and send it as a bearer token. Both a Clerk JWT and an API key resolve to the same account:
Authorization: Bearer sk_live_... # an API key
Authorization: Bearer eyJraWQ... # or a Clerk JWT (web session)The web app uses your session JWT; scripts and pipelines use an API key. Keys are shown once at creation, hashed at rest, and revocable from the dashboard.
Quick start
One call to POST /v1/videos creates the job. With the SDK, createVideo(file, options) uploads the file and enqueues the render in a single step; with raw HTTP the response hands back a presigned form to POST the file to storage. Then poll until it's done and download the MP4. Defaults give medium white captions at the bottom, active word highlighted, black outline, up to 3 words per group.
import { Caption } from "caption.sh";
const caption = new Caption(process.env.CAPTION_API_KEY);
// One call: uploads the file, creates the job, returns a job id.
const { jobId } = await caption.createVideo(file, { size: "large" });
// Or pull from a URL: the bytes never leave your storage:
// const { jobId } = await caption.createVideoFromUrl(url, { size: "large" });
// Wait for the render, then grab the captioned MP4.
await caption.pollJob(jobId);
const resultUrl = await caption.getResultUrl(jobId); // signed video/mp4 URLPull from a URL (one call)
Already have the video on a public URL? Send source_url instead of filename and skip the upload entirely: POST /v1/videos returns just a job_id, no upload block.
# Truly one call: hand the API a URL and it pulls the video itself :
# no upload step, no presigned form, no second request.
curl -X POST https://api.caption.sh/v1/videos \
-H "Authorization: Bearer $CAPTION_API_KEY" \
-H "Content-Type: application/json" \
-d '{"source_url":"https://cdn.example.com/clip.mp4","options":{"size":"large"}}'
# -> 202 { "job_id": "..." } # then poll /v1/jobs/{id} as usualJob lifecycle
# File upload:
awaiting_upload -> queued -> running -> done # result ready at /v1/jobs/{id}/result
# source_url pull:
queued -> running -> done
# on failure:
running -> error # generic message to you, detail logged server-sidePricing
Options
All fields are optional: send only the ones you want to change, inside the job's options object. Fields that take a preset or a number let you stay high-level ("medium") or go precise (0.06): the number is always a fraction of the video's own dimension, so it scales with any input size.
| Field | Type | Default | Meaning |
|---|---|---|---|
| font | font key (string) | "luckiest-guy" | Caption typeface. One of the bundled fonts (see Fonts below). Unknown key → 422. |
| caps | boolean | true | Uppercase all caption text. false keeps the transcription's natural casing: sentence case, proper nouns and punctuation as Deepgram returns them. |
| size | "small" | "medium" | "large" | number | "medium" | Text height as a fraction of video height. Presets below. |
| max_words | integer ≥ 1 | "auto" | 3 | Words per caption group. 1 = one word at a time. "auto" = pack as many as fit the size + wrap budget. |
| max_lines | integer ≥ 1 | 2 | How tall captions may wrap. The block never exceeds this many lines. |
| position | "top" | "center" | "bottom" | number | "bottom" | Vertical placement of the caption block. Number = fraction of height (0 = top edge, 1 = bottom edge) for the block's baseline. |
| text_color | hex string | "#FFFFFF" | Colour of normal (non-active) words. |
| highlight | boolean | true | Highlight the word currently being spoken. |
| highlight_color | hex string | "#00E676" | Colour of the active word. Ignored when highlight is false. |
| decoration | "outline" | "backdrop" | "none" | "outline" | How glyphs are made legible on any background: a stroke around each glyph, a filled rounded box behind the lines, or nothing. Outline and backdrop are mutually exclusive. |
| decoration_color | hex string | "#000000" | Colour of the outline stroke (outline mode) or the backdrop box (backdrop mode). |
| outline_width | "auto" | integer (px) | "auto" | Stroke thickness. "auto" scales with the font size. decoration: "outline" only. |
| backdrop_radius | "square" | "rounded" | "pill" | number | "rounded" | Corner roundness of the backdrop box, as a fraction of the box height (0 = square, 0.5 = pill). decoration: "backdrop" only. |
| backdrop_opacity | number 0–1 | 1.0 | Opacity of the backdrop box (0 = invisible, 1 = solid). Text stays fully opaque. decoration: "backdrop" only. |
| max_tilt | number 0–45 | 0 | Max random tilt in degrees. Each caption is rotated a random amount within ±max_tilt, biased to alternate direction. 0 = always straight. |
| censor_captions | boolean | false | Mask common profanity in the caption text (e.g. F***). |
| censor_audio | boolean | false | Silence (mute) the audio over profane words. Independent of censor_captions. |
Colour format: Hex strings, #RRGGBB (case-insensitive, leading # optional). Invalid colours are rejected with 422.
Presets
The presets are the numeric fractions the keywords expand to: a starting point for fine-tuning with a raw number.
size (fraction of video height)
| Preset | Fraction |
|---|---|
| small | 0.045 |
| medium | 0.060 |
| large | 0.080 |
position (fraction of height, block baseline)
| Preset | Fraction |
|---|---|
| top | 0.18 |
| center | 0.55 |
| bottom | 0.86 |
backdrop_radius (fraction of box height)
| Preset | Fraction |
|---|---|
| square | 0.0 |
| rounded | 0.30 |
| pill | 0.50 |
Fonts
Set with the font option: e.g. lobster. All 18 fonts are bundled in the image (nothing is downloaded at request time) and are open source (OFL / Apache-2.0), free to embed and burn into your video. Pass one of the keys below; an unknown key returns 422. Default: luckiest-guy.
Display / decorative
Sans-serif
Serif / slab
Handwriting / script
Legibility tip: script/handwriting fonts (thin strokes) read best at a larger size and with decoration: "backdrop" or a bold text_color. Display fonts shine with the default outline.
Fit guarantee
You never have to worry about text overflowing. For every caption group the renderer works through the same four steps:
- Starts at the requested
size(font height =size × video_height). - Lays words left-to-right within a 90% width margin (5% padding each side), wrapping onto new lines as needed.
- If the group would exceed
max_lines, or a single word is wider than the margin, it shrinks the font and re-lays out: repeating down to a minimum readable floor: until everything fits. - Clamps the block vertically so it never bleeds past the top/bottom edges, regardless of
position.
Because step 1 is a fraction of the source height and step 2 is a fraction of the source width, the identical options payload produces proportional, on-screen captions for any input resolution or aspect ratio: vertical, square, or landscape.
Limits & errors
Every response carries a request_id; errors share the JSON shape { error, detail, request_id }.
| Status | When |
|---|---|
| 400 | Malformed request body: send either a filename (file upload) or a source_url, not neither. |
| 401 | Missing or invalid credentials: send a Clerk JWT or an API key as a bearer token. |
| 402 | Prepaid balance is too low to cover the estimated charge. Top up and retry: a broke account never triggers a paid render. |
| 404 | Unknown job id, or a job that isn't yours. |
| 422 | options failed validation, or the video is too long (max 10 minutes), too high-resolution, or unreadable. |
| 429 | Transcription throughput ceiling reached. The queue absorbs bursts; retry shortly. |
| 500 | Transcription or rendering failed. Returns a generic message; the cause is logged server-side with a request_id. |
MVP limits: Max video length 10 minutes. Results are stored for 24 hours and then deleted: download promptly, or re-run the job. Because the work runs async, there's no request-timeout ceiling: submit, poll, and collect the result when it's ready.