# caption.sh API reference

An async job API. One call to `POST /v1/videos` creates a job: upload a file or pass a `source_url`, then poll for status and download the captioned MP4. Every styling option is optional and has a good-looking default, so the simplest call produces a clean result with zero configuration.

All endpoints live under `https://api.caption.sh/v1`. Every request is authenticated.

## Authentication

Create an API key in the dashboard (https://caption.sh/dashboard, Keys) and send it as a bearer token. Keys are shown once at creation, hashed at rest, and revocable from the dashboard. The web app uses a Clerk session JWT; scripts, pipelines and agents use an API key. Both resolve to the same account.

```
Authorization: Bearer sk_live_...   # an API key
Authorization: Bearer eyJraWQ...    # or a Clerk JWT (web session)
```

## Endpoints

| Method | Path | Purpose |
|---|---|---|
| POST | /v1/videos | Create a job: upload a file or pull a `source_url`. Returns 202 with `job_id` |
| GET | /v1/jobs/{id} | Job status: `awaiting_upload`, `queued`, `running`, `done`, or `error` |
| GET | /v1/jobs/{id}/result | 302 redirect to a signed download URL for the captioned MP4 |
| GET | /v1/jobs | List your jobs |
| GET | /v1/balance | Prepaid balance and recent transactions |

## Quickstart: pull from a URL (one call)

Hand the API a public video URL and it pulls the video itself. No upload step.

```bash
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}
```

## Quickstart: upload a file

1. `POST /v1/videos` with `{"filename": "clip.mp4", "content_type": "video/mp4", "options": {...}}`. The 202 response includes `job_id` and an `upload` object with `method` (`PUT`), `url`, `headers`, `max_bytes` and `expires_in`.
2. Send the file bytes with an HTTP `PUT` to `upload.url` (relative URLs resolve against `https://api.caption.sh`) using `upload.headers`. The upload URL is signed, so no Authorization header is needed. The job starts automatically when the upload lands.
3. Poll `GET /v1/jobs/{id}` until `status` is `done` or `error`, then download from `GET /v1/jobs/{id}/result` (follow the redirect).

```bash
curl -H "Authorization: Bearer $CAPTION_API_KEY" https://api.caption.sh/v1/jobs/$JOB_ID
curl -L -H "Authorization: Bearer $CAPTION_API_KEY" https://api.caption.sh/v1/jobs/$JOB_ID/result -o out.mp4
```

Send an `Idempotency-Key` header on `POST /v1/videos` to make retries safe.

## 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
```

Finished results are kept for 24 hours.

## Options

Send these inside `options` on `POST /v1/videos`. All are optional.

- `font` (font key (string); default `"luckiest-guy"`): Caption typeface. One of the bundled fonts (see Fonts below). Unknown key → `422`.
- `caps` (boolean; default `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; default `"medium"`): Text height as a fraction of video height. Presets below.
- `max_words` (integer ≥ 1 | `"auto"`; default `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; default `2`): How tall captions may wrap. The block never exceeds this many lines.
- `position` (`"top"` | `"center"` | `"bottom"` | number; default `"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; default `"#FFFFFF"`): Colour of normal (non-active) words.
- `highlight` (boolean; default `true`): Highlight the word currently being spoken.
- `highlight_color` (hex string; default `"#00E676"`): Colour of the active word. Ignored when `highlight` is `false`.
- `decoration` (`"outline"` | `"backdrop"` | `"none"`; default `"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; default `"#000000"`): Colour of the outline stroke (outline mode) or the backdrop box (backdrop mode).
- `outline_width` (`"auto"` | integer (px); default `"auto"`): Stroke thickness. `"auto"` scales with the font size. `decoration: "outline"` only.
- `backdrop_radius` (`"square"` | `"rounded"` | `"pill"` | number; default `"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; default `1.0`): Opacity of the backdrop box (0 = invisible, 1 = solid). Text stays fully opaque. `decoration: "backdrop"` only.
- `max_tilt` (number 0–45; default `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; default `false`): Mask common profanity in the caption text (e.g. `F***`).
- `censor_audio` (boolean; default `false`): Silence (mute) the audio over profane words. Independent of `censor_captions`.

Presets: `size` small 0.045, medium 0.060, large 0.080 (fraction of video height). `position` top 0.18, center 0.55, bottom 0.86 (fraction of height). `backdrop_radius` square 0.0, rounded 0.30, pill 0.50.

Captions always fit: text height is a fraction of the source height and wrapping uses a 90% width margin, so the same options produce proportional captions on vertical, square, or landscape video.

Fonts (`font`): luckiest-guy, roboto, inter, merriweather, noto-serif, dynapuff, mouse-memoirs, jim-nightshade, imperial-script, lobster, unifrakturmaguntia, story-script, black-ops-one, arvo, ultra, michroma, shadows-into-light, fredoka.

## Errors

| Status | When |
|---|---|
| 400 | Malformed body: send either a `filename` (file upload) or a `source_url` |
| 401 | Missing or invalid credentials |
| 402 | Prepaid balance too low to cover the estimated charge. Top up and retry; a paid render never starts on an empty balance |
| 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 | Throughput ceiling reached. Retry shortly |
| 500 | Transcription or rendering failed. Generic message; details are logged server-side with a `request_id` |

## Pricing

$0.10 per minute, rounded up to the nearest minute (a 1:30 video bills as 2 minutes, $0.20). Billing is a prepaid balance: top up credit in the dashboard and each job debits the real duration, measured server-side. Credit packs: $5, $10, $20, $50 (includes $5 bonus credit), $100 (includes $10 bonus credit).
