The first tutorial dubbed one video in about ten lines. The second fanned a single upload out into thirty languages. Both were scripts you run yourself. This one is for when you run the platform: a course host, a media library, a creator tool. You want your users to dub a video with one click, without leaving your product.
What you'll build
A "Dub this video" button that lives in your own UI. Clicking it:
- Calls your own backend (never the Dub Any Video API directly, see the box below).
- Your backend calls
POST /api/v1/dubwith the video's URL, gets back a jobid, and stores it against the video/row in your database. - Your UI polls your backend for status; your backend reads
GET /api/v1/jobs/{id}and relays the state. - 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, so 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 that spends
your balance. It has to live in your backend and never reach the browser. The
button talks to your backend; only your backend holds the key and calls the Dub
Any Video API. Every snippet below works that way.
Prerequisites
You need an account and an API key. Create one under Account → API keys (dubanyvideo.com/account/api-keys), or start from the docs at 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. There is 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/dubsstarts a dub: callPOST /api/v1/dub, store the returnedid. -
GET /api/dubs/:idreturns status: readGET /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/dubwithsource_type+input_url+target_lang→201 { id, state }. -
GET /api/v1/jobs/{id}→ a camelCase status object withstate,stage,progressPct,archiveUrl,artifacts.dubbedVideoUrl, anderrorMessage. - Auth is
Authorization: Bearer $DV_API_KEY. -
A failed job is a
200withstate: "failed"and anerrorMessage. Branch onstate, 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
idand let a background job pollGET /api/v1/jobs/{id}on an interval, then flip your own video row toreadywhenstateisdone. Your UI can then just read your DB. -
Scope jobs to the user. Store the
idagainst 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: aGET /api/v1/jobs/{id}for an id your key doesn't own returns404 { "error": "not_found" }, never leaking it.) -
Show the cost before the click, optionally.
POST /api/v1/estimateis 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, downloadarchiveUrl(orartifacts.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 you're fine sending the user over to Dub Any Video to finish the dub themselves, the workspace URL takes a ?source_url= parameter that pre-fills the source field:
https://dubanyvideo.com/dubbing?source_url=<url-encoded video URL>
Know the trade-off before you reach for it. /dubbing requires sign-in, 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), with 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 / 429with{ "error", … }(validation422s add a humanmessage). That is distinct from afailedjob, which is a200withstate: "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.
Top comments (0)