Developer blog

Add AI Dubbing to Your App in 10 Lines

The problem

You have video in one language and you need it in another. Sometimes programmatically, right when a user uploads it, sometimes across an entire back-catalog at once. Build it yourself and you're on the hook for the whole stack: ASR, translation, speaker diarization, a TTS engine with voices that don't sound robotic, and the glue to mux the new audio back onto the original video. That's a couple of weeks of ML plumbing before you dub a single clip.

This tutorial skips all of that. You get speaker-aware dubbing with one POST and a poll loop. No models to host, no voices to train.

What you'll build

A small script that takes a video URL, dubs it into another language, and downloads the result. Three calls:

  1. POST /api/v1/dub: submit a job (returns 201 { id, state })
  2. GET /api/v1/jobs/{id}: poll until state: "done"
  3. Download the finished MP4 from archiveUrl

A few things about the API up front. It's poll-only, so no webhooks. It authenticates with a Bearer key. And it takes video by link: a direct file URL or an HLS playlist, which means you never push multi-gigabyte uploads through your own backend. YouTube links are not supported.

Prerequisites

You need an account and an API key. Create a key under Account → API keys, or start from the docs at dubanyvideo.com/api. Keep it in an environment variable. The examples all read DV_API_KEY.

export DV_API_KEY="dv_live_…"

Step 1: check the cost first (optional)

POST /api/v1/estimate tells you how long the source is and how many minutes a dub would cost. It's free: nothing is charged and no job is created. Useful when you want to show a price before the user commits to anything.

curl -s -X POST 'https://dubanyvideo.com/api/v1/estimate' \
  -H "Authorization: Bearer $DV_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{ "source_type": "url",
        "input_url": "https://cdn.example.com/lessons/01-welcome.mp4" }'
# → 200 { "sourceType": "url", "durationS": 212, "durationMin": 4, "maxDurationMin": 60 }

Step 2: submit a dub job

curl -s -X POST 'https://dubanyvideo.com/api/v1/dub' \
  -H "Authorization: Bearer $DV_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{ "source_type": "url",
        "input_url": "https://cdn.example.com/lessons/01-welcome.mp4",
        "target_lang": "es" }'
# → 201 { "id": 4321, "state": "queued" }

By default the pipeline detects each speaker and assigns them their own voice, which is the automatic diarization at work. Grab the id from the response. That's the only thing you need to track the job.

Step 3: poll until done

No webhooks, so you poll GET /api/v1/jobs/{id} on an interval. Here's the one gotcha that catches people: a failed job comes back as a 200 with state: "failed" and an errorMessage, not as an HTTP error. Branch on state, not on the status code.

while :; do
  JOB=$(curl -s "https://dubanyvideo.com/api/v1/jobs/$ID" \
    -H "Authorization: Bearer $DV_API_KEY")
  STATE=$(echo "$JOB" | jq -r .state)
  echo "state: $STATE"
  [ "$STATE" = "done" ] && break
  [ "$STATE" = "failed" ] && { echo "job failed: $(echo "$JOB" | jq -r .errorMessage)"; exit 1; }
  sleep 5
done

While it runs, the job object also carries stage and progressPct. Wire those into a progress bar if you want one.

Step 4: use the result

Once state is done, archiveUrl is the primary download: the dubbed video, or the dubbed audio when the source had no picture track. Every output is also listed separately under artifacts, whose four keys are always present and null until the file exists:

{
  "id": 4321, "state": "done", "targetLang": "es",
  "archiveUrl": "https://…/dubbed.mp4", "archiveExpiresAt": "2026-07-13T…Z",
  "artifacts": {
    "dubbedVideoUrl":   "https://…/dubbed.mp4",
    "dubbedAudioUrl":   "https://…/dubbed.mp3",
    "translationSrtUrl":"https://…/es.srt",
    "transcriptSrtUrl": "https://…/transcript.srt"
  }
}

Download archiveUrl (or just artifacts.dubbedVideoUrl) before the time in archiveExpiresAt. These result URLs are temporary and stop working after that.

curl -L "$(echo "$JOB" | jq -r .archiveUrl)" -o dubbed.mp4

Full code

All three versions run end to end: submit, poll, download. Same live-verified contract as the snippets above.

curl (bash)

