2026-10-01 · 5 min read

Add captions to video in Node.js with plain fetch: no FFmpeg, no Whisper

If your backend is Node, captioning a video should not mean installing FFmpeg on every worker, shipping fonts, or running Whisper next to your web server. It is three HTTP calls: submit a video, poll the job, download the result. This post is the whole thing in about thirty lines, using the built-in fetch in Node 18 and later with no dependencies.

What the API does

caption.sh is an async job API. You POST a video URL to /v1/videos and get a 202 with a job id. The render runs on our side. You poll /v1/jobs/{job_id} until the status is done, then fetch /v1/jobs/{job_id}/result to get the MP4 with the captions burned into the pixels. Pricing is prepaid at $0.10 per minute of video, and failed renders are not charged.

The script

import { writeFile } from "node:fs/promises";

const API = "https://api.caption.sh";
const headers = {
  Authorization: `Bearer ${process.env.CAPTION_API_KEY}`,
  "Content-Type": "application/json",
};

export async function captionVideo(sourceUrl, outPath, options = {}) {
  const res = await fetch(`${API}/v1/videos`, {
    method: "POST",
    headers,
    body: JSON.stringify({ source_url: sourceUrl, options }),
  });
  if (res.status === 402) throw new Error("out of credits, top up in the dashboard");
  if (!res.ok) throw new Error(`submit failed: ${res.status} ${await res.text()}`);
  const { job_id } = await res.json();

  for (;;) {
    await new Promise((r) => setTimeout(r, 5000));
    const job = await (await fetch(`${API}/v1/jobs/${job_id}`, { headers })).json();
    if (job.status === "error") throw new Error(`render failed: ${JSON.stringify(job)}`);
    if (job.status === "done") break;
  }

  const mp4 = await fetch(`${API}/v1/jobs/${job_id}/result`, { headers });
  await writeFile(outPath, Buffer.from(await mp4.arrayBuffer()));
}

await captionVideo(process.argv[2], process.argv[3], { max_words: 3 });

Save it as caption.mjs, set CAPTION_API_KEY, and run node caption.mjs https://example.com/clip.mp4 out.mp4. The options object is optional. max_words controls how many words show at once, which is the setting short-form pipelines usually change first.

Handling the cases that happen in production

  • A 402 means the prepaid balance is empty. Stop the batch, top up, then resume. Nothing was charged for the request that bounced.
  • A 422 means the input was rejected: the video is too long, unreadable, or an option failed validation. Fix the input instead of retrying the same request.
  • Send an Idempotency-Key header on submits that might be retried. The same key returns the same job instead of starting a second render.
  • Job states are awaiting_upload, queued, running, done, and error. Put a ceiling on the polling loop and treat anything past it as a failure to retry as a new job.
  • Videos that only exist as local files use the upload flow instead: the POST returns a signed upload URL, you PUT the bytes to it, and the job starts when the upload finishes.

Running it for many clips

Wrap captionVideo in a small queue that runs three to five jobs at once, and log the job id next to each source URL so a failed render is easy to find. Because the API is async and the work happens remotely, concurrency is bounded by your own limits, not by CPU on your servers. Pairing it with a webhook-style flow is possible later, but polling every five seconds is fine for clips under ten minutes.

When this is the wrong tool

caption.sh burns captions into the video. It does not export SRT or VTT sidecar files, so if you need a transcript file for an accessibility archive, use a transcription service for that and keep caption.sh for the version people actually watch on a feed.

Try it

Judge the output in the playground at caption.sh first, then grab an API key from the dashboard and run the script above. The full reference is at caption.sh/docs, and the OpenAPI spec is at api.caption.sh/openapi.json if you would rather generate a typed client.

Back to all articles