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 }.

Endpoints
The full /v1 surface. Bytes never flow through the API: uploads and downloads go straight to storage over presigned URLs.
MethodPathAuthPurpose
POST/v1/videoskey / JWTCreate a job: upload a file (presigned POST) or pull a source_url → 202 { job_id }
GET/v1/jobs/{id}ownerJob status: queued | awaiting_upload | running | done | error
GET/v1/jobs/{id}/resultowner302 to a signed download URL
GET/v1/jobsownerList your jobs
GET/v1/balancekey / JWTPrepaid balance + recent transactions

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 URL

Pull 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 usual

Job 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-side

Pricing

$0.10 per minute, rounded up to the nearest minute.

1:30 clipbilled 2 min$0.20

Billing is a prepaid balance: top up credit in the dashboard, and each job debits ceil(duration_min) × $0.10. Duration is measured server-side (ffprobe), so you're billed for the real length, never a client-declared one. Run dry and POST /v1/videos returns 402 before any paid work starts.

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.

FieldTypeDefaultMeaning
fontfont key (string)"luckiest-guy"Caption typeface. One of the bundled fonts (see Fonts below). Unknown key → 422.
capsbooleantrueUppercase 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_wordsinteger ≥ 1 | "auto"3Words per caption group. 1 = one word at a time. "auto" = pack as many as fit the size + wrap budget.
max_linesinteger ≥ 12How 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_colorhex string"#FFFFFF"Colour of normal (non-active) words.
highlightbooleantrueHighlight the word currently being spoken.
highlight_colorhex 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_colorhex 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_opacitynumber 0–11.0Opacity of the backdrop box (0 = invisible, 1 = solid). Text stays fully opaque. decoration: "backdrop" only.
max_tiltnumber 0–450Max random tilt in degrees. Each caption is rotated a random amount within ±max_tilt, biased to alternate direction. 0 = always straight.
censor_captionsbooleanfalseMask common profanity in the caption text (e.g. F***).
censor_audiobooleanfalseSilence (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)

PresetFraction
small0.045
medium0.060
large0.080

position (fraction of height, block baseline)

PresetFraction
top0.18
center0.55
bottom0.86

backdrop_radius (fraction of box height)

PresetFraction
square0.0
rounded0.30
pill0.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

Luckiest Guyluckiest-guy · defaultDynaPuffdynapuffLobsterlobsterBlack Ops Oneblack-ops-oneUnifrakturMaguntiaunifrakturmaguntia

Sans-serif

RobotorobotoInterinterFredokafredokaMichromamichromaMouse Memoirsmouse-memoirs

Serif / slab

MerriweathermerriweatherNoto Serifnoto-serifArvoarvoUltraultra

Handwriting / script

Shadows Into Lightshadows-into-lightStory Scriptstory-scriptJim Nightshadejim-nightshadeImperial Scriptimperial-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:

  1. Starts at the requested size (font height = size × video_height).
  2. Lays words left-to-right within a 90% width margin (5% padding each side), wrapping onto new lines as needed.
  3. 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.
  4. 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 }.

StatusWhen
400Malformed request body: send either a filename (file upload) or a source_url, not neither.
401Missing or invalid credentials: send a Clerk JWT or an API key as a bearer token.
402Prepaid balance is too low to cover the estimated charge. Top up and retry: a broke account never triggers a paid render.
404Unknown job id, or a job that isn't yours.
422options failed validation, or the video is too long (max 10 minutes), too high-resolution, or unreadable.
429Transcription throughput ceiling reached. The queue absorbs bursts; retry shortly.
500Transcription 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.