captionbob docs

Version 1.0.0
Contents ↓

HTTP API examples

Quickstart

This page makes a captioned video from a video file. You make an API key, create a project, upload the video and transcribe it. Then you choose an effect, burn the captions on the video and download the captioned video.

Before you start

You need a captionbob server that runs, and a user of that server. In the folder captionbob_server, node server.js starts the server with the settings of its .env file, and node add_user.js <user name> adds a user. The command asks for the password, which has at least 12 characters. You also need a video file. The steps use the file test.mp4 in the current folder.

CAPTIONBOB_URL is the default address of the server. Change it when your server listens on another host or port. The code of each step runs after this first code. The curl code runs in bash and reads values from the answers with jq. The Python code uses the package requests. The JavaScript code runs in Node.js 22 or later as an ES module, such as a file example.mjs.

The answers on this page are real answers of a captionbob server, recorded on 2026-10-28. Your ids, names and times are different.

curl

CAPTIONBOB_URL="http://127.0.0.1:9400"
CAPTIONBOB_KEY="<your API key>"

Python

import time

import requests

CAPTIONBOB_URL = "http://127.0.0.1:9400"
HEADERS = {"Authorization": "Bearer <your API key>"}

JavaScript

import { readFile, writeFile } from "node:fs/promises";
import { setTimeout } from "node:timers/promises";

const CAPTIONBOB_URL = "http://127.0.0.1:9400";
const HEADERS = { Authorization: "Bearer <your API key>" };
let response;
let answer;

Step 1: Log in

A user who is logged in makes the API key. Log in with your user name and your password. A good login sets the session cookie captionbob_session. You can also make the key on the API keys page of the dashboard. Then put the key in place of <your API key> and go to step 3.

curl

curl -s -X POST "$CAPTIONBOB_URL/api/login" \
  -c cookies.txt \
  -H "Content-Type: application/json" \
  -d '{"userName": "<your user name>", "password": "<your password>"}'

Python

session = requests.Session()
response = session.post(
    f"{CAPTIONBOB_URL}/api/login",
    json={"userName": "<your user name>", "password": "<your password>"},
)
if not response.ok:
    raise RuntimeError(response.text)
answer = response.json()
print(answer)

JavaScript

response = await fetch(`${CAPTIONBOB_URL}/api/login`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ userName: "<your user name>", password: "<your password>" }),
});
if (!response.ok) {
  throw new Error(await response.text());
}
const cookie = response.headers.get("set-cookie").split(";")[0];
answer = await response.json();
console.log(JSON.stringify(answer, null, 2));

Answer: status 200.

{
  "user": {
    "userId": 1,
    "userName": "editor"
  },
  "expiresAt": "2026-11-04T01:33:12.056Z"
}

Step 2: Make an API key

Make a key with the session cookie of the login. The answer shows the key one time only, so keep it. This page shows <your API key> in place of the key. The next steps send the key in the header Authorization.

curl

ANSWER=$(curl -s -X POST "$CAPTIONBOB_URL/api/keys" \
  -b cookies.txt \
  -H "Content-Type: application/json" \
  -d '{"name": "Quickstart"}')
echo "$ANSWER"
CAPTIONBOB_KEY=$(echo "$ANSWER" | jq -r '.key.key')

Python

response = session.post(
    f"{CAPTIONBOB_URL}/api/keys",
    json={"name": "Quickstart"},
)
if not response.ok:
    raise RuntimeError(response.text)
answer = response.json()
print(answer)
HEADERS["Authorization"] = "Bearer " + answer["key"]["key"]

JavaScript

response = await fetch(`${CAPTIONBOB_URL}/api/keys`, {
  method: "POST",
  headers: { Cookie: cookie, "Content-Type": "application/json" },
  body: JSON.stringify({ name: "Quickstart" }),
});
if (!response.ok) {
  throw new Error(await response.text());
}
answer = await response.json();
console.log(JSON.stringify(answer, null, 2));
HEADERS.Authorization = "Bearer " + answer.key.key;

