Add burned-in captions to a video over HTTP

Three endpoints: upload a file, start a job, poll for the MP4. Transcription runs on Whisper, rendering uses the same engine as the editor, so the output matches what you'd get by hand. Built for agents and scripts.

Authentication

Create a key from the account menu in the editor. It's shown once. Send it on every request:

Authorization: Bearer sk_live_…

Keys inherit your plan. Rendering requires an active subscription; transcription and rendering draw on the same monthly minutes as the editor.

Quickstart — local file

import { readFileSync } from "node:fs";

const KEY = process.env.SOCAPTIONS_API_KEY;
const auth = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };

const file = readFileSync("clip.mp4");

// 1. Get a signed upload URL for your local file.
const upload = await fetch("https://socaptions.com/api/v1/uploads", {
  method: "POST",
  headers: auth,
  body: JSON.stringify({ filename: "clip.mp4", contentType: "video/mp4", size: file.length }),
}).then((r) => r.json());

// 2. Send the bytes straight to storage.
await fetch(upload.uploadUrl, {
  method: "PUT",
  headers: { "Content-Type": "video/mp4" },
  body: file,
});

// 3. Transcribe + render.
const job = await fetch("https://socaptions.com/api/v1/jobs", {
  method: "POST",
  headers: auth,
  body: JSON.stringify({ videoUrl: upload.videoUrl, styleName: "Classic Shadow" }),
}).then((r) => r.json());

// 4. Poll until the MP4 is ready.
let result;
do {
  await new Promise((r) => setTimeout(r, 3000));
  result = await fetch(`https://socaptions.com/api/v1/jobs/${job.jobId}`, {
    headers: auth,
  }).then((r) => r.json());
} while (result.status === "processing");

console.log(result.videoUrl);

The upload URL accepts exactly the declared size, expires in 24 hours, and caps at 25 MB. The source file is deleted when you poll a finished job.

Already have a public URL

Skip the upload step entirely and post the URL:

curl -X POST https://socaptions.com/api/v1/jobs \
  -H "Authorization: Bearer $SOCAPTIONS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "videoUrl": "https://example.com/clip.mp4",
    "styleName": "Classic Shadow",
    "mode": "segment",
    "fontSize": 18,
    "textY": 80,
    "animation": { "speed": 1, "reveal": "word", "type": "pop", "maxWordsPerLine": 4 }
  }'

Endpoints

POST /api/v1/uploads

Body: filename, contentType (video/mp4 or video/quicktime), size in bytes. Returns an uploadUrl to PUT the file to and the videoUrl to pass to the job.

POST /api/v1/jobs

Only videoUrl is required. Everything else falls back to the editor defaults. Returns 202 with a jobId. Takes up to a few minutes because transcription happens inline.

  • subtitles — supply your own entries to skip transcription entirely.
  • subtitleFile (string, optional) — raw SRT, VTT or ASS file contents. Burned in as-is instead of transcribing; not combined with subtitles.
  • mode — segment (default) or word.
  • language — ISO-639-1 hint for Whisper.
  • styleName, customStyle, fontSize, lineHeight, textX, textY, containerWidthPercent, animation — the same knobs as the editor sidebar.
  • videoWidth, videoHeight, videoDurationSeconds — only needed if we can't read them from the container.

GET /api/v1/jobs/{id}

Returns status (processing / done / failed), progress (0–1) and, once done, videoUrl. The rendered MP4 is deleted after 24 hours — download it.

Caption styles

Pass any of these as styleName:

Classic ShadowBox BackgroundEmphasisBold OutlineNeon GlowCinematicTypewriter3D PopKaraokeHormoziHighlight BoxMinimalBubble

Custom style

customStyle overrides any field of the chosen style; leave out what you want to keep. Lengths are in caption units (pixels on a 400 px tall frame), so a caption looks the same at every resolution.

  • fontFamily — one of the font ids below
  • fontWeight — "400" to "900"; snaps to the closest weight the font has
  • textColor — CSS colour of the text
  • emphasisColor — accent colour: the spoken word for highlights, the box for highlight "box", the italic words of alternateSerif
  • highlight — "none", "color" (spoken word in the accent colour), "box" (spoken word on a box) or "karaoke" (words fill in as they are spoken)
  • alternateSerif — every other word in an italic serif
  • textTransform — "none", "uppercase", "lowercase" or "capitalize"
  • letterSpacing — in em, e.g. 0.1
  • shadowBlur, shadowOpacity, shadowOffsetY — drop shadow (blur 0 turns it off)
  • hasGlow — glow in the text colour; shadowBlur sets its size
  • has3DEffect — stacked 3D shadow
  • hasOutline, outlineColor, outlineWidth — stroke around the letters; width in percent of the font size (default 8)
  • backgroundColor, borderRadius — box behind the caption, e.g. "rgba(0, 0, 0, 0.75)" ("transparent" for none)

Fonts:

sans · Instrument Sansmontserrat · Montserratinter · Interpoppins · Poppinsoswald · Oswaldanton · Antonbebas · Bebas Neuebangers · Bangersmarker · Permanent Markerplayfair · Playfair Displaymono · Courier Prime

Any language renders in the same font in the editor and the MP4: Cyrillic, Greek, Arabic, Hebrew, Indic scripts, Thai, Chinese, Japanese and Korean fall back to the matching Noto webfont.

"styleName": "Hormozi",
"customStyle": {
  "fontFamily": "anton",
  "emphasisColor": "#00E676",
  "highlight": "box",
  "outlineWidth": 12
}

Limits

  • Source files up to 25 MB, MP4 or MOV.
  • Transcription and rendered video each come out of your plan's 100 monthly minutes. A job whose video would exceed the export minutes left returns 402 with reason: "export_quota_exceeded" and resetsAt.
  • Uploads are deleted after rendering; renders expire after 24 hours.
  • Errors are JSON with an error field: 401 bad key, 402 quota or no subscription, 413 file too large, 422 unreadable video.