DubAnyVideo

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 — programmatically, at
the moment a user uploads it, or across a whole back-catalog. The DIY path is a
stack of moving parts: ASR, translation, speaker diarization, a TTS engine with
decent voices, and the glue to mux new audio back onto the original video. That's
weeks of ML infra before you dub a single clip.

This tutorial skips all of it. You'll add real, speaker-aware dubbing to your app
with a single POST and a poll loop — no models to host, no voices to train.

What you'll build

A tiny 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

The API is poll-only (no webhooks), Bearer-key authenticated, and takes
video by link — YouTube, an HLS playlist, or a direct file URL — so you never
push multi-gigabyte uploads through your own backend.

Prerequisites

An account and an API key. Create one under Account → API keys, or start at
the docs: dubanyvideo.com/api. Keep the key in an
environment variable — the examples read DV_API_KEY.

export DV_API_KEY="dv_live_…"

Step 1 — (optional) check the cost first

POST /api/v1/estimate tells you how long the source is and how many minutes a dub
would cost — free, nothing is charged, no job is created. Handy for showing a
price before a user commits.

curl -s -X POST 'https://dubanyvideo.com/api/v1/estimate' \
  -H "Authorization: Bearer $DV_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{ "source_type": "youtube",
        "input_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ" }'
# → 200 { "sourceType": "youtube", "durationS": 212, "durationMin": 4, "maxDurationMin": 30 }

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": "youtube",
        "input_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
        "target_lang": "es" }'
# → 201 { "id": 4321, "state": "queued" }

By default the pipeline detects each speaker and gives them their own voice
(automatic diarization). Grab the id — that's all you need to track the job.

Step 3 — poll until done

There are no webhooks; you poll GET /api/v1/jobs/{id} on an interval. One
gotcha worth internalizing: a failed job is a 200 with state: "failed" and an
errorMessage, not an HTTP error.
So 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

The job object also carries stage, progressPct, and eta_s while it runs — use
them to drive a progress bar.

Step 4 — use the result

When state is done, archiveUrl is your one-click download (MP4 + audio +
subtitles + transcript). Individual pieces live under artifacts:

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

Download archiveUrl (or just artifacts.dubbedVideoUrl) before
archiveExpiresAt
— result URLs are temporary.

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

Full code

All three run end to end — submit, poll, download. Same live-verified contract as
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":"youtube",
       "input_url":"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
       "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: 'youtube',
    input_url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
    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": "youtube",
    "input_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
    "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": "youtube", "input_url": "", "target_langs": ["es", "ru", "hi"] }
    
    You get back 201 { "jobs": [ { "id", "state", "targetLang" }, … ] }. Charging is
    all-or-nothing across the batch.
  • One voice for the whole video (skip speaker separation) — add
    "diarization": "skip" with a "voice". Omit both for automatic per-speaker voices.
  • Transcript onlyPOST /api/v1/transcribe returns a speaker-labelled
    transcript at half the per-minute rate.
  • Other sourcessource_type also accepts "url" for a direct file or HLS
    playlist link, not just YouTube.

Cost & limits

  • One balance covers both the dashboard and the API — minutes billed = video
    duration. Transcribe costs half.
  • Max source length: 30 minutes per job.
  • Input is by link only (YouTube / HLS / direct URL) — no file uploads.
  • Rate limits: 60 requests/min per key; new submissions are 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" }
    distinct from a failed job (which is a 200, see Step 3).
  • Prefer pay-per-dub over 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:
dubanyvideo.com/api.


Pre-publish checklist: set canonical_url + cover; re-run one snippet
against the live API the day you publish; confirm the 30-min limit, plan submit
caps, and language count still match /api.