You have a list of YouTube videos and you need subtitle files for them: .srt for an editor or a video player, .vtt for an HTML <track> element, maybe a second language for the same video. Doing it by hand in the YouTube UI does not scale past a handful, and the usual libraries hand you raw caption segments that you still have to turn into valid files.
This post covers the whole path: fetching captions, choosing a language track, building SRT and VTT correctly (including the one timing quirk that breaks players), and what to do when a translation request fails. The sample output below comes from real runs on 27 October 2026 against the first YouTube video ever uploaded, jNQXAC9IVRw (19 seconds, so the files stay short enough to read).
What an SRT and a VTT file actually are
Both formats are plain text lists of cues. A cue is a time range and the text to show.
SRT numbers every cue and uses a comma before the milliseconds:
1
00:00:01,200 --> 00:00:03,360
All right, so here we are, in front of the elephants
VTT starts with a WEBVTT header, drops the numbers (they are optional) and uses a dot:
WEBVTT
00:00:01.200 --> 00:00:03.360
All right, so here we are, in front of the elephants
That is the entire difference for simple captions. Which means the interesting work is not the syntax. It is getting correct timings and the right language in the first place.
Step 1: fetch the captions
The Siftwright YouTube transcript API can return srt and vtt strings directly through its outputFormats option, next to the raw segments. Requesting segments as well is worth it, because they let you build your own files when you need a different line length or style.
curl https://siftwright.com/v1/youtube-transcript \
-H "Authorization: Bearer $SIFTWRIGHT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://www.youtube.com/watch?v=jNQXAC9IVRw",
"language": "en",
"outputFormats": ["segments", "srt", "vtt"]}'
In Python, saving one pair of files per video:
import os
import requests
API = "https://siftwright.com/v1/youtube-transcript"
KEY = os.environ["SIFTWRIGHT_API_KEY"]
def fetch(url, language="en"):
r = requests.post(
API,
headers={"Authorization": f"Bearer {KEY}"},
json={"url": url, "language": language,
"outputFormats": ["segments", "srt", "vtt"]},
timeout=120,
)
r.raise_for_status()
return r.json()["results"][0]
item = fetch("https://www.youtube.com/watch?v=jNQXAC9IVRw")
if item["status"] == "ok":
open(f'{item["videoId"]}.{item["language"]}.srt', "w", encoding="utf-8").write(item["srt"])
open(f'{item["videoId"]}.{item["language"]}.vtt', "w", encoding="utf-8").write(item["vtt"])
A note on what was and was not tested. For this post we ran the same request, with the same input fields, against the public YouTube Transcript Extractor Actor, the engine behind the endpoint, and checked every output below against it. The response envelope of the endpoint (a results array, as shown in our earlier tutorial) is taken from our docs rather than from a fresh call with a new key.
The Actor returned this for the zoo video:
1
00:00:01,200 --> 00:00:03,360
All right, so here we are, in front of the elephants
2
00:00:05,318 --> 00:00:07,974
the cool thing about these guys is that they have really...
3
00:00:07,974 --> 00:00:12,616
really really long trunks
Notice cue 2 ends at 07,974, exactly where cue 3 begins. That is deliberate, and it is the quirk the next section is about.
Step 2: the overlap problem
Raw YouTube caption segments come as start and duration. For auto-generated captions in particular, a segment's start + duration often runs past the start of the next segment, because the text is being rolled up on screen while the next words arrive.
If you naively compute end = start + duration, you get cues that overlap. Overlapping cues are legal in VTT but they stack on screen, and strict SRT consumers either reject them or show two cues at once. The fix is simple: never let a cue end after the next one starts.
Two consecutive segments from the zoo video show the fields involved:
{"start": 5.318, "duration": 2.656, "text": "the cool thing about these guys is that they have really..."}
{"start": 7.974, "duration": 4.642, "text": "really really long trunks"}
Here 5.318 + 2.656 is exactly 7.974, so nothing overlaps. In auto-generated captions the rolled-up text can make a segment's computed end land after the next segment's start, so the clamp matters; it costs nothing when nothing overlaps.
Step 3: build your own files from segments
You need your own builder when you want different line lengths (broadcast guidelines often use 42 characters per line and two lines per cue), when you merge segments, or when you want to post-process text. This is the whole thing in standard-library Python:
import re
def ts(sec, sep):
ms = round(sec * 1000)
h, ms = divmod(ms, 3_600_000)
m, ms = divmod(ms, 60_000)
s, ms = divmod(ms, 1000)
return f"{h:02}:{m:02}:{s:02}{sep}{ms:03}"
def wrap(text, width=42):
lines, cur = [], ""
for w in text.split():
if cur and len(cur) + 1 + len(w) > width:
lines.append(cur)
cur = w
else:
cur = f"{cur} {w}".strip()
lines.append(cur)
return "\n".join(lines)
def cues(segments):
out = []
for i, seg in enumerate(segments):
start = seg["start"]
end = start + seg["duration"]
if i + 1 < len(segments): # never run past the next cue
end = min(end, segments[i + 1]["start"])
text = " ".join(seg["text"].split())
if text and end > start:
out.append((start, end, wrap(text)))
return out
def to_srt(segments):
return "\n".join(
f"{n}\n{ts(a, ',')} --> {ts(b, ',')}\n{t}\n"
for n, (a, b, t) in enumerate(cues(segments), 1))
def to_vtt(segments):
body = "\n".join(
f"{ts(a, '.')} --> {ts(b, '.')}\n{t}\n" for a, b, t in cues(segments))
return "WEBVTT\n\n" + body
We checked this builder against the API's own srt output for the zoo video. The timings match cue for cue; the only difference is that the builder wraps long lines at 42 characters, so the first cue becomes two lines:
1
00:00:01,200 --> 00:00:03,360
All right, so here we are, in front of the
elephants
Whether that is better depends on where the subtitles are shown. For a narrow mobile player, wrapping helps. For a wide web player it is just noise, which is why we return the unwrapped version by default and give you the segments to wrap yourself.
If you already have an SRT string and just need VTT, converting it is three lines, because VTT is nearly a subset:
def srt_to_vtt(srt):
srt = srt.replace("\r\n", "\n").strip() + "\n"
srt = re.sub(r"(\d\d:\d\d:\d\d),(\d{3})", r"\1.\2", srt)
srt = re.sub(r"^\d+\n(?=\d\d:)", "", srt, flags=re.M) # drop cue numbers
return "WEBVTT\n\n" + srt
Run on the API's SRT for the zoo video, this produced a file identical to the vtt field the API returned.
Step 4: choose the language track deliberately
A video can carry several caption tracks, and a track can be manual (written by the uploader) or auto-generated. Two things follow:
- Ask for
preferManual: true(the default) so a human-written track wins over a machine one in the same language. - Read
availableLanguagesin the response to see what actually exists instead of guessing.
For the zoo video the response lists two tracks, English and German, both manual. Asking for German with "language": "de" returned a real German track, not a machine translation:
1
00:00:00,000 --> 00:00:05,067
Also hier sind wir vor den Elefanten.
2
00:00:05,067 --> 00:00:12,467
Das Coole an den Typen ist dass sie sehr, sehr, sehr, lange Rüssel haben.
Note that the German cues have different timings from the English ones. Tracks are separate files written separately, so you cannot reuse one track's timings for another. If you ship both, ship both files.
Step 5: translation, and what happens when it fails
For languages the video does not have a track for, you can ask for a translation with translateTo (for example "translateTo": "fr"). It uses YouTube's own caption translation, so quality is machine quality, and it is a service we depend on rather than control.
We tested this, and it is worth being honest about the result. Two translation requests from our test environment, translateTo: "es" and translateTo: "fr", both came back with status: "translation_failed". The first said Translation failed (fetch failed) and the second Translation failed (HTTP 429), which is YouTube rate-limiting the request. In both cases the original-language transcript was returned instead, and the error message states that it is free of charge.
That behaviour is the useful part, and your code should expect it:
def subtitles(url, want="es"):
r = requests.post(API, headers={"Authorization": f"Bearer {KEY}"},
json={"url": url, "translateTo": want,
"outputFormats": ["segments", "srt", "vtt"]}, timeout=120)
r.raise_for_status()
item = r.json()["results"][0]
if item["status"] == "ok":
return item, item["language"] # translated (or native) track
if item["status"] == "translation_failed":
# original language came back, unbilled: keep it, retry the translation later
return item, item["sourceLanguage"]
raise RuntimeError(f'{item["status"]}: {item.get("error")}')
A failed translation is not a reason to drop the video. Store the original-language file, record that the translation is pending, and retry later with a delay. A 429 is the textbook case for backing off rather than hammering.
If you need translations you can depend on at volume, a better pipeline is to fetch the source-language segments once and send the cue texts to a translation service you control, keeping the original start and end values. That keeps timings stable and moves the failure risk to a service you can pick and pay for.
Practical checks before you ship the files
A few small checks catch most subtitle bugs:
-
Save as UTF-8. German, French and every non-Latin script break on a default Windows code page. Always pass
encoding="utf-8". - Validate monotonic cues. Assert that each cue's start is at or after the previous cue's end. The builder above guarantees it; third-party SRTs often do not.
-
Name files by language.
videoId.en.srtandvideoId.de.srt. Players and HTML<track srclang>both want the language code. - Skip videos without captions. The API marks them with an error status and does not bill them, so a batch job should log and move on.
- Respect the source. Captions belong to the video's uploader. Fine for accessibility, search and personal study; check the licence before republishing them.
What it costs
The Siftwright API plans start at $29 per month and count only results with status ok, so videos without captions and failed translations do not use your quota. If you only need a few thousand files, the pay-per-result Apify Actor is simpler. The pricing page has a calculator, and the post on pay-per-result vs subscription pricing explains where the break-even sits.
To check what a video offers before writing any code, the free YouTube transcript tool downloads TXT, SRT and VTT in the browser, no key needed.
Originally published on siftwright.com.
Top comments (0)