DubAnyVideo

Developer blog

Add a 'Dub this video' Button to Your Video Platform

In the first tutorial we dubbed one video in
about ten lines, and in the second we fanned
a single upload out into thirty languages. Both were scripts you run yourself. This
one is different: you run a video platform — a course host, a media library, a
creator tool — and you want your users to dub a video with one click, without
ever leaving your product.

What you'll build

A "Dub this video" button that lives in your own UI. Clicking it:

  1. Calls your own backend (never the Dub Any Video API directly — see the box below).
  2. Your backend calls POST /api/v1/dub with the video's URL, gets back a job id,
    and stores it against the video/row in your database.
  3. Your UI polls your backend for status; your backend reads
    GET /api/v1/jobs/{id} and relays the state.
  4. When the job is done, you show a Play dub / Download action pointing at the
    finished MP4.

The user sees a button, a progress state, and a result — all inside your app. The
dubbing runs on our GPU pipeline; you never host a model, train a voice, or push
gigabytes of video through your own servers (input is by link).

Keep the key on the server. Your API key (dv_live_…) is a secret — it spends
your balance. It must live in your backend and never ship to the browser. The
button talks to your backend; only your backend holds the key and talks to the Dub
Any Video API. Every snippet below is built that way.

Prerequisites

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

export DV_API_KEY="dv_live_…"

Step 1 — the button (front-end)

The button hits your endpoints, not ours. It POSTs the video to your backend to
start a dub, then polls your backend for status until the dub is ready. No API key
anywhere in this file.

<button id="dub-btn" data-video-url="https://www.youtube.com/watch?v=dQw4w9WgXcQ">
  Dub this video
</button>
<span id="dub-status"></span>

<script>
const btn = document.getElementById('dub-btn')
const status = document.getElementById('dub-status')
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))

btn.addEventListener('click', async () => {
  btn.disabled = true
  status.textContent = 'Starting…'

  // 1. Ask YOUR backend to start a dub. Your backend holds the API key.
  const { jobId } = await fetch('/api/dubs', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ videoUrl: btn.dataset.videoUrl, targetLang: 'es' }),
  }).then((r) => r.json())

  // 2. Poll YOUR backend for status until the dub is done.
  while (true) {
    await sleep(5000)
    const job = await fetch(`/api/dubs/${jobId}`).then((r) => r.json())
    status.textContent = `${job.state}${job.stage ? `${job.stage}` : ''}`
    if (job.state === 'done') {
      status.innerHTML = `<a href="${job.dubbedVideoUrl}">Play dub</a>`
      break
    }
    if (job.state === 'failed') {
      status.textContent = `Failed: ${job.errorMessage}`
      break
    }
  }
  btn.disabled = false
})
</script>

That's the whole client. Everything sensitive happens behind /api/dubs.

Step 2 — the backend (start + status)

Your backend needs two routes:

  • POST /api/dubs — start a dub: call POST /api/v1/dub, store the returned id.
  • GET /api/dubs/:id — status: read GET /api/v1/jobs/{id} and relay the fields
    the UI needs.

The Dub Any Video contract, verbatim from the API tutorials in this series:

  • POST /api/v1/dub with source_type + input_url + target_lang201 { id, state }.
  • GET /api/v1/jobs/{id} → a camelCase status object with state, stage,
    progressPct, archiveUrl, artifacts.dubbedVideoUrl, and errorMessage.
  • Auth is Authorization: Bearer $DV_API_KEY.
  • A failed job is a 200 with state: "failed" and an errorMessage — branch on
    state, not on the HTTP status.

Node (Express)

// npm i express. Node 18+ for global fetch. DV_API_KEY set in the environment.
import express from 'express'

const app = express()
app.use(express.json())

const base = 'https://dubanyvideo.com/api/v1'
const dvHeaders = {
  Authorization: `Bearer ${process.env.DV_API_KEY}`,
  'Content-Type': 'application/json',
}

// Start a dub. In real code, store jobId against the video row (and the user) in your DB.
app.post('/api/dubs', async (req, res) => {
  const { videoUrl, targetLang } = req.body
  // POST /api/v1/dub -> 201 { id, state }
  const created = await fetch(`${base}/dub`, {
    method: 'POST',
    headers: dvHeaders,
    body: JSON.stringify({
      source_type: 'youtube', // or 'url' for a direct file / HLS link
      input_url: videoUrl,
      target_lang: targetLang, // one ISO 639-1 code, e.g. "es"
    }),
  })
  const job = await created.json()
  if (created.status !== 201) return res.status(400).json({ error: job.message || job.error })
  // Persist { jobId: job.id } for this video here.
  res.json({ jobId: job.id, state: job.state })
})

// Relay status. The browser only ever sees the fields you choose to expose.
app.get('/api/dubs/:id', async (req, res) => {
  // GET /api/v1/jobs/{id} -> camelCase status object.
  const job = await fetch(`${base}/jobs/${req.params.id}`, { headers: dvHeaders }).then((r) => r.json())
  res.json({
    state: job.state,             // queued | … | done | failed
    stage: job.stage,             // human-readable step while running
    progressPct: job.progressPct, // 0–100 while running
    dubbedVideoUrl: job.artifacts?.dubbedVideoUrl, // the finished MP4 (null until done)
    archiveUrl: job.archiveUrl,   // one-click zip: video + audio + subtitles + transcript
    errorMessage: job.errorMessage,
  })
})