Answer: status 201.

{
  "key": {
    "id": 1,
    "name": "Quickstart",
    "key": "<your API key>",
    "keyStart": "ocap_J6Xugaw"
  }
}

Step 3: Create a project

A project holds videos. Give the project a name.

curl

ANSWER=$(curl -s -X POST "$CAPTIONBOB_URL/api/projects" \
  -H "Authorization: Bearer $CAPTIONBOB_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Launch videos"}')
echo "$ANSWER"
PROJECT_ID=$(echo "$ANSWER" | jq -r '.project.id')

Python

response = requests.post(
    f"{CAPTIONBOB_URL}/api/projects",
    headers=HEADERS,
    json={"name": "Launch videos"},
)
if not response.ok:
    raise RuntimeError(response.text)
answer = response.json()
print(answer)
project_id = answer["project"]["id"]

JavaScript

response = await fetch(`${CAPTIONBOB_URL}/api/projects`, {
  method: "POST",
  headers: { ...HEADERS, "Content-Type": "application/json" },
  body: JSON.stringify({ name: "Launch videos" }),
});
if (!response.ok) {
  throw new Error(await response.text());
}
answer = await response.json();
console.log(JSON.stringify(answer, null, 2));
const projectId = answer.project.id;

Answer: status 201.

{
  "project": {
    "id": 1,
    "name": "Launch videos",
    "state": "active",
    "createdBy": "editor",
    "createdAt": "2026-10-28T01:33:12.066Z",
    "updatedAt": "2026-10-28T01:33:12.066Z",
    "videoCount": 0
  }
}

Step 4: Upload the video

Send the video file as the raw body of the request, and its file name in the query value name. The server probes the file and keeps only a video.

curl

ANSWER=$(curl -s -X POST "$CAPTIONBOB_URL/api/projects/$PROJECT_ID/videos?name=test.mp4" \
  -H "Authorization: Bearer $CAPTIONBOB_KEY" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @test.mp4)
echo "$ANSWER"
VIDEO_ID=$(echo "$ANSWER" | jq -r '.video.id')

Python

with open("test.mp4", "rb") as video_file:
    response = requests.post(
        f"{CAPTIONBOB_URL}/api/projects/{project_id}/videos",
        headers={**HEADERS, "Content-Type": "application/octet-stream"},
        params={"name": "test.mp4"},
        data=video_file,
    )
if not response.ok:
    raise RuntimeError(response.text)
answer = response.json()
print(answer)
video_id = answer["video"]["id"]

JavaScript

response = await fetch(`${CAPTIONBOB_URL}/api/projects/${projectId}/videos?${new URLSearchParams({ name: "test.mp4" })}`, {
  method: "POST",
  headers: { ...HEADERS, "Content-Type": "application/octet-stream" },
  body: await readFile("test.mp4"),
});
if (!response.ok) {
  throw new Error(await response.text());
}
answer = await response.json();
console.log(JSON.stringify(answer, null, 2));
const videoId = answer.video.id;

Answer: status 201.

{
  "video": {
    "id": 1,
    "projectId": 1,
    "originalName": "test.mp4",
    "sizeBytes": 44219360,
    "width": 1080,
    "height": 1920,
    "frameRate": "25/1",
    "durationSeconds": 42,
    "hasAudio": true,
    "currentVersion": null,
    "versionCount": 0,
    "createdBy": "editor",
    "createdAt": "2026-10-28T01:33:13.076Z",
    "updatedAt": "2026-10-28T01:33:13.076Z",
    "latestRender": null
  }
}

Step 5: Transcribe the video

A transcription finds the spoken words and their times. It is a render of the kind transcribe. The server queues the render and answers at once.

curl

ANSWER=$(curl -s -X POST "$CAPTIONBOB_URL/api/videos/$VIDEO_ID/renders" \
  -H "Authorization: Bearer $CAPTIONBOB_KEY" \
  -H "Content-Type: application/json" \
  -d '{"kind": "transcribe"}')
