Subtitle any video
with three API calls
Send a video URL, get back an mp4 with word-by-word subtitles burned in. Whisper times every word, an AI pass picks the ones to highlight, and your renderer is our worker fleet.
POSThttps://autostud.ai/api/v1/actions/subtitles.generateThe whole thing, in one file
Paste it, set your key, run it. Nothing below is pseudo-code.
const API = 'https://autostud.ai/api/v1'
const KEY = process.env.AUTOSTUD_API_KEY
const call = async (path, body) => {
const response = await fetch(API + path, {
method: body ? 'POST' : 'GET',
headers: {
Authorization: `Bearer ${KEY}`,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
})
const payload = await response.json()
if (!response.ok) throw new Error(payload.error?.message || response.statusText)
return payload
}
// 1. A video project to hang the timeline on.
const created = await call('/actions/videos.create', {
video_name: 'Interview clip',
video_type_id: 'timeline',
video_format: 'portrait',
video_lang: 'en',
})
const video_id = created.data.video_id
// 2. Transcribe, emphasise, style — one call.
const subtitled = await call('/actions/subtitles.generate', {
video_id,
media_url: 'https://cdn.example.com/interview.mp4',
style_preset: 'beast',
words_per_group: 3,
})
console.log(subtitled.data.word_count, 'words', subtitled.data.duration_seconds, 's')
// 3. Render it.
await call('/actions/renders.create', {
video_id,
video_type_id: 'timeline',
video_format: 'portrait',
})
// 4. Wait for the worker. A webhook is better; this is the short version.
let render
do {
await new Promise((resolve) => setTimeout(resolve, 15000))
const list = await call(`/renders?video_id=${video_id}&limit=1`)
render = list.data[0]
} while (render && ['not_started', 'processing'].includes(render.render_status))
if (render.render_status !== 'done') throw new Error('Render failed')
console.log(render.render_url)Base URL: https://autostud.ai/api/v1 — authenticate with `Authorization: Bearer sk_live_…`. A `sk_test_` key runs the exact same request path and stops before spending anything.
What you actually have to do
Six steps, four of them one request each.
- 1
Create an API key
Dashboard, Settings, API keys. Pick the "Automation" preset unless you want to choose scopes by hand — it grants videos, renders and assets. The secret is shown once. `sk_live_` spends, `sk_test_` validates the whole request and stops before the write.
export AUTOSTUD_API_KEY="sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # What can this key actually do? curl https://autostud.ai/api/v1 \ -H "Authorization: Bearer $AUTOSTUD_API_KEY" - 2
Put your video behind a URL
There is no upload endpoint: the API reads a URL. Your bucket, your CDN, a signed link — anything our servers can fetch. Registering it in the workspace library is optional and does not change the flow.
# The API takes a URL, never a file upload. Anything publicly # reachable works: your bucket, your CDN, a signed URL. export MEDIA_URL="https://cdn.example.com/interview.mp4" # Optional: keep a record of it in the workspace library. curl -X POST https://autostud.ai/api/v1/assets \ -H "Authorization: Bearer $AUTOSTUD_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "provider_file_url": "'"$MEDIA_URL"'", "provider_file_name": "interview.mp4", "file_mime_type": "video/mp4", "provider_file_id": "interview-2026-08-18", "sync_method": "api" }' - 3
Create the video project
`videos.create` returns the `video_id` everything else hangs off. Send an `Idempotency-Key` and a retried request returns the first answer instead of creating a second project.
curl -X POST https://autostud.ai/api/v1/actions/videos.create \ -H "Authorization: Bearer $AUTOSTUD_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: interview-2026-08-18" \ -d '{ "video_name": "Interview clip", "video_type_id": "timeline", "video_format": "portrait", "video_lang": "en" }' - 4
Subtitle it
`subtitles.generate` does the whole job server-side: Whisper transcribes with word-level timings, an AI pass marks the words to emphasise, and one of 116 styles is applied. It answers with the word count, the exact duration and the detected language.
export VIDEO_ID="9f0c4e2a-1d6b-4a77-9d51-6b0f2e8c3a4d" curl -X POST https://autostud.ai/api/v1/actions/subtitles.generate \ -H "Authorization: Bearer $AUTOSTUD_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "video_id": "'"$VIDEO_ID"'", "media_url": "'"$MEDIA_URL"'", "style_preset": "beast", "words_per_group": 3, "enable_emphasis": true, "enable_diarization": false }' - 5
Queue the render
`renders.create` puts the job on the worker fleet and answers immediately with the `render_id`. A render costs 20 credits, whatever the length of the video.
curl -X POST https://autostud.ai/api/v1/actions/renders.create \ -H "Authorization: Bearer $AUTOSTUD_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "video_id": "'"$VIDEO_ID"'", "video_type_id": "timeline", "video_format": "portrait" }' - 6
Collect the mp4
Subscribe to `render.completed` and the finished URL is pushed to you, signed. If you would rather pull, list the renders of your video and read `render_status` until it says `done`.
# Register once, then stop polling. curl -X POST https://autostud.ai/api/v1/webhooks \ -H "Authorization: Bearer $AUTOSTUD_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.com/hooks/autostud", "events": ["render.completed", "render.failed"], "description": "Subtitled videos" }' # The signing secret comes back once, in this response. Store it.
Every call on this page
Scopes are what the key must carry, not what you send.
| Call | Scope | What it does |
|---|---|---|
| GET /api/v1 | - | Discovery: what this key can do, its limits and its usage. |
| POST /api/v1/actions/videos.create | videos:write | Creates the video project and returns its id. |
| POST /api/v1/actions/subtitles.generate | videos:execute | Transcribes, emphasises, styles and saves the subtitle track. |
| POST /api/v1/actions/renders.create | renders:execute | Queues the render on the worker fleet. 20 credits. |
| GET /api/v1/renders?video_id=… | renders:read | The render jobs of a video, newest first, with their status and URL. |
| POST /api/v1/webhooks | webhooks:write | Registers an https endpoint and returns its signing secret, once. |
Style presets
Pass one as `style_preset`. Each one sets the font, the outline, the highlight colour and the animation together — there is nothing else to configure.
default4 words · simplebold3 words · simpleimpact3 words · focus_on_one_wordbeast1 words · focus_on_one_wordkaraoke5 words · focus_on_one_wordkaraoke_fill5 words · karaokeneon4 words · progressively_visibleminimal6 words · simplepop5 words · progressively_visiblepill5 words · simplecinematic9 words · simpleThings worth knowing before you build
- Rate limits are per key and answered in headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `Retry-After` on a 429.
- `Idempotency-Key` on any POST makes a retry safe for 24 hours; the first response is replayed verbatim.
- A `sk_test_` key exercises authentication, scopes, limits and validation, then answers `simulated: true` without writing or spending.
- Errors are typed: `invalid_request`, `insufficient_credits`, `rate_limit_exceeded`, `upstream_error` — branch on the code, never on the message.
When it fails
Every failure has the same shape: a stable `code` to branch on, a `hint` naming the call that fixes it, and a `request_id` to quote. The full vocabulary lives at /docs/api/errors.
| Code | What it means |
|---|---|
| 401 missing_credentials | No key on the request, or one we do not recognise. |
| 403 insufficient_scope | The key is missing a scope; `details.required_scopes` names it. |
| 422 validation_failed | A field is wrong; `details.issues` lists every one of them. |
| 402 insufficient_credits | Not enough credits for the render. Top up, or run a test key. |
| 429 rate_limit_exceeded | Too many calls. Wait `Retry-After` seconds, then retry. |
| 502 upstream_error | A provider we depend on failed. Retry with backoff. |
{
"error": {
"type": "permission_error",
"code": "insufficient_scope",
"message": "This API key is missing the required scope: videos:execute.",
"details": {
"required_scopes": ["videos:execute"],
"granted_scopes": ["videos:read", "videos:write"]
},
"hint": "`details.required_scopes` lists what is missing. Call GET /api/v1 to see what this key does hold, then re-scope it at /app/settings/api-keys.",
"retryable": false,
"request_id": "req_8f2c41d0a95b",
"doc_url": "https://autostud.ai/docs/api/errors#insufficient_scope"
}
}Stop polling
A render takes minutes. Let it come to you.
Signed, and verifiable in ten lines
`X-Autostud-Signature: t=<unix>,v1=<hex>` is an HMAC-SHA256 over `"<timestamp>.<raw body>"`. Verify against the raw body, before parsing it.
Retried for you
Five attempts over about two hours, with the delivery row written before the first one. An endpoint that fails twenty times in a row is disabled rather than hammered.
Deduplicate on the delivery id
Every attempt carries `X-Autostud-Delivery`. Same id means same event: treat it as the primary key of the work you do in response.
import crypto from 'node:crypto'
// The header is "t=<unix>,v1=<hex>" and the signed string is "<t>.<raw body>".
// Raw body: parse it AFTER verifying, never re-serialize before.
export function verify(raw_body, header, secret, tolerance = 300) {
const parts = Object.fromEntries(
String(header || '').split(',').map((entry) => entry.split('=').map((s) => s.trim()))
)
const timestamp = Number(parts.t)
if (!Number.isFinite(timestamp)) return false
if (Math.abs(Date.now() / 1000 - timestamp) > tolerance) return false
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${raw_body}`)
.digest('hex')
const received = String(parts.v1 || '')
if (received.length !== expected.length) return false
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))
}
app.post('/hooks/autostud', express.raw({ type: '*/*' }), (req, res) => {
const raw = req.body.toString('utf8')
if (!verify(raw, req.get('X-Autostud-Signature'), process.env.AUTOSTUD_WEBHOOK_SECRET)) {
return res.status(400).send('bad signature')
}
// Deliveries can repeat: deduplicate on this id before doing any work.
const delivery_id = req.get('X-Autostud-Delivery')
const event = JSON.parse(raw)
if (event.event === 'render.completed') {
console.log(event.data.render_url)
}
res.status(200).send('ok')
})Why this is one call and not fifteen
The parts that are hard to rebuild are the parts we run.
Word-level timing
Every word carries its own start and end, so the highlight lands on the syllable. Sentence-level timing is what makes most automated subtitles feel late.
116 styles, one string
From clean broadcast captions to the bouncing highlighted look. The preset sets font, outline, colour and animation together, scaled to your canvas.
The AI picks the accents
A second pass reads the transcript and marks the words that carry the meaning. Filler words stay calm. Turn it off with one boolean.
Two speakers, two colours
Diarization is a flag. An interview comes back with each voice on its own colour, so a viewer knows who is talking with the sound off.
The same objects as the dashboard
A video subtitled through the API opens in the editor like any other. Fix a word by hand, re-render, keep automating — the two paths write the same document.
Built to be retried
Idempotency keys, typed errors, test keys, signed webhooks and a 30-day request log. The boring half of an API, which is the half you feel at 3am.
The questions developers actually send us
No, and that is deliberate: the API takes a URL. Host the video anywhere our servers can reach it — your own bucket, a CDN, a signed link — and pass it as `media_url`. Files uploaded through the dashboard already have a URL you can use.
Not today. The subtitles are rendered into the mp4, which is what short-form platforms need. The word list with its timings is stored on the video, so a sidecar file is a transformation you can do on your side from what the API returns.
A render is 20 credits, fixed, whatever the length. The transcription and the emphasis pass are logged to your workspace like every other AI call, and `GET /api/v1/usage` reports what a key has spent this period.
Minutes, not seconds: it runs on a worker fleet, not in the request. That is exactly why `render.completed` exists — subscribe to it rather than holding a connection open.
Yes. Call `subtitles.generate` again with `restyle_only: true` and a different `style_preset`: it reuses the words already stored on the video, so nothing is transcribed twice.
Whisper detects the language on its own and the subtitles come back in whatever is being spoken — no locale to declare. The detected language is returned in the response so you can branch on it.
More free tools
Every one of them runs in the browser, no account needed to try.
TikTok Quiz Video Generator
Live toolTurn any topic into a quiz video
YouTube Quiz Video Generator
Live toolQuiz videos in 16:9 or Shorts
Language Quiz Video Generator
Live toolTurn a word list into a language video
Tweet to Video
Live toolTurn tweets & X posts into videos
YouTube to Karaoke Video
Live toolTurn any track into a karaoke video
Reddit Post to Video
Live toolTurn a Reddit thread into a short
CSV to Leaderboard Video
Live toolTurn a spreadsheet into a ranking video
TikTok Comments to Song Video
Live toolTurn a comment section into a song video
AI Fruit Videos
Create viral fruit quiz videos with AI
Ship subtitled video from your own backend
Create a key, run the quickstart, and the first subtitled mp4 is a few minutes away.