REST API|Video Subtitles API

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.generate
Quickstart

The 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.

Step by step

What you actually have to do

Six steps, four of them one request each.

  1. 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. 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. 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. 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. 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. 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.
Reference

Every call on this page

Scopes are what the key must carry, not what you send.

CallScopeWhat it does
GET /api/v1-Discovery: what this key can do, its limits and its usage.
POST /api/v1/actions/videos.createvideos:writeCreates the video project and returns its id.
POST /api/v1/actions/subtitles.generatevideos:executeTranscribes, emphasises, styles and saves the subtitle track.
POST /api/v1/actions/renders.createrenders:executeQueues the render on the worker fleet. 20 credits.
GET /api/v1/renders?video_id=…renders:readThe render jobs of a video, newest first, with their status and URL.
POST /api/v1/webhookswebhooks:writeRegisters 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.

makeitlookeasy
default4 words · simple
makeitloud
bold3 words · simple
thisoneword
impact3 words · focus_on_one_word
huge
beast1 words · focus_on_one_word
onewordatatime
karaoke5 words · focus_on_one_word
itfillsasyousing
karaoke_fill5 words · karaoke
lightsonthewords
neon4 words · progressively_visible
nothingbehindthetextatall
minimal6 words · simple
wordbywordtheyland
pop5 words · progressively_visible
darktextonapill
pill5 words · simple
twocalmlinesatthebottomoftheframe
cinematic9 words · simple

Things 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.

CodeWhat it means
401 missing_credentialsNo key on the request, or one we do not recognise.
403 insufficient_scopeThe key is missing a scope; `details.required_scopes` names it.
422 validation_failedA field is wrong; `details.issues` lists every one of them.
402 insufficient_creditsNot enough credits for the render. Top up, or run a test key.
429 rate_limit_exceededToo many calls. Wait `Retry-After` seconds, then retry.
502 upstream_errorA 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"
  }
}

Error codes

Webhooks

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')
})
What you get

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.

FAQ

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.

Ship subtitled video from your own backend

Create a key, run the quickstart, and the first subtitled mp4 is a few minutes away.

Secure payments
Instant access
Cancel anytime