DEV Community

My Little Indie Studio
My Little Indie Studio

Posted on

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 (YouTube, an HLS playlist, or a direct file URL), which means you never push multi-gigabyte uploads through your own backend.

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_…"
Enter fullscreen mode Exit fullscreen mode

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": "youtube",
        "input_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ" }'
# → 200 { "sourceType": "youtube", "durationS": 212, "durationMin": 4, "maxDurationMin": 30 }
Enter fullscreen mode Exit fullscreen mode

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" }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Step 4: use the result

Once state is done, archiveUrl is a single download with everything in it: MP4, audio, subtitles, and transcript. If you only want one piece, they're all listed separately 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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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":"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
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Handy variations

  • Many languages at once: send target_langs instead of target_lang:
  { "source_type": "youtube", "input_url": "…", "target_langs": ["es", "ru", "hi"] }
Enter fullscreen mode Exit fullscreen mode

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 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 equal the video duration, and transcribe costs half.
  • Max source length is 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 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.

Top comments (0)