The first three tutorials in this series were all about dubbing: one video in ten lines, a whole batch of languages, and a button inside your own product. This one is about the output people forget the pipeline already produces: the words. Subtitles, captions, a plain timed transcript. Sometimes you want the text on screen, not a new voice over the top, and that path is cheaper.
The problem
Text under a video is not a nice-to-have. Most of your feed autoplays on mute, so captions are the difference between someone watching and someone scrolling past. Screen-reader users and anyone in a noisy room need them. Search engines index the transcript, not the pixels. And a viewer who reads your language better than they hear it will follow along with subtitles when they would give up on audio.
Building that yourself is the same stack as dubbing minus the last step: run ASR, line the words up against the audio, figure out who is speaking, and write it all out as timed cues. You do all of that work and still do not have a dub. If all you need is the text, paying for the full voice-over is overkill.
What you'll build
A script that takes a video URL and gets back an .srt subtitle file: timed cues in the source language. No models, no alignment code, one POST and the same poll loop from tutorial #1. Then a second thing you get for free: any dub job you already run also hands you subtitles in both languages, so you may not need a separate call at all.
Two ways to get subtitles
The pipeline writes subtitles in two situations, and which one you want depends on whether you also need the dub.
-
Transcribe only.
POST /api/v1/transcriberuns the speech-to-text half on its own: no translation, no voice-over. You get a source-language SRT, billed at half the per-minute rate a dub costs. Reach for this when you want captions in the original language and nothing else. -
Subtitles as a byproduct of a dub. Every finished dub job carries four downloads, not just the video. Two of them are subtitle files:
translationSrtUrl(captions in the language you dubbed into) andtranscriptSrtUrl(the original source transcript). If you are already dubbing the video, the subtitles are sitting there in the same response for no extra minutes.
The rest of this post covers the transcribe path first, then how to pull the two SRTs out of a dub you already have.
Step 1: transcribe the video
Same shape as a dub, minus the target language. Transcribe does not translate, so there is no target_lang here: you are asking for the words that are already spoken.
curl -s -X POST 'https://dubanyvideo.com/api/v1/transcribe' \
-H "Authorization: Bearer $DV_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "source_type": "youtube", "input_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ" }'
# → 201 { "id": 7100, "state": "queued" }
You get back the same { id, state } a dub gives you. Grab the id and poll it exactly like any other job. source_type also accepts "url" for a direct file or an HLS playlist, not just YouTube.
Step 2: poll, then download the SRT
No webhooks, so you poll GET /api/v1/jobs/{id} on an interval until state is done. Same gotcha as always: a failed job comes back as a 200 with state: "failed" and an errorMessage, not an HTTP error, so branch on state.
For a transcribe job the SRT is the main output. It shows up in two places in the status object, and both point at the same file:
-
archiveUrlis the primary download for the job, which for a transcribe job is the SRT itself. -
artifacts.transcriptSrtUrlis the same file under a stable named key, if you prefer to read artifacts by name.
// GET /api/v1/jobs/7100 once it's done
{
"id": 7100,
"mode": "transcribe",
"state": "done",
"archiveUrl": "https://…/transcript.srt",
"artifacts": {
"dubbedVideoUrl": null,
"dubbedAudioUrl": null,
"translationSrtUrl": null,
"transcriptSrtUrl": "https://…/transcript.srt"
}
}
The other three artifact keys are null on a transcribe job. That is on purpose: the four keys are always present so you can rely on the shape, and they fill in only when the artifact exists. A dub fills all four; a transcribe fills one.
Full code
Set DV_API_KEY in your environment. Each version transcribes, polls, and writes a .srt next to the script.
Node (18+, global fetch)
// Node 18+ (global fetch). Set DV_API_KEY in your environment.
import { writeFile } from 'node:fs/promises'
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))
const INPUT_URL = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
// 1. Start a transcribe job (no target language): 201 { id, state }
const { id } = await fetch(`${base}/transcribe`, {
method: 'POST',
headers,
body: JSON.stringify({ source_type: 'youtube', input_url: INPUT_URL }),
}).then((r) => r.json())
// 2. Poll until it's done.
let job
do {
await sleep(5000)
job = await fetch(`${base}/jobs/${id}`, { headers }).then((r) => r.json())
console.log(job.state + (job.stage ? ` — ${job.stage}` : ''))
} while (job.state !== 'done' && job.state !== 'failed')
if (job.state === 'failed') throw new Error(job.errorMessage)
// 3. Download the SRT (archiveUrl, or artifacts.transcriptSrtUrl — same file).
const srt = await fetch(job.artifacts.transcriptSrtUrl).then((r) => r.arrayBuffer())
await writeFile('transcript.srt', Buffer.from(srt))
console.log('saved transcript.srt')
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']}"}
INPUT_URL = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
# 1. Start a transcribe job (no target language): 201 { "id", "state" }
job_id = requests.post(f"{base}/transcribe", headers=headers, json={
"source_type": "youtube",
"input_url": INPUT_URL,
}).json()["id"]
# 2. Poll until it's done.
while True:
job = requests.get(f"{base}/jobs/{job_id}", headers=headers).json()
print(job["state"])
if job["state"] == "done":
break
if job["state"] == "failed":
raise RuntimeError(job["errorMessage"])
time.sleep(5)
# 3. Download the SRT (archiveUrl, or artifacts.transcriptSrtUrl — same file).
srt = requests.get(job["artifacts"]["transcriptSrtUrl"]).content
with open("transcript.srt", "wb") as f:
f.write(srt)
print("saved transcript.srt")
Subtitles from a dub you already ran
If the video is getting dubbed anyway, you do not need a second call. A finished dub job carries subtitles in both languages already. Point at a done dub job and read its artifacts:
// GET /api/v1/jobs/{id} for a done DUB job (dubbed en → es)
{
"state": "done",
"targetLang": "es",
"archiveUrl": "https://…/dubbed.mp4",
"artifacts": {
"dubbedVideoUrl": "https://…/dubbed.mp4",
"dubbedAudioUrl": "https://…/dubbed.mp3",
"translationSrtUrl": "https://…/es.srt", // captions in the dubbed language
"transcriptSrtUrl": "https://…/en.srt" // the original source transcript
}
}
So one dub gives you the dubbed video, the dubbed audio track, target-language subtitles, and the source transcript in a single job. Ship the .srt alongside the video and viewers can read along in either language.
What the SRT looks like, and where it goes
It is a standard SubRip (.srt) file: numbered cues with start and end timings and the transcribed line, in the source language. That format drops straight into VLC, Premiere, DaVinci Resolve, a YouTube caption upload, or an ffmpeg burn-in.
One thing to know if you are putting captions on an HTML5 <video>: the browser <track> element wants WebVTT, not SRT. The two are nearly identical. Converting is a header line and a punctuation swap: add WEBVTT as the first line and change the , before the milliseconds in each timestamp to a .. Any subtitle library does it in one call, or you can do it with a two-line replace.
Cost & limits
- Transcribe is billed from the same balance as everything else, at half the per-minute rate of a dub. A 10-minute video transcribed costs 5 minutes.
- Subtitles that come out of a dub cost nothing extra. You already paid the dub's minutes; the two SRTs are part of that job.
- Want the number before you commit?
POST /api/v1/estimateis free, creates no job, and returns the source duration. Halve it for the transcribe cost. To check your remaining balance,GET /api/v1/me/usagereturns your plan, monthly quota, minutes used, and top-up balance. - Max source length is 30 minutes per job, and input is by link only (YouTube, HLS, or direct URL).
- Rate limits: 60 requests/min per key. New submissions are capped per minute by plan (free 2 · starter 5 · medium 10 · super 20).
- Prefer pay-per-job over a subscription? The same engine is on the RapidAPI marketplace, billed per job.
Get your key
Grab a key and pull the text out of your next video: dubanyvideo.com/api. That closes the loop on the pipeline: dub it, localize it into a batch, put a button on it, and caption it, all from the same key.
Top comments (0)