Generate SRT and VTT Subtitles from YouTube Videos
David Boulen · 7/8/2026 · 10 min read
Generate SRT and VTT Subtitles from YouTube Videos
Subtitles matter more than most developers expect. 70% of Americans watch online video with subtitles, and 80% of caption users have no hearing impairment — they are watching in noisy coffee shops, studying in a second language, or simply concentrating better with text on screen. If you are building a video platform, content tool, or accessibility pipeline, generating proper subtitle files from YouTube videos is a core feature, not an afterthought.
This guide shows you exactly how to go from the timestamped JSON that the YouTube Transcriber API returns to production-ready SRT and VTT files — with working code in Python and JavaScript, format gotchas, and line-length best practices.

SRT, VTT, and JSON: What Is the Difference?
Before writing any code, it helps to know what format you actually need.
JSON (what the API returns)
The YouTube Transcriber API returns transcript data as a JSON array. Each segment looks like this:
{
"video_id": "dQw4w9WgXcQ",
"language": "en",
"transcript": [
{ "text": "We're no strangers to love", "start": 0.5, "duration": 2.1 },
{ "text": "You know the rules and so do I", "start": 2.8, "duration": 2.4 },
{ "text": "A full commitment's what I'm thinking of", "start": 5.4, "duration": 2.6 }
]
}
JSON is the right format for programmatic processing — feeding an LLM, building a search index, or running sentiment analysis. But video players expect SRT or VTT.
SRT (SubRip Text)
SRT is the oldest and most widely supported subtitle format. It is plain text with a strict structure:
1
00:00:00,500 --> 00:00:02,600
We're no strangers to love
2
00:00:02,800 --> 00:00:05,200
You know the rules and so do I
3
00:00:05,400 --> 00:00:08,000
A full commitment's what I'm thinking of
Key rules:
- Sequential integers starting at
1 - Timestamps in
HH:MM:SS,mmmformat — comma before milliseconds -->separator with a space on each side- Blank line between every cue block
- No header line
SRT works in virtually every video editing tool: Premiere Pro, Final Cut, DaVinci Resolve, VLC, and most CDN subtitle upload flows.
VTT (WebVTT)
VTT is the W3C standard for the web. Structurally it is similar to SRT with three important differences:
WEBVTT
00:00:00.500 --> 00:00:02.600
We're no strangers to love
00:00:02.800 --> 00:00:05.200
You know the rules and so do I
00:00:05.400 --> 00:00:08.000 align:center
A full commitment's what I'm thinking of
Differences from SRT:
- Requires a
WEBVTTheader on the first line, followed by a blank line - Timestamps use a period before milliseconds, not a comma
- Cue identifiers are optional
- Supports positioning (
align:center), speaker tags (<v Speaker>Text</v>), and CSS via::cue - Native support in the HTML5
<track>element
The most common conversion bug: copying an SRT file and just prepending WEBVTT without swapping commas to periods. Browsers will silently fail to parse the cues.
| Feature | SRT | VTT |
|---|---|---|
| Millisecond separator | Comma , | Period . |
| Header required | No | Yes (WEBVTT) |
| Cue numbering | Required | Optional |
| CSS styling | No | Yes |
| Positioning controls | No | Yes |
| HTML5 <track> support | Indirect (via JS) | Native |
| Desktop tool compatibility | Excellent | Good |
Rule of thumb: use VTT for web players, use SRT for video editing tools and broad compatibility.
Fetching the Transcript JSON
First, get the raw transcript from the API. You need a Bearer token from getyoutubetranscriber.com — 100 free credits, no credit card required.
curl "https://getyoutubetranscriber.com/api/v2/transcript?video_url=dQw4w9WgXcQ&include_timestamp=true" \
-H "Authorization: Bearer YOUR_API_KEY"
The include_timestamp=true parameter is on by default but it is worth being explicit. The start and duration fields (both in seconds as floats) are what you will convert into cue timestamps.
For multilingual projects, add the lang parameter. For example, lang=fr returns the French track if one exists. See Get YouTube Transcripts in 125+ Languages for the full language code list and fallback behavior.
Converting JSON to SRT and VTT in Python
The conversion is straightforward: iterate over segments, format the timestamps, and build up a string.
import requests
API_KEY = "YOUR_API_KEY"
VIDEO_ID = "dQw4w9WgXcQ"
def fetch_transcript(video_id: str, lang: str = "en") -> list[dict]:
url = "https://getyoutubetranscriber.com/api/v2/transcript"
headers = {"Authorization": f"Bearer {API_KEY}"}
params = {"video_url": video_id, "include_timestamp": "true", "lang": lang}
response = requests.get(url, headers=headers, params=params)
response.raise_for_status()
return response.json()["transcript"]
def seconds_to_srt_timestamp(seconds: float) -> str:
"""Convert float seconds to SRT format: HH:MM:SS,mmm"""
ms = int(round(seconds * 1000))
h, remainder = divmod(ms, 3_600_000)
m, remainder = divmod(remainder, 60_000)
s, ms = divmod(remainder, 1000)
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
def seconds_to_vtt_timestamp(seconds: float) -> str:
"""Convert float seconds to VTT format: HH:MM:SS.mmm"""
# Only difference from SRT is the period instead of comma
return seconds_to_srt_timestamp(seconds).replace(",", ".")
def wrap_lines(text: str, max_chars: int = 42) -> str:
"""Split text into lines of at most max_chars characters."""
words = text.split()
lines, current = [], ""
for word in words:
if current and len(current) + 1 + len(word) > max_chars:
lines.append(current)
current = word
else:
current = f"{current} {word}".strip()
if current:
lines.append(current)
return "\n".join(lines)
def to_srt(segments: list[dict]) -> str:
blocks = []
for i, seg in enumerate(segments, start=1):
start = seconds_to_srt_timestamp(seg["start"])
end = seconds_to_srt_timestamp(seg["start"] + seg["duration"])
text = wrap_lines(seg["text"])
blocks.append(f"{i}\n{start} --> {end}\n{text}")
return "\n\n".join(blocks) + "\n"
def to_vtt(segments: list[dict]) -> str:
blocks = ["WEBVTT\n"] # Header + blank line
for seg in segments:
start = seconds_to_vtt_timestamp(seg["start"])
end = seconds_to_vtt_timestamp(seg["start"] + seg["duration"])
text = wrap_lines(seg["text"])
blocks.append(f"{start} --> {end}\n{text}")
return "\n\n".join(blocks) + "\n"
# --- Main ---
segments = fetch_transcript(VIDEO_ID)
with open("subtitles.srt", "w", encoding="utf-8") as f:
f.write(to_srt(segments))
with open("subtitles.vtt", "w", encoding="utf-8") as f:
f.write(to_vtt(segments))
print("Done.")
Gotcha: always use utf-8 encoding. SRT technically allows other encodings but players consistently handle UTF-8 best. For Arabic, Chinese, or emoji-heavy tracks, a different encoding will corrupt the file.
Converting JSON to SRT and VTT in JavaScript (Node.js)
const fs = require("fs");
const API_KEY = "YOUR_API_KEY";
const VIDEO_ID = "dQw4w9WgXcQ";
async function fetchTranscript(videoId, lang = "en") {
const url = new URL("https://getyoutubetranscriber.com/api/v2/transcript");
url.searchParams.set("video_url", videoId);
url.searchParams.set("include_timestamp", "true");
url.searchParams.set("lang", lang);
const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${API_KEY}` },
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
const data = await res.json();
return data.transcript;
}
function secondsToTimestamp(seconds, separator = ",") {
const ms = Math.round(seconds * 1000);
const h = Math.floor(ms / 3_600_000);
const m = Math.floor((ms % 3_600_000) / 60_000);
const s = Math.floor((ms % 60_000) / 1000);
const ms3 = ms % 1000;
const pad = (n, d) => String(n).padStart(d, "0");
return `${pad(h,2)}:${pad(m,2)}:${pad(s,2)}${separator}${pad(ms3,3)}`;
}
function wrapLines(text, maxChars = 42) {
const words = text.split(" ");
const lines = [];
let current = "";
for (const word of words) {
if (current && current.length + 1 + word.length > maxChars) {
lines.push(current);
current = word;
} else {
current = current ? `${current} ${word}` : word;
}
}
if (current) lines.push(current);
return lines.join("\n");
}
function toSrt(segments) {
return segments
.map((seg, i) => {
const start = secondsToTimestamp(seg.start, ",");
const end = secondsToTimestamp(seg.start + seg.duration, ",");
const text = wrapLines(seg.text);
return `${i + 1}\n${start} --> ${end}\n${text}`;
})
.join("\n\n") + "\n";
}
function toVtt(segments) {
const cues = segments.map((seg) => {
const start = secondsToTimestamp(seg.start, ".");
const end = secondsToTimestamp(seg.start + seg.duration, ".");
const text = wrapLines(seg.text);
return `${start} --> ${end}\n${text}`;
});
return ["WEBVTT", "", ...cues].join("\n\n") + "\n";
}
(async () => {
const segments = await fetchTranscript(VIDEO_ID);
fs.writeFileSync("subtitles.srt", toSrt(segments), "utf8");
fs.writeFileSync("subtitles.vtt", toVtt(segments), "utf8");
console.log("Done.");
})();
The separator parameter in secondsToTimestamp makes it trivial to switch between SRT (,) and VTT (.) without duplicating timestamp logic.
Timing and Line-Length Best Practices
Getting the format right is necessary but not sufficient. Poorly timed or overly long subtitle cues frustrate viewers even when they parse correctly.
Line length: Keep each line under 42 characters. This is the rule used by the BBC, Netflix, and most broadcast standards. On a mobile screen, longer lines either overflow or force tiny font sizes. The wrap_lines / wrapLines helpers above enforce this at the word boundary.
Cue duration: Aim for 1–7 seconds per cue. YouTube auto-captions sometimes generate very short cues (under 0.5 s) that flash too fast to read. You can merge consecutive short segments:
def merge_short_segments(segments, min_duration=1.0):
merged = []
buffer = None
for seg in segments:
if buffer is None:
buffer = dict(seg)
elif buffer["duration"] < min_duration:
buffer["text"] += " " + seg["text"]
buffer["duration"] = (seg["start"] + seg["duration"]) - buffer["start"]
else:
merged.append(buffer)
buffer = dict(seg)
if buffer:
merged.append(buffer)
return merged
Timing gaps: Avoid cue overlap unless you are using VTT for karaoke-style highlighting. Overlapping cues in SRT confuse some players and can cause cues to drop entirely.
Reading speed: A comfortable reading pace is roughly 17 characters per second (the Netflix standard). If a cue contains 40 characters and lasts 1 second, viewers cannot read it. You can flag problematic cues:
def check_reading_speed(segments, max_cps=17):
for i, seg in enumerate(segments):
cps = len(seg["text"]) / max(seg["duration"], 0.1)
if cps > max_cps:
print(f"Cue {i+1} too fast: {cps:.1f} chars/sec — '{seg['text'][:40]}'")

Localization and Accessibility Use Cases
Multilingual caption pipelines
The lang parameter on the transcript endpoint lets you request any of the 125+ supported languages. A localization pipeline might look like this:
- Fetch the source-language transcript (
lang=en) - For each target locale, fetch the transcript in that language (
lang=fr,lang=ja,lang=pt-BR) - Generate per-locale SRT and VTT files
- Upload each file to your CDN or video platform
If you also want the video title and channel metadata alongside the transcript, add send_metadata=true to the request. The response includes title, author_name, and thumbnail_url — useful for file naming and logging.
For a practical example of building subtitle pipelines across many videos at once, see Download YouTube Transcripts in Bulk Without Getting Blocked.
Accessibility compliance
VTT has a feature SRT lacks: speaker identification. You can add speaker labels directly in the cue:
WEBVTT
00:00:01.000 --> 00:00:04.500
<v Host>Welcome back to the channel.</v>
00:00:05.000 --> 00:00:08.000
<v Guest>Thanks for having me.</v>
CSS can then style each speaker differently via ::cue(v[voice="Host"]). This is particularly valuable for interview content, panel discussions, or educational videos where distinguishing speakers is essential for deaf or hard-of-hearing viewers.
WCAG 1.2.2 (Level AA) requires synchronized captions for prerecorded video. If your platform hosts third-party YouTube embeds, generating and attaching subtitle tracks is the cleanest path to compliance — the HTML5 <track> element with kind="subtitles" or kind="captions" handles the rest.
Content repurposing
SRT and VTT files are also useful beyond playback. The same caption data can feed:
- Blog posts and articles: strip the timestamps, join the text, and you have a rough transcript ready for editing. See 7 Ways to Repurpose YouTube Videos into SEO Content for a full workflow.
- Search indexing: index cue text alongside its start time so users can jump to the exact moment in the video where a keyword appears.
- Translation workflows: SRT/VTT files are the standard input format for professional translation tools like Amara, Transifex, and Phrase.
Serving VTT in a Web Player
Once you have a .vtt file, attaching it to an HTML5 video element is two lines:
<video controls>
<source src="video.mp4" type="video/mp4" />
<track src="subtitles.vtt" kind="subtitles" srclang="en" label="English" default />
<track src="subtitles-fr.vtt" kind="subtitles" srclang="fr" label="Français" />
</video>
The default attribute activates the track automatically. Multiple <track> elements let viewers switch languages via the player controls without any JavaScript.
If you are streaming from a CDN, make sure the VTT file is served with the Content-Type: text/vtt header. Some CDNs default to text/plain for .vtt files, which causes Chrome to refuse to load the track.
Getting Started
The YouTube Transcriber API gives you 100 free credits to start — no credit card required. The single /api/v2/transcript endpoint handles proxy rotation, anti-bot challenges, and retries for you. You pass a video URL, get back clean JSON, and the conversion code above handles the rest.
Check the full API documentation for rate limits, bulk endpoints, and the complete list of language codes.