echo "$ANSWER"
TRANSCRIPTION_ID=$(echo "$ANSWER" | jq -r '.render.id')

Python

response = requests.post(
    f"{CAPTIONBOB_URL}/api/videos/{video_id}/renders",
    headers=HEADERS,
    json={"kind": "transcribe"},
)
if not response.ok:
    raise RuntimeError(response.text)
answer = response.json()
print(answer)
transcription_id = answer["render"]["id"]

JavaScript

response = await fetch(`${CAPTIONBOB_URL}/api/videos/${videoId}/renders`, {
  method: "POST",
  headers: { ...HEADERS, "Content-Type": "application/json" },
  body: JSON.stringify({ kind: "transcribe" }),
});
if (!response.ok) {
  throw new Error(await response.text());
}
answer = await response.json();
console.log(JSON.stringify(answer, null, 2));
const transcriptionId = answer.render.id;

Answer: status 201.

{
  "render": {
    "id": 1,
    "videoId": 1,
    "versionNumber": null,
    "kind": "transcribe",
    "state": "queued",
    "requestedBy": "editor",
    "apiKeyName": "Quickstart",
    "doneSeconds": 0,
    "totalSeconds": null,
    "hasOutput": false,
    "errorMessage": null,
    "createdAt": "2026-10-28T01:33:13.083Z",
    "startedAt": null,
    "finishedAt": null
  }
}

Step 6: Wait for the transcription

Read the render every 2 seconds until its state is done or failed. The page Track a task tells more about the states.

curl

while true; do
  ANSWER=$(curl -s "$CAPTIONBOB_URL/api/renders/$TRANSCRIPTION_ID" \
    -H "Authorization: Bearer $CAPTIONBOB_KEY")
  STATE=$(echo "$ANSWER" | jq -r '.render.state')
  echo "$STATE"
  if [ "$STATE" = "done" ] || [ "$STATE" = "failed" ]; then
    break
  fi
  sleep 2
done
echo "$ANSWER"

Python

while True:
    response = requests.get(
        f"{CAPTIONBOB_URL}/api/renders/{transcription_id}",
        headers=HEADERS,
    )
    if not response.ok:
        raise RuntimeError(response.text)
    answer = response.json()
    state = answer["render"]["state"]
    print(state)
    if state == "done" or state == "failed":
        break
    time.sleep(2)
print(answer)

JavaScript

while (true) {
  response = await fetch(`${CAPTIONBOB_URL}/api/renders/${transcriptionId}`, { headers: HEADERS });
  if (!response.ok) {
    throw new Error(await response.text());
  }
  answer = await response.json();
  console.log(answer.render.state);
  if (answer.render.state === "done" || answer.render.state === "failed") {
    break;
  }
  await setTimeout(2000);
}
console.log(JSON.stringify(answer, null, 2));

Answer: status 200, the last answer of the wait.

{
  "render": {
    "id": 1,
    "videoId": 1,
    "versionNumber": null,
    "kind": "transcribe",
    "state": "done",
    "requestedBy": "editor",
    "apiKeyName": "Quickstart",
    "doneSeconds": 41.991875,
    "totalSeconds": 41.991875,
    "hasOutput": true,
    "errorMessage": null,
    "createdAt": "2026-10-28T01:33:13.083Z",
    "startedAt": "2026-10-28T01:33:13.240Z",
    "finishedAt": "2026-10-28T01:33:31.403Z"
  }
}

Step 7: Get the transcript

The output file of a transcription is the transcript JSON: the language of the speech and each word with its start time and its end time.

curl

ANSWER=$(curl -s "$CAPTIONBOB_URL/api/renders/$TRANSCRIPTION_ID/file" \
  -H "Authorization: Bearer $CAPTIONBOB_KEY")
echo "$ANSWER"
TRANSCRIPT="$ANSWER"

Python

