# Capsubs API: reference for AI coding assistants You are helping a developer integrate the Capsubs API, which burns word-by-word captions (and optionally an AI voiceover) into short videos. Write code that follows these rules: - Read the API key from an environment variable (CAPSUBS_API_KEY). Never hardcode it. - Send it on every request as the header `X-API-Key: `. There is no Bearer/OAuth auth. - Uploads are multipart/form-data with the video in the `file` field. Booleans are the strings "true"/"false". - Processing is asynchronous: create a job, poll GET /api/v1/jobs/{id} every 5 seconds until status is "completed" or "failed", then download output_url WITH the same X-API-Key header. - Check the HTTP status of every response; errors are JSON with a `detail` field. - Files over 95 MB must use the chunked upload flow (see Large files). Base URL: https://capsubs.com Full docs: https://capsubs.com/docs (this text: https://capsubs.com/docs/llm.txt) ## Authentication Header `X-API-Key`. Keys start with "vk_". API access requires the Pro plan. One key per account, created in Dashboard -> API Keys and shown only once. Changing or resetting the account password deletes the key; create a new one afterwards. ## Quota Each accepted video uses one video from the plan (Pro: 40 a month), the same as uploading in the dashboard. Failed jobs are refunded automatically. Rejected requests (4xx) use nothing. A batch is accepted only if every video fits in what is left. ## POST /api/v1/captions/auto Upload a video; captions are transcribed automatically. Fields (multipart/form-data): | Field | Type | Required | Default | Description | |---|---|---|---|---| | file | file | yes | - | The video: MP4, MOV, AVI, MKV or WEBM, up to 95 MB in one request. For bigger files send upload_id instead (see Large files). | | style | string | no | default | A preset ("default", "classic" or "boxed") or a JSON object of style options (see Caption styles). | | tts_enabled | boolean | no | false | Add an AI voiceover read from the captions. | | tts_voice | string | no | alloy | Voiceover voice: alloy, echo, fable, onyx, nova or shimmer. | | mute_original_audio | boolean | no | false | Remove the original audio (the voiceover stays if enabled). | | enable_captions | boolean | no | true | Burn captions into the video. Set false for a voiceover-only video. | | webhook_url | string | no | - | HTTPS URL to notify when this job finishes (unsigned). Register a webhook instead for signed events. | Response: ```json { "id": "3f0c6c1e-8a51-4c47-9d0b-6b2f1f0f7a9e", "status": "pending", "progress": 0, "message": "Job created. Processing will begin shortly.", "created_at": "2026-01-20T12:00:00+00:00" } ``` ## POST /api/v1/captions/custom Use your own words instead of transcription. Same fields as /auto plus a required `captions` field: a JSON string `{"words": [{"text": "Hello", "start": 0.0, "end": 0.4}, ...]}` with times in seconds (up to 3000 words, 60 characters each, all within the video). ```bash curl -X POST "https://capsubs.com/api/v1/captions/custom" \ -H "X-API-Key: YOUR_API_KEY" \ -F "file=@video.mp4" \ -F 'captions={"words":[{"text":"Hello","start":0.0,"end":0.4},{"text":"world","start":0.4,"end":0.9}]}' ``` ## POST /api/v1/captions/batch Up to 5 videos in one request: repeat the `videos` field once per file (all files together up to 95 MB), or repeat `upload_ids` for chunked uploads. Other fields as /auto, applied to every video. Response: `{"jobs": [, ...], "total": 2, "message": "..."}`. ## GET /api/v1/jobs/{job_id} The job. Poll every 5 seconds. ```json { "id": "3f0c6c1e-8a51-4c47-9d0b-6b2f1f0f7a9e", "status": "completed", "progress": 100, "original_filename": "video.mp4", "output_url": "https://capsubs.com/api/v1/jobs/3f0c6c1e-8a51-4c47-9d0b-6b2f1f0f7a9e/download", "duration_seconds": 31.2, "word_count": 84, "error_message": null, "tts_enabled": true, "created_at": "2026-01-20T12:00:00+00:00", "processing_started_at": "2026-01-20T12:00:01+00:00", "completed_at": "2026-01-20T12:00:40+00:00" } ``` output_url is set once completed. error_message explains a failure. Statuses: - pending: Queued. Processing starts shortly. - processing: Transcribing and rendering. progress goes from 0 to 100. - completed: Done. Download the video from output_url. - failed: Something went wrong; error_message says what. The video is returned to your quota. - review: Only for videos uploaded in the dashboard with caption review on: waiting for you to check the words. API jobs never use it. ## GET /api/v1/jobs/{job_id}/download The finished MP4 (send X-API-Key). This is the job's output_url. Kept for 7 days after upload. ## GET /api/v1/jobs/ Your jobs, newest first. Query: limit (1-100, default 50), offset, status. Response: `{"jobs": [...], "total": 12, "limit": 50, "offset": 0}`. ## Large files (chunked upload, up to 1 GB) 1. POST /api/v1/uploads with JSON `{"filename": "video.mp4", "size": }` -> `{"upload_id": "...", "chunk_size": 52428800, "chunks": 3}` 2. PUT /api/v1/uploads/{upload_id}/chunks/{index} for index 0..chunks-1, body = that part's raw bytes (chunk_size bytes each, the last one shorter). Parts can be resent. -> `{"upload_id": "...", "received": 1}` 3. POST /api/v1/captions/auto (or /custom) with `upload_id` instead of `file`. DELETE /api/v1/uploads/{upload_id} cancels. Unfinished uploads are removed after a day. ```python # Files over 95 MB (up to 1 GB): send them in parts, then caption the upload_id. import os import requests BASE = "https://capsubs.com" HEADERS = {"X-API-Key": os.environ["CAPSUBS_API_KEY"]} path = "long-video.mp4" # 1. Start an upload: tells you the part size and how many parts to send r = requests.post(f"{BASE}/api/v1/uploads", headers=HEADERS, json={"filename": os.path.basename(path), "size": os.path.getsize(path)}) r.raise_for_status() upload = r.json() # {"upload_id": "...", "chunk_size": 52428800, "chunks": 3} # 2. Send each part as the raw request body (parts are numbered from 0) with open(path, "rb") as f: for index in range(upload["chunks"]): r = requests.put(f"{BASE}/api/v1/uploads/{upload['upload_id']}/chunks/{index}", headers=HEADERS, data=f.read(upload["chunk_size"])) r.raise_for_status() # 3. Caption it: same fields as a normal upload, with upload_id instead of file r = requests.post(f"{BASE}/api/v1/captions/auto", headers=HEADERS, data={"upload_id": upload["upload_id"], "style": "default"}) r.raise_for_status() job = r.json() # then poll and download as usual ``` ## Caption styles `style` is a preset name: - default: Bold white text, the spoken word highlighted, each line pops in. - classic: Static white text with a black outline, no highlight or animation. - boxed: Text on a solid box (outline_color), easiest to read on bright footage. or a JSON object (every key optional): | Field | Type | Required | Default | Description | |---|---|---|---|---| | style | string | no | default | Preset to start from: default, classic or boxed. Other options override it. | | font_size | integer | no | 110 | 20 to 200, in pixels on a frame whose short side is 1080 (scaled to your video). Long words shrink to fit. | | primary_color | string | no | #FFFFFF | Text color as #RRGGBB. | | highlight_color | string | no | #FFD700 | Color of the word being spoken, as #RRGGBB. | | highlight_words | boolean | no | true | Highlight each word as it is spoken (false in classic). | | animation | string | no | pop | pop (each line pops in) or none (none in classic). | | outline_color | string | no | #000000 | Outline color as #RRGGBB (the box color in boxed). | | outline_width | integer | no | 6 | 0 to 10 (4 in classic). | | position | string | no | bottom | bottom, center or top. | | max_words_per_line | integer | no | 3 | 1 to 10 words shown at a time. Lines also break at the end of a sentence. | | uppercase | boolean | no | true | Show captions in capitals. | Example: `style={"style":"boxed","position":"top","highlight_color":"#39FF14"}` ## Voiceover `tts_enabled=true` reads the captions aloud. `mute_original_audio=true` removes the original sound. Voices: alloy (neutral, balanced), echo (male, clear), fable (british accent), onyx (deep male), nova (female, warm), shimmer (soft, gentle). ## Webhooks (instead of polling) - POST /api/v1/webhooks/ with JSON `{"url": "https://...", "events": ["job.completed", "job.failed"]}` (public HTTPS on port 443, up to 5 active). The response includes a `secret`. - GET /api/v1/webhooks/ (includes each secret), GET /api/v1/webhooks/{id}, DELETE /api/v1/webhooks/{id} Each event is POSTed as JSON: ```json { "event": "job.completed", "job_id": "3f0c6c1e-8a51-4c47-9d0b-6b2f1f0f7a9e", "timestamp": "2026-01-20T12:00:40.123456", "data": { "status": "completed", "output_url": "https://capsubs.com/api/v1/jobs/3f0c6c1e-8a51-4c47-9d0b-6b2f1f0f7a9e/download", "output_filename": "processed_3f0c6c1e_input.mp4", "word_count": 84, "duration_seconds": 31.2 } } // job.failed: "data": { "status": "failed", "error": "..." } ``` Headers: X-Capsubs-Timestamp, and X-Capsubs-Signature = "sha256=" + HMAC-SHA256(secret, timestamp + "." + raw body). Deliveries time out after 10 s, are not retried and do not follow redirects, so keep polling as a fallback. A per-job webhook_url gets the same payload without a signature. ```python import hashlib, hmac def is_from_capsubs(secret: str, headers, raw_body: bytes) -> bool: timestamp = headers["X-Capsubs-Timestamp"] expected = "sha256=" + hmac.new( secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, headers["X-Capsubs-Signature"]) ``` ## Errors - 400: Invalid input: wrong file type, style, captions, voice or webhook URL, or a video longer than 6 minutes. detail says which. - 401: Missing, wrong or deleted API key. - 403: Your plan doesn't include API access (Pro only), or the job belongs to another account. - 404: Job, upload or webhook not found. - 409: A chunked upload is busy (a part is still being written). Retry in a moment. - 413: Request larger than 95 MB. Use a chunked upload. - 429: No videos left this period (detail is an object with used, limit and resets_at), or too many requests (detail is a message). Wait and retry. - 503: Processing queue briefly unavailable. Nothing was charged; retry. ## Limits - API access is on the Pro plan: 40 videos a month, videos up to 6 minutes. - One request (including a whole batch) can be up to 95 MB. Files up to 1 GB go through a chunked upload. - Up to 5 videos per batch. - Finished videos are deleted 7 days after upload: download them before then. - One API key per account, up to 5 active webhooks. - About 20 requests a second per IP. Polling every 5 seconds is plenty. ## Complete example (Python) ```python # pip install requests import os import time import requests BASE = "https://capsubs.com" HEADERS = {"X-API-Key": os.environ["CAPSUBS_API_KEY"]} # 1. Upload with open("video.mp4", "rb") as f: r = requests.post(f"{BASE}/api/v1/captions/auto", headers=HEADERS, files={"file": f}, data={"style": "default"}) r.raise_for_status() job = r.json() # 2. Wait until it's done while job["status"] not in ("completed", "failed"): time.sleep(5) r = requests.get(f"{BASE}/api/v1/jobs/{job['id']}", headers=HEADERS) r.raise_for_status() job = r.json() if job["status"] == "failed": raise SystemExit(job["error_message"]) # 3. Download r = requests.get(job["output_url"], headers=HEADERS) r.raise_for_status() with open("captioned.mp4", "wb") as out: out.write(r.content) ``` ## Complete example (Node.js 20+) ```js // Node.js 20+, no packages needed. Save as caption.mjs, run: node caption.mjs import fs from "node:fs"; const BASE = "https://capsubs.com"; const headers = { "X-API-Key": process.env.CAPSUBS_API_KEY }; async function call(url, options = {}) { const res = await fetch(url, { ...options, headers }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); return res; } // 1. Upload const form = new FormData(); form.append("file", await fs.openAsBlob("video.mp4"), "video.mp4"); form.append("style", "default"); let job = await (await call(`${BASE}/api/v1/captions/auto`, { method: "POST", body: form })).json(); // 2. Wait until it's done while (!["completed", "failed"].includes(job.status)) { await new Promise((r) => setTimeout(r, 5000)); job = await (await call(`${BASE}/api/v1/jobs/${job.id}`)).json(); } if (job.status === "failed") throw new Error(job.error_message); // 3. Download const video = await call(job.output_url); fs.writeFileSync("captioned.mp4", Buffer.from(await video.arrayBuffer())); ```