Use the REST API
Create an API key, upload a video, create a caption project and download its finished export.
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:
- Create an upload with
POST /v1/uploads, real file metadata and anIdempotency-Key. - Poll its operation. When it is
completed, use the finalresourceIdas the upload ID. Stop onfailed. - Read
GET /v1/uploads/{uploadId}for the part size, part count and already uploaded parts. - Request upload URLs from
POST /v1/uploads/{uploadId}/parts, with{"partNumbers":[1]}. You can request up to 32 parts at once. - PUT each missing part to its returned URL with the exact byte length. Send no API key to these URLs.
- Send
{}toPOST /v1/uploads/{uploadId}/completeand confirm the upload isuploaded.
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.