Skip to main content

Use the REST API

Create an API key, upload a video, create a caption project and download its finished export.

Integrations are available on Max subscriptions. Video processing uses your existing minutes and plan limits.

Create an API key

Integrations are available on Max subscriptions. Open Settings → Integrations, choose Create API key, enter a name and select permissions.

For the workflow below, keep Read projects, Create and manage projects and Export projects selected. Copy the key when it appears; it is shown only once.

| Permission | Scope | Needed for | | --- | --- | --- | | Read projects | projects:read | Account limits, styles, presets, projects, upload details, operations and results | | Create and manage projects | projects:write | Uploads, project creation, retries and cancellation | | Export projects | exports:write | Exports, auto-export and export retries | | Manage Webhooks | webhooks:manage | Webhook endpoints and deliveries; optional and not selected by default |

Keys expire after 90 days by default. You can choose 1–365 days or no expiration. Revoke unused keys in settings. Revocation blocks new requests; it does not cancel work already accepted.

Check your first request

The REST base URL is https://api.captionbolt.com. Send the key in the Authorization header from your server or local script. Keep it out of browser code, URLs and AI conversations. Session cookies are not accepted.

In a Bash terminal, enter your key without putting it in shell history:

read -r -s -p 'CaptionBolt API key: ' CAPTIONBOLT_API_KEY
export CAPTIONBOLT_API_KEY
printf '\n'

curl --fail-with-body https://api.captionbolt.com/v1/account \
  -H "Authorization: Bearer $CAPTIONBOLT_API_KEY"

A successful request returns your credit balance, upload limits, available output formats and project access period. Credit values are in seconds; divide by 60 for minutes. Check credits.availableCreditsSec before starting work.

You can also find a caption style with GET /v1/styles or one of your saved presets with GET /v1/presets. Lists accept search, limit (1–100, default 50) and offset.

Upload your video

Upload MP4, MOV, WebM or AVI bytes before creating a project. A video URL is not accepted. The sequence is:

  1. Create an upload with POST /v1/uploads, real file metadata and an Idempotency-Key.
  2. Poll its operation. When it is completed, use the final resourceId as the upload ID. Stop on failed.
  3. Read GET /v1/uploads/{uploadId} for the part size, part count and already uploaded parts.
  4. Request upload URLs from POST /v1/uploads/{uploadId}/parts, with {"partNumbers":[1]}. You can request up to 32 parts at once.
  5. PUT each missing part to its returned URL with the exact byte length. Send no API key to these URLs.
  6. Send {} to POST /v1/uploads/{uploadId}/complete and confirm the upload is uploaded.

Submit the measured video duration as durationSec. If omitted, CaptionBolt reserves your plan's maximum video duration, which can reject a short video when fewer minutes remain. The actual duration is checked before processing.

Example upload and project creation

This Node.js 24 example handles the multipart upload and creates a project in review mode. Save it as upload-and-create.mjs. It reads the file in parts rather than loading the whole video into memory. Do not change the file while it runs.

import { createHash } from "node:crypto";
import { openAsBlob } from "node:fs";
import { stat } from "node:fs/promises";
import { basename, extname } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";

const apiKey = process.env.CAPTIONBOLT_API_KEY;
const uploadKey = process.env.CAPTIONBOLT_UPLOAD_KEY;
const createKey = process.env.CAPTIONBOLT_CREATE_KEY;
const [filePath, durationText] = process.argv.slice(2);
const durationSec = Number(durationText);
if (!apiKey || !uploadKey || !createKey || !filePath || !Number.isFinite(durationSec) || durationSec <= 0) {
  throw new Error("Set the API key and action keys, then supply a file and measured duration in seconds.");
}

