YouTube Transcript Scraper API - Captions and Subtitles
Pricing
from $5.92 / 1,000 transcript returneds
YouTube Transcript Scraper API - Captions and Subtitles
Extracts YouTube transcripts (manual captions and auto-generated). Tells you whether a video is private, has no captions, or was blocked - instead of returning a blank row - and charges only for transcripts that actually contain text.
Pricing
from $5.92 / 1,000 transcript returneds
Rating
0.0
(0)
Developer
NeverEmpty
Maintained by CommunityActor stats
0
Bookmarked
1
Total users
0
Monthly active users
6 days ago
Last modified
Share
Get the transcript of any public YouTube video — manual captions and auto-generated ones — as clean text, or as timestamped lines.
The difference is what happens when something goes wrong. Most transcript scrapers return an empty result whether the video has no captions, the video is private, or YouTube blocked the request. You cannot tell which, so you cannot fix it. This one tells you, in plain words, and only charges you for transcripts that actually contain text.
Measured, not claimed
A run of 15 videos on 2026-08-25, from the shipped build:
- 14 returned a transcript. 83,647 characters total.
- 1 did not — and it said why: "この動画は非公開です" (this video is private). Nobody can scrape that one. It was not silently dropped, and it was not charged.
- 48.7 seconds for all 15.
What 180 videos actually returned
The 15-video run above is small, so on 2026-08-29 the same engine was pointed at the 15 newest uploads from 12 channels — 180 videos in all, picked from each channel's public feed rather than by hand.
| Outcome | Videos | Share |
|---|---|---|
| Transcript returned | 163 | 90.6% |
| No caption track on the video at all | 15 | 8.3% |
| Video not playable (private, removed, region) | 2 | 1.1% |
Of the 163 transcripts, 120 were auto-generated and 43 were manual captions. In other words, on a representative slice of active channels, three transcripts in four are machine transcription, and any claim that a scraper "gets the subtitles the creator wrote" is wrong three times out of four.
It varies enormously by channel
| Channel | Transcripts | Auto-generated | No caption track |
|---|---|---|---|
| Veritasium | 15 / 15 | 11 | 0 |
| Marques Brownlee | 15 / 15 | 3 | 0 |
| 3Blue1Brown | 15 / 15 | 8 | 0 |
| Linus Tech Tips | 15 / 15 | 15 | 0 |
| TED | 15 / 15 | 8 | 0 |
| Computerphile | 15 / 15 | 15 | 0 |
| Fireship | 15 / 15 | 15 | 0 |
| NASA | 12 / 15 | 1 | 1 |
| ThePrimeagen | 14 / 15 | 14 | 1 |
| HIKAKIN | 14 / 15 | 14 | 1 |
| Kurzgesagt – In a Nutshell | 11 / 15 | 9 | 4 |
| Billie Eilish | 7 / 15 | 7 | 8 |
Music channels are the outlier: more than half of the Billie Eilish uploads had no caption track at all. If your pipeline assumes "public video ⇒ transcript", a music-heavy input list will look broken.
(Channel names above are the titles YouTube itself returned for each handle. An earlier version of this measurement read the wrong channel id from the page and attributed videos to the wrong channels; the numbers here are from the corrected run.)
Asking for English does not guarantee English
The same 180-video run requested ["en"] every time.
- 156 of 163 transcripts came back in English (95.7%).
- 7 came back in another language — Arabic on a TED upload, Dutch on a Computerphile upload, and Japanese, Spanish and Vietnamese on HIKAKIN uploads.
- In none of those 7 cases did the video have an English track that was passed over. When you get another language, it is because English was not there.
That is why every row carries language, isAutoGenerated and availableLanguages: so you can drop or
translate the odd row instead of quietly mixing languages into a dataset you thought was English.
What you get
| Field | Example |
|---|---|
ok | true / false — did this video produce a transcript |
videoId / url | aircAruvnKk |
title / author | But what is a neural network? / 3Blue1Brown |
lengthSeconds | 1132 |
language | en |
isAutoGenerated | false — manual captions are preferred when both exist |
availableLanguages | ["en","es","fr","ja", ...] |
segmentCount | 286 |
text | The whole transcript as one string |
segments | [{ "start": 12.4, "duration": 3.1, "text": "..." }, ...] |
reason | Why it failed, when ok is false |
Why transcripts come back empty elsewhere
The caption URL published on a YouTube watch page returns zero bytes for every format — measured on every video tried. Anything built on that path silently produces nothing. This Actor does not use it. It reads the same endpoint the official mobile apps use, which still serves captions, and it requests only the fields it needs, so a lookup transfers about 19 KB instead of 228 KB.
That is also why it is cheap to run at volume.
Honest failure reasons
When a video does not produce a transcript, you get one of these in reason — never a blank row:
この動画は非公開です— private videoメンバー限定の動画です— members-only年齢制限のある動画です— age-restrictedこの地域では再生できない動画です— geo-blockedこの動画に字幕がありません— the video genuinely has no captionsボット判定でブロック— YouTube rate-limited the request; retried automatically with a fresh IP first
A private video and a blocked request are not the same problem, and this Actor does not pretend they are.
Input
{"videos": ["https://www.youtube.com/watch?v=aircAruvnKk","https://youtu.be/dQw4w9WgXcQ","M7lc1UVf-VE"],"languages": ["en"],"includeTimestamps": true,"proxyMode": "auto"}
Watch URLs, youtu.be links, Shorts URLs, embed URLs, and bare 11-character IDs all work.
| Field | Default | Meaning |
|---|---|---|
videos | (required) | URLs or IDs |
languages | ["en"] | Preferred order. Manual captions win over auto-generated |
includeTimestamps | true | Include per-line segments alongside the full text |
proxyMode | auto | auto tries datacenter first (cheaper), falls back to residential when YouTube blocks |
maxRetries | 3 | Retries with a fresh IP on a block or a network failure |
Calling it from your own code
JavaScript:
import { ApifyClient } from 'apify-client';const client = new ApifyClient({ token: process.env.APIFY_TOKEN });const run = await client.actor('neverempty/youtube-transcript-reliable').call({videos: ['https://www.youtube.com/watch?v=aircAruvnKk'],languages: ['en'],includeTimestamps: true,});const { items } = await client.dataset(run.defaultDatasetId).listItems();for (const row of items) {if (!row.ok) { console.warn(row.videoId, 'skipped:', row.reason); continue; }console.log(row.title, '-', row.language, row.isAutoGenerated ? '(auto)' : '(manual)');console.log(row.text.slice(0, 200));}
Python:
from apify_client import ApifyClientclient = ApifyClient("<APIFY_TOKEN>")run = client.actor("neverempty/youtube-transcript-reliable").call(run_input={"videos": ["https://www.youtube.com/watch?v=aircAruvnKk"],"languages": ["en"],"includeTimestamps": True,})for row in client.dataset(run["defaultDatasetId"]).iterate_items():if not row["ok"]:print(row["videoId"], "skipped:", row["reason"])continueprint(row["title"], row["language"], "auto" if row["isAutoGenerated"] else "manual")
The same run is available over plain HTTP, and through the Apify MCP server if you are wiring it into an AI agent.
Feeding a transcript to an LLM
Two things matter and both are in the output.
Length. A 20-minute talk is roughly 3,000 words. Send text when you want a summary; send segments
when the model has to cite a moment, because each segment carries start in seconds and you can turn that
straight into a ?t= link back to the video.
Provenance. isAutoGenerated tells you whether the words were typed by a person or guessed by a
recogniser. Auto-generated tracks have no punctuation on many channels and will mis-hear names — which is
survivable for search and summarisation, and not survivable for quotation. Keep the flag on the row so the
model, or the person reading its answer, can tell the difference.
Pricing
You are charged only when a transcript with actual text comes back. Private videos, videos without captions, and blocked requests cost you nothing.
That matters more than the headline number: a cheaper Actor that charges for empty rows costs more per usable transcript. On the 180-video run above, 17 videos produced nothing — a scraper that bills per input row would have charged for all 17.
Typical uses
- Feeding video content to an LLM for summaries, Q&A or RAG
- Searching across a channel's back catalogue
- Subtitling, translation and repurposing workflows
- Research and content analysis at scale
FAQ
Does it work on auto-generated captions?
Yes. Manual captions are preferred when both exist, and isAutoGenerated tells you which you got. On the
2026-08-29 run, 120 of 163 transcripts were auto-generated.
What about videos in other languages?
Pass languages in your order of preference. availableLanguages lists everything the video has. If you ask
for English and the video has no English track, you get what it does have, and language says so.
Why does it need a proxy? YouTube rate-limits repeated caption requests from one address. Requests are retried from a fresh IP automatically. Residential proxying is used only when it is actually needed.
Will I be charged for a video with no captions? No. Charging happens after a transcript with text is produced.
Can it take a whole channel? This Actor takes a list of videos. For a channel, use YouTube Channel Transcript Scraper, which resolves the handle to a channel id first — handles get reused and redirected, so resolving them is not optional.
How many videos can I send at once? There is no fixed cap. The run is sequential with retries, so a list of a few hundred is routine; for tens of thousands, split the list across runs so a single block does not stall everything behind it.
Other tools by NeverEmpty
Every NeverEmpty Actor follows the same rule: it never returns an empty result to mean two different things, and it only charges for rows that actually carry an answer.
- youtube-channel-transcripts - the same engine, pointed at a channel handle
- github-repo-search - GitHub repository search through the official API, with activity columns
- seo-site-audit - technical SEO audit: meta, canonical, robots, headings
Support
If a video that should work does not, open an issue on the Issues tab with the video ID. The reason field on the failed row tells me most of what I need.