response = requests.get(
    f"{CAPTIONBOB_URL}/api/renders/{transcription_id}/file",
    headers=HEADERS,
)
if not response.ok:
    raise RuntimeError(response.text)
answer = response.json()
print(answer)
transcript = answer

JavaScript

response = await fetch(`${CAPTIONBOB_URL}/api/renders/${transcriptionId}/file`, { headers: HEADERS });
if (!response.ok) {
  throw new Error(await response.text());
}
answer = await response.json();
console.log(JSON.stringify(answer, null, 2));
const transcript = answer;

Answer: status 200.

{
  "format_version": 1,
  "language": "en",
  "language_probability": 0.9990234375,
  "duration_seconds": 41.991875,
  "words": [
    {
      "text": "Dirty",
      "start_seconds": 0,
      "end_seconds": 0.22,
      "probability": 0.9716796875
    },
    {
      "text": "facts",
      "start_seconds": 0.22,
      "end_seconds": 0.62,
      "probability": 0.78515625
    },
    {
      "text": "about",
      "start_seconds": 0.62,
      "end_seconds": 1,
      "probability": 0.99951171875
    },
    {
      "text": "ancient",
      "start_seconds": 1,
      "end_seconds": 1.44,
      "probability": 0.9482421875
    }
  ]
}

The answer has 116 words. This page shows the first 4.

Step 8: Choose an effect

A version of the video holds the effect with its settings, the transcript and the plugins. The HTTP API calls the effect a style. Add the first version with the effect word_by_word, with no changed settings and no plugins.

curl

BODY=$(jq -n --argjson transcript "$TRANSCRIPT" '{
  "version": {
    "format_version": 1,
    "style": {"name": "word_by_word", "settings": {}},
    "transcript": $transcript,
    "plugins": []
  }
}')
curl -s -X POST "$CAPTIONBOB_URL/api/videos/$VIDEO_ID/versions" \
  -H "Authorization: Bearer $CAPTIONBOB_KEY" \
  -H "Content-Type: application/json" \
  -d "$BODY"

Python

response = requests.post(
    f"{CAPTIONBOB_URL}/api/videos/{video_id}/versions",
    headers=HEADERS,
    json={
        "version": {
            "format_version": 1,
            "style": {"name": "word_by_word", "settings": {}},
            "transcript": transcript,
            "plugins": [],
        },
    },
)
if not response.ok:
    raise RuntimeError(response.text)
answer = response.json()
print(answer)

JavaScript

response = await fetch(`${CAPTIONBOB_URL}/api/videos/${videoId}/versions`, {
  method: "POST",
  headers: { ...HEADERS, "Content-Type": "application/json" },
  body: JSON.stringify({
    version: {
      format_version: 1,
      style: { name: "word_by_word", settings: {} },
      transcript: transcript,
      plugins: [],
    },
  }),
});
if (!response.ok) {
  throw new Error(await response.text());
}
answer = await response.json();
console.log(JSON.stringify(answer, null, 2));

Answer: status 201.

{
  "currentVersion": 1,
  "versionCount": 1
}

Step 9: Burn the captions

A burn is a render of the kind burn. It burns the current version on the video.

curl

ANSWER=$(curl -s -X POST "$CAPTIONBOB_URL/api/videos/$VIDEO_ID/renders" \
  -H "Authorization: Bearer $CAPTIONBOB_KEY" \
  -H "Content-Type: application/json" \
  -d '{"kind": "burn"}')
echo "$ANSWER"
BURN_ID=$(echo "$ANSWER" | jq -r '.render.id')

Python

response = requests.post(
    f"{CAPTIONBOB_URL}/api/videos/{video_id}/renders",
    headers=HEADERS,
    json={"kind": "burn"},
)
if not response.ok:
    raise RuntimeError(response.text)
answer = response.json()
print(answer)
burn_id = answer["render"]["id"]

JavaScript

