使用 REST API
创建 API Key、上传视频、创建字幕项目并下载导出的成片。
创建 API Key
集成能力向 Max 订阅用户开放。打开设置 → 集成,选择 创建 API Key,填写名称并选择权限。
只授予工作流需要的权限。读取、写入和导出权限默认勾选;查询进度或下载结果时,请保留读取权限。
| 设置中的权限 | Scope | 用途 |
| --- | --- | --- |
| 读取项目 | projects:read | 账户、样式、预设、项目、操作状态和结果 |
| 创建和管理项目 | projects:write | 上传、创建项目、取消和重试 |
| 导出项目 | exports:write | 导出、自动导出和导出重试 |
| 管理 Webhooks | webhooks:manage | 接收地址和投递记录;可选,默认不勾选 |
密钥默认 90 天后过期,可选择 1–365 天或永不过期。在设置中撤销不再使用的密钥。撤销阻止新请求,但不会取消已经接受的任务。
验证首次请求
REST 基础地址为 https://api.captionbolt.com。从服务器或本地脚本通过 Authorization 请求头发送密钥。不要将密钥放入浏览器代码、URL 或 AI 对话。接口不接受会话 Cookie。
在 Bash 终端中输入密钥,避免将其写入命令历史:
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"
成功响应包含处理时长余额、上传限制、可用导出格式和项目访问期。时长字段以秒为单位,除以 60 即为分钟。开始任务前检查 credits.availableCreditsSec。
通过 GET /v1/styles 查找字幕样式,或通过 GET /v1/presets 查询已保存的预设。列表支持 search、limit(1–100,默认 50)和 offset。
上传视频
创建项目前,先上传 MP4、MOV、WebM 或 AVI 文件的实际内容,不接受视频 URL。步骤如下:
- 使用
POST /v1/uploads、真实文件元数据和Idempotency-Key创建上传。 - 轮询返回的 operation。达到
completed后,将最终resourceId作为上传 ID;遇到failed则停止。 - 使用
GET /v1/uploads/{uploadId}获取分片大小、数量及已上传的分片。 - 向
POST /v1/uploads/{uploadId}/parts发送{"partNumbers":[1]}获取上传 URL,每次最多请求 32 个分片。 - 将每个缺失分片 PUT 到返回的 URL,字节长度必须准确;不要向这些 URL 发送 API Key。
- 向
POST /v1/uploads/{uploadId}/complete发送{},确认状态为uploaded。
通过 durationSec 提交测量得到的视频时长。如果省略,CaptionBolt 会预留套餐允许的最大视频时长;余额较少时,即使短视频也可能被拒绝。处理前仍会核验实际时长。
上传与创建项目示例
以下 Node.js 24 示例完成分片上传,并创建 review 项目。保存为 upload-and-create.mjs。它逐片读取文件,不会将整个视频载入内存;运行期间不要修改文件。
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);
为本次上传和项目创建各生成一次操作键。重发相同请求时保留原键;处理不同视频或发起新操作时使用新键。
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
把路径和 60 替换为你的文件及测量得到的秒数。可使用 FFprobe 等媒体工具测量时长,不要根据文件大小猜测。脚本先打印用于恢复的 operation ID,再打印上传 ID 和项目 ID。
文件指纹使用 SHA-256:先处理示例中的 UTF-8 元数据前缀,再处理首尾各 64 KiB,重叠部分不重复计算。恢复时保持文件名、大小、修改时间和 MIME 类型不变。
查询项目进度
将 PROJECT_ID 设为脚本打印的项目 ID:
export PROJECT_ID='<project-id>'
curl --fail-with-body "https://api.captionbolt.com/v1/projects/$PROJECT_ID" \
-H "Authorization: Bearer $CAPTIONBOLT_API_KEY"
读取返回的 status,按 pollAfterSeconds 间隔轮询:
| 项目状态 | 下一步 |
| --- | --- |
| processing | 等待视频准备和字幕处理 |
| ready | 在 CaptionBolt 中检查、保存,再导出 |
| exporting | 等待导出 |
| completed | 获取结果并下载文件 |
| failed | 读取 error,解决原因后明确发起重试 |
| cancelled | 停止轮询,本次处理已取消 |
operation 的 completed 不表示视频已经完成。operation 描述操作,项目状态描述视频。通过 GET /v1/operations/{id} 查询操作;遇到 failed 停止并检查 error。
创建项目时可提供 language,并选择列表返回的 styleId 或 presetId。两者都省略时使用默认字幕样式。设置 mode: "auto-export" 可在处理后自动导出 MP4,另需 exports:write。若项目一直停在 ready,检查其 autoExport operation 是否报错。
导出已保存的结果
对于 review 项目,在 CaptionBolt 编辑器中检查并保存修改。为导出生成一次操作键,再发送请求:
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"]}'
先轮询 operation,再查询项目直到 completed;任一失败则停止。导出使用最新保存的修改,不含未保存内容。outputFormats 可包含 mp4、srt、vtt、ass 和 txt,具体取决于当前套餐,默认导出 MP4。
下载文件
项目达到 completed 后:
curl --fail-with-body "https://api.captionbolt.com/v1/projects/$PROJECT_ID/result" \
-H "Authorization: Bearer $CAPTIONBOLT_API_KEY"
响应包含 files,每项都有 format 和 url,另有下载链接的 expiresAt。从文件 URL 下载时不要发送 API Key。
链接最长有效十分钟。过期后,可在项目仍可访问时重新获取。项目自创建起可访问 14 天,更新下载链接不会延长访问期。项目的 expiresAt 是访问截止时间,结果的 expiresAt 是下载链接截止时间。
已完成项目保留现有导出。需要再次编辑时,在 CaptionBolt 中创建可编辑副本。
处理错误与恢复
| 响应 | 下一步 |
| --- | --- |
| 400 | 检查字段及必需的幂等请求头 |
| 401 | 检查密钥是否有效、未过期且未撤销 |
| 403 | 检查权限及当前账户限制;只有写入权限不能读取进度 |
| 404 | 检查 ID 和账户;已删除或过期的项目不可访问 |
| 409 | 检查项目状态,或是否用同一操作键提交了不同输入 |
| 429 | 按返回的 Retry-After 等待并读取错误;可能是请求频率或上传、任务容量限制。REST 和 MCP 共用每账户每分钟 120 次请求的限制 |
| 503 或超时 | 等待、查询已知 operation,或用原键重发完全相同的请求 |
HTTP 成功可能只表示操作已接受,之后仍可能失败。检查 operation.state 和 operation.error。PAYMENT_REQUIRED 表示可用处理时长不足,也可能是现有任务已预留时长;PAYLOAD_TOO_LARGE 表示文件超过当前上传限制。查询 GET /v1/account 并解决原因,确认此前 operation 已失败后,再用新键发起新操作;原键只会返回原来的失败 operation。
创建上传、创建项目、导出、重试和取消需要 Idempotency-Key:1–128 个无空格的可打印 ASCII 字符。新操作使用新键。超时后使用原键和相同输入重发;响应丢失不代表失败。
恢复中断上传时,先用 GET /v1/uploads/{uploadId} 核对原始元数据和指纹,再调用 POST /v1/uploads/{uploadId}/resume 并使用返回的状态和分片列表。若仍是 completing,等待后再次恢复;只有 uploading 才上传缺失分片并完成上传。expired 或 aborted 无法恢复,需新建上传。DELETE /v1/uploads/{uploadId} 中止尚未被项目使用的上传,不取消已创建项目。
明确重试或取消时,向 POST /v1/projects/{projectId}/retry 或 /cancel 发送空 JSON 对象,并使用该操作自己的幂等键。导出重试另需 exports:write。
无需密钥的 GET /health/integrations 在集成服务就绪时返回 200,不可用时返回 503。它不验证你的凭证,也不表示视频已完成。
完整结构见 API 参考 (将在新窗口中打开)和 OpenAPI 文档。需要接收进度节点而非轮询时,配置 Webhooks。