app.listen(3000)

Python (Flask)

# pip install flask requests. DV_API_KEY set in the environment.
import os, requests
from flask import Flask, request, jsonify

app = Flask(__name__)

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

# Start a dub. In real code, store job_id against the video row (and the user) in your DB.
@app.post("/api/dubs")
def start_dub():
    body = request.get_json()
    # POST /api/v1/dub -> 201 { "id", "state" }
    r = requests.post(f"{base}/dub", headers=dv_headers, json={
        "source_type": "youtube",       # or "url" for a direct file / HLS link
        "input_url": body["videoUrl"],
        "target_lang": body["targetLang"],  # one ISO 639-1 code, e.g. "es"
    })
    job = r.json()
    if r.status_code != 201:
        return jsonify(error=job.get("message") or job.get("error")), 400
    # Persist { "job_id": job["id"] } for this video here.
    return jsonify(jobId=job["id"], state=job["state"])

# Relay status. The browser only ever sees the fields you choose to expose.
@app.get("/api/dubs/<int:job_id>")
def dub_status(job_id):
    # GET /api/v1/jobs/{id} -> camelCase status object.
    job = requests.get(f"{base}/jobs/{job_id}", headers=dv_headers).json()
    return jsonify(
        state=job["state"],               # queued | … | done | failed
        stage=job.get("stage"),           # human-readable step while running
        progressPct=job.get("progressPct"),
        dubbedVideoUrl=(job.get("artifacts") or {}).get("dubbedVideoUrl"),
        archiveUrl=job.get("archiveUrl"), # one-click zip: video + audio + subtitles + transcript
        errorMessage=job.get("errorMessage"),
    )

That's the whole integration: a button, one create call, one status relay. The dub
runs on our pipeline; your users never leave your app.

Make it robust in production

The two-route shape above is the core. A few things to add once it works:

  • Poll from a worker, not a web request. The browser poll is fine for a demo,
    but the API is poll-only (no webhooks). For real traffic, store the id and
    let a background job poll GET /api/v1/jobs/{id} on an interval, then flip your own
    video row to ready when state is done. Your UI can then just read your DB.
  • Scope jobs to the user. Store the id against the row that started it so one
    user can't poll another user's dub through your endpoint. (The Dub Any Video API is
    already owner-scoped by key — a GET /api/v1/jobs/{id} for an id your key doesn't
    own returns 404 { "error": "not_found" }, never leaking it.)
  • Show the cost before the click, optionally. POST /api/v1/estimate is free,
    creates no job, and returns the source duration in minutes — use it to render a "this
    will cost ~N minutes" hint next to the button.
  • Grab the result before it expires. A finished job carries archiveExpiresAt;
    result URLs are temporary. If you want to keep the dub, download archiveUrl (or
    artifacts.dubbedVideoUrl) to your own storage when the job completes.

Prefer to hand off instead of integrate?

If you'd rather not run a backend at all and are fine sending the user over to
Dub Any Video to finish the dub themselves, the workspace URL accepts a
?source_url= parameter that pre-fills the source field:

https://dubanyvideo.com/dubbing?source_url=<url-encoded video URL>

Be clear-eyed about the trade-off: /dubbing is sign-in-required, so the user
needs their own Dub Any Video account, and the dub is produced and downloaded on our
site, not returned to your platform
. That makes it a hand-off link ("dub this
yourself"), not an in-product integration. If you want the dub to come back into your
own UI — the actual "Dub this video" button — use the backend path above.

Cost & limits

  • One balance covers the dashboard and the API — minutes billed = video duration
    (a 4-minute video into one language costs 4 minutes). Transcribe costs half.
  • Max source length: 30 minutes per job. Input is by link only (YouTube /
    HLS / direct URL) — no file uploads through your backend.
  • Rate limits: 60 requests/min per key; new submissions are capped per minute
    by plan (free 2 · starter 5 · medium 10 · super 20). If you expect a burst of button
    clicks, queue submissions on your side.
  • Errors come back as an envelope — 401 / 404 / 422 / 429 with { "error", … }
    (validation 422s add a human message) — distinct from a failed job, which is a
    200 with state: "failed".
  • Prefer pay-per-dub over a subscription? The same engine is on the
    RapidAPI marketplace, billed per job.

Get your key

That's a real "Dub this video" button — one click for your users, one create call and
one status relay for you. Grab a key and wire it into your platform:
dubanyvideo.com/api · Account → API keys.


Pre-publish checklist: set cover; run the button end-to-end once against the
live API
— click → POST /api/v1/dub returns 201 { id, state } → poll
GET /api/v1/jobs/{id} to doneartifacts.dubbedVideoUrl plays; confirm the
?source_url= prefill still lands on the sign-in-gated /dubbing; re-confirm the
30-min limit, plan submit caps, and error envelope against /api.