# 1. Submit a dub job — POST /api/v1/dub returns 201 { "id", "state" }
ID=$(curl -s -X POST 'https://dubanyvideo.com/api/v1/dub' \
  -H "Authorization: Bearer $DV_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"source_type":"url",
       "input_url":"https://cdn.example.com/lessons/01-welcome.mp4",
       "target_lang":"es"}' | jq -r .id)

# 2. Poll GET /api/v1/jobs/{id} until state == "done"
while :; do
  JOB=$(curl -s "https://dubanyvideo.com/api/v1/jobs/$ID" \
    -H "Authorization: Bearer $DV_API_KEY")
  STATE=$(echo "$JOB" | jq -r .state)
  echo "state: $STATE"
  [ "$STATE" = "done" ] && break
  [ "$STATE" = "failed" ] && { echo "job failed"; exit 1; }
  sleep 5
done

# 3. Download the dubbed MP4 from archiveUrl
curl -L "$(echo "$JOB" | jq -r .archiveUrl)" -o dubbed.mp4

Node (18+, global fetch)

// Node 18+ (global fetch). Set DV_API_KEY in your environment.
const KEY = process.env.DV_API_KEY
const base = 'https://dubanyvideo.com/api/v1'
const headers = { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' }
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))

// 1. Submit a dub job -> 201 { id, state }
const created = await fetch(`${base}/dub`, {
  method: 'POST',
  headers,
  body: JSON.stringify({
    source_type: 'url',
    input_url: 'https://cdn.example.com/lessons/01-welcome.mp4',
    target_lang: 'es',
  }),
}).then((r) => r.json())

// 2. Poll until done
let job
do {
  await sleep(5000)
  job = await fetch(`${base}/jobs/${created.id}`, { headers }).then((r) => r.json())
  console.log('state:', job.state)
} while (job.state !== 'done' && job.state !== 'failed')
if (job.state === 'failed') throw new Error(job.errorMessage)

// 3. Download the dubbed MP4
const mp4 = await fetch(job.archiveUrl).then((r) => r.arrayBuffer())
await require('node:fs/promises').writeFile('dubbed.mp4', Buffer.from(mp4))

Python (pip install requests)

# pip install requests. Set DV_API_KEY in your environment.
import os, time, requests

base = "https://dubanyvideo.com/api/v1"
headers = {"Authorization": f"Bearer {os.environ['DV_API_KEY']}"}

# 1. Submit a dub job -> 201 { "id", "state" }
created = requests.post(f"{base}/dub", headers=headers, json={
    "source_type": "url",
    "input_url": "https://cdn.example.com/lessons/01-welcome.mp4",
    "target_lang": "es",
}).json()

# 2. Poll until done
while True:
    job = requests.get(f"{base}/jobs/{created['id']}", headers=headers).json()
    print("state:", job["state"])
    if job["state"] == "done":
        break
    if job["state"] == "failed":
        raise RuntimeError(job["errorMessage"])
    time.sleep(5)

# 3. Download the dubbed MP4
with open("dubbed.mp4", "wb") as f:
    f.write(requests.get(job["archiveUrl"]).content)

Handy variations

  • Many languages at once: send target_langs instead of target_lang:
    { "source_type": "url", "input_url": "", "target_langs": ["es", "ru", "hi"] }
    
    You get back 201 { "jobs": [ { "id", "state", "targetLang" }, … ] }. Charging is all-or-nothing across the batch, so either every language bills or none do.
  • One voice for the whole video: skip speaker separation by adding "diarization": "skip" with a "voice". Omit both and you get automatic per-speaker voices.
  • Transcript only: POST /api/v1/transcribe returns a plain timed transcript in the source language at half the per-minute rate.
  • Other sources: source_type takes "url" for a direct file, "hls" for a playlist, or "link" to let the server work it out from the URL.

Cost & limits

  • One balance covers both the dashboard and the API. Minutes billed equal the video duration, and transcribe costs half.
  • Max source length is 60 minutes per job.
  • Input is by link only (a direct video URL or an HLS playlist). No file uploads, and YouTube links are not supported.
  • Rate limits: 60 requests/min per key. New submissions are also capped per minute by plan (free 2 · starter 5 · medium 10 · super 20).
  • Errors come back as an envelope, 401 / 404 / 422 / 429 → { "error", "message" }, which is a different thing from a failed job (that one is a 200, see Step 3).
  • Want pay-per-dub instead of a subscription? The same engine is on the RapidAPI marketplace (/rapidapi/v1, billed per job).

Get your key

That's the whole integration: submit, poll, download. Grab a key and ship it at dubanyvideo.com/api.