async function api(method, path, input, idempotencyKey) {
  const response = await fetch(`https://api.captionbolt.com${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
    },
    ...(input === undefined ? {} : { body: JSON.stringify(input) }),
    redirect: "error",
    signal: AbortSignal.timeout(30000),
  });
  if (!response.ok) {
    const error = await response.json().catch(() => null);
    const detail = error?.message ?? error?.code ?? "Request failed";
    throw new Error(`API HTTP ${response.status}: ${detail}; Retry-After: ${response.headers.get("retry-after") ?? "none"}`);
  }
  return response.json();
}

async function waitForOperation(operation) {
  console.log("Operation ID:", operation.id);
  const deadline = Date.now() + 120000;
  while (operation.state !== "completed") {
    if (operation.state === "failed") throw new Error(operation.error ?? "Action failed");
    if (Date.now() >= deadline) throw new Error(`Continue polling operation ${operation.id}; do not start a new action.`);
    await sleep(Math.max(1, operation.pollAfterSeconds) * 1000);
    operation = await api("GET", `/v1/operations/${operation.id}`);
  }
  return operation;
}

const contentType = {
  ".mp4": "video/mp4", ".mov": "video/quicktime",
  ".webm": "video/webm", ".avi": "video/x-msvideo",
}[extname(filePath).toLowerCase()];
if (!contentType) throw new Error("Choose an MP4, MOV, WebM or AVI file.");
const metadata = await stat(filePath);
if (!metadata.isFile()) throw new Error("Choose a regular video file.");
const video = await openAsBlob(filePath, { type: contentType });
const fileName = basename(filePath);
const lastModifiedMs = Math.trunc(metadata.mtimeMs);
const fingerprint = createHash("sha256")
  .update(`captionbolt-upload-v1\0${fileName}\0${video.size}\0${lastModifiedMs}\0${contentType}`)
  .update(Buffer.from(await video.slice(0, 65536).arrayBuffer()))
  .update(Buffer.from(await video.slice(Math.max(65536, video.size - 65536)).arrayBuffer()))
  .digest("hex");

const uploaded = await waitForOperation(await api("POST", "/v1/uploads", {
  fileName, fileSize: video.size, lastModifiedMs, contentType, fingerprint, durationSec,
}, uploadKey));
const uploadId = uploaded.resourceId;
console.log("Upload ID:", uploadId);
let upload = await api("GET", `/v1/uploads/${uploadId}`);
if (upload.status === "uploading" || upload.status === "completing") {
  upload = await api("POST", `/v1/uploads/${uploadId}/resume`, {});
}
if (upload.status === "completing") {
  throw new Error("Upload completion is still in progress. Wait, then rerun with the same action keys.");
}
if (upload.status === "expired" || upload.status === "aborted") {
  throw new Error(`Upload is ${upload.status}. Start a new upload with new action keys.`);
}
if (upload.status !== "uploaded" && upload.status !== "consumed") {
  const completed = new Set(upload.completedPartNumbers);
  for (let number = 1; number <= upload.partCount; number++) {
    if (completed.has(number)) continue;
    const signed = await api("POST", `/v1/uploads/${uploadId}/parts`, { partNumbers: [number] });
    for (const part of signed.signedParts) {
      const start = (part.partNumber - 1) * upload.partSize;
      const bytes = video.slice(start, start + part.contentLength);
      if (bytes.size !== part.contentLength) throw new Error("File part length changed.");
      const response = await fetch(part.url, {
        method: "PUT", body: bytes,
        headers: { "Content-Length": String(part.contentLength) },
        redirect: "error", signal: AbortSignal.timeout(120000),
      });
      if (!response.ok) throw new Error(`Part ${part.partNumber}: HTTP ${response.status}`);
    }
  }
  const finished = await api("POST", `/v1/uploads/${uploadId}/complete`, {});
  if (finished.status !== "uploaded") throw new Error("Upload is not ready for project creation.");
}

const created = await waitForOperation(await api("POST", "/v1/projects", {
  uploadSessionId: uploadId, mode: "review",
}, createKey));
console.log("Project ID:", created.resourceId);

Generate keys once for this video's upload and project creation. Retain them if you retry the same requests; use new keys for a different video or action.

export CAPTIONBOLT_UPLOAD_KEY="$(node -e 'console.log(crypto.randomUUID())')"
export CAPTIONBOLT_CREATE_KEY="$(node -e 'console.log(crypto.randomUUID())')"
node upload-and-create.mjs /absolute/path/lesson.mp4 60

Replace the path and 60 with your file and its measured duration. A media inspection tool such as FFprobe can measure duration; do not guess it from the file size. The script prints operation IDs for recovery, then the upload and project IDs.

The fingerprint is SHA-256 over the UTF-8 metadata prefix shown above, followed by the first and last 64 KiB without overlap. Preserve the same name, size, modification time and MIME type when resuming.

Follow your project

Set PROJECT_ID to the ID printed by the script:

export PROJECT_ID='<project-id>'
curl --fail-with-body "https://api.captionbolt.com/v1/projects/$PROJECT_ID" \
  -H "Authorization: Bearer $CAPTIONBOLT_API_KEY"

Read the returned status and poll at pollAfterSeconds:

| Project status | What to do | | --- | --- | | processing | Wait for video preparation and captions | | ready | Review and save the video in CaptionBolt, then export | | exporting | Wait for the export | | completed | Fetch the result and download your files | | failed | Read error, fix the cause and explicitly retry | | cancelled | Stop polling; this run was cancelled |

An operation marked completed does not mean the video is finished. Operations describe actions, while project status describes the video. To inspect an action, call GET /v1/operations/{id}; stop on failed and inspect its error.

When creating a project, you can pass language and either styleId or presetId from the list endpoints. Omit both IDs for the default caption style. Use mode: "auto-export" to automatically export the MP4 and subtitle files after processing; this also requires exports:write. If it stays at ready, inspect the project's autoExport operation for an error.

Export the saved result

For review projects, open the project in the CaptionBolt Editor, review it and save changes. Generate an export key once, then request your export:

export CAPTIONBOLT_EXPORT_KEY="$(node -e 'console.log(crypto.randomUUID())')"
curl --fail-with-body "https://api.captionbolt.com/v1/projects/$PROJECT_ID/export" \
  -H "Authorization: Bearer $CAPTIONBOLT_API_KEY" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $CAPTIONBOLT_EXPORT_KEY" \
  --data '{"outputFormats":["mp4"]}'

Poll the returned operation, then the project until it is completed. Stop if either fails. Exports use the latest saved changes; unsaved changes are excluded. outputFormats can include mp4, srt, vtt, ass and txt, subject to your current plan. Omit it to export every format, as the Editor does; results include only the formats your plan allows. A completed project keeps its files, so requesting a format it lacks is rejected.

Download your files

Once the project is completed:

curl --fail-with-body "https://api.captionbolt.com/v1/projects/$PROJECT_ID/result" \
  -H "Authorization: Bearer $CAPTIONBOLT_API_KEY"

The response contains files, each with format and url, plus expiresAt for the download links. Download from each file URL without sending your API key.

Links last at most ten minutes. If one expires, request fresh links while the project remains accessible. Projects are accessible for 14 days from creation; link renewal does not extend that window. The project's own expiresAt is its access deadline, while the result's expiresAt is the download-link deadline.

Completed projects keep their existing export. To edit again, create an editable copy in CaptionBolt.

Recover from errors

| Response | Next step | | --- | --- | | 400 | Check fields and required idempotency headers | | 401 | Check that the API key is valid, unexpired and not revoked | | 403 | Check scopes and current account limits; write permission alone cannot read progress | | 404 | Check the ID and account; deleted or expired projects are unavailable | | 409 | Check project state and whether the same action key was reused with different input | | 429 | Follow Retry-After when present and read the error; request-rate or upload/job capacity limits may apply. REST and MCP share 120 requests per account per minute | | 503 or timeout | Wait, poll a known operation, or retry the identical action with its original key |

An HTTP success can accept an action that later fails. Inspect operation.state and operation.error, not only the initial HTTP status. PAYMENT_REQUIRED means available processing minutes are insufficient, possibly because active work has reserved them. PAYLOAD_TOO_LARGE means the file exceeds your current upload limit. Check GET /v1/account, fix the cause and start a new action with a new key only after the earlier operation is confirmed failed. Reusing its key returns that same failed operation.

Upload creation and project creation, export, retry and cancellation require Idempotency-Key: 1–128 printable ASCII characters without spaces. A new action needs a new key. After a timeout, reuse the original key and identical input; a lost response does not prove failure.

To resume an interrupted upload, compare the original metadata and fingerprint with GET /v1/uploads/{uploadId}. Call POST /v1/uploads/{uploadId}/resume and use its returned state and part list. If it remains completing, wait before resuming again. Upload only missing parts and complete it when the state is uploading. An expired or aborted session needs a new upload; it cannot be resumed. DELETE /v1/uploads/{uploadId} aborts an upload not yet consumed by a project; it does not cancel a created project.

Use POST /v1/projects/{projectId}/retry or /cancel for an explicit action, with an empty JSON object and its own idempotency key. An export retry also requires exports:write.

For service availability, the public GET /health/integrations endpoint returns 200 when the integration service is ready or 503 when unavailable. It requires no API key. It does not test your credentials or confirm that your video finished.

Complete schemas are available in the API reference (opens in a new window) and OpenAPI document. To receive progress milestones without polling, configure Webhooks.

We use cookies to remember preferences, measure site performance, and improve CaptionBolt.