response = await fetch(`${CAPTIONBOB_URL}/api/videos/${videoId}/renders`, {
  method: "POST",
  headers: { ...HEADERS, "Content-Type": "application/json" },
  body: JSON.stringify({ kind: "burn" }),
});
if (!response.ok) {
  throw new Error(await response.text());
}
answer = await response.json();
console.log(JSON.stringify(answer, null, 2));
const burnId = answer.render.id;

Answer: status 201.

{
  "render": {
    "id": 2,
    "videoId": 1,
    "versionNumber": 1,
    "kind": "burn",
    "state": "queued",
    "requestedBy": "editor",
    "apiKeyName": "Quickstart",
    "doneSeconds": 0,
    "totalSeconds": null,
    "hasOutput": false,
    "errorMessage": null,
    "createdAt": "2026-10-28T01:33:34.446Z",
    "startedAt": null,
    "finishedAt": null
  }
}

Step 10: Wait for the burn

Read the render every 2 seconds until its state is done or failed.

curl

while true; do
  ANSWER=$(curl -s "$CAPTIONBOB_URL/api/renders/$BURN_ID" \
    -H "Authorization: Bearer $CAPTIONBOB_KEY")
  STATE=$(echo "$ANSWER" | jq -r '.render.state')
  echo "$STATE"
  if [ "$STATE" = "done" ] || [ "$STATE" = "failed" ]; then
    break
  fi
  sleep 2
done
echo "$ANSWER"

Python

while True:
    response = requests.get(
        f"{CAPTIONBOB_URL}/api/renders/{burn_id}",
        headers=HEADERS,
    )
    if not response.ok:
        raise RuntimeError(response.text)
    answer = response.json()
    state = answer["render"]["state"]
    print(state)
    if state == "done" or state == "failed":
        break
    time.sleep(2)
print(answer)

JavaScript

while (true) {
  response = await fetch(`${CAPTIONBOB_URL}/api/renders/${burnId}`, { headers: HEADERS });
  if (!response.ok) {
    throw new Error(await response.text());
  }
  answer = await response.json();
  console.log(answer.render.state);
  if (answer.render.state === "done" || answer.render.state === "failed") {
    break;
  }
  await setTimeout(2000);
}
console.log(JSON.stringify(answer, null, 2));

Answer: status 200, the last answer of the wait.

{
  "render": {
    "id": 2,
    "videoId": 1,
    "versionNumber": 1,
    "kind": "burn",
    "state": "done",
    "requestedBy": "editor",
    "apiKeyName": "Quickstart",
    "doneSeconds": 42,
    "totalSeconds": 42,
    "hasOutput": true,
    "errorMessage": null,
    "createdAt": "2026-10-28T01:33:34.446Z",
    "startedAt": "2026-10-28T01:33:35.264Z",
    "finishedAt": "2026-10-28T01:34:06.800Z"
  }
}

Step 11: Download the captioned video

The output file of a burn is the captioned video.

curl

curl -s -D - -o captioned.mp4 "$CAPTIONBOB_URL/api/renders/$BURN_ID/file" \
  -H "Authorization: Bearer $CAPTIONBOB_KEY"

Python

response = requests.get(
    f"{CAPTIONBOB_URL}/api/renders/{burn_id}/file",
    headers=HEADERS,
)
if not response.ok:
    raise RuntimeError(response.text)
with open("captioned.mp4", "wb") as output_file:
    output_file.write(response.content)
print(response.status_code, response.headers["Content-Type"], len(response.content))

JavaScript

response = await fetch(`${CAPTIONBOB_URL}/api/renders/${burnId}/file`, { headers: HEADERS });
if (!response.ok) {
  throw new Error(await response.text());
}
await writeFile("captioned.mp4", Buffer.from(await response.arrayBuffer()));
console.log(response.status, response.headers.get("content-type"));

Answer: status 200. The body is the file. Its headers are:

HTTP/1.1 200 OK
Content-Type: video/mp4
Content-Length: 25180555
Accept-Ranges: bytes

Next steps

Each example page does one job: