YouTube Transcript & Subtitles Scraper API
Pricing
$2.50 / 1,000 transcripts
YouTube Transcript & Subtitles Scraper API
Get YouTube transcripts and captions from videos, playlists and channels as timed segments, plain text, SRT or VTT. Many languages, an auto-generated fallback and video metadata. Each failed video gets a clear error row and the run never hangs. Built for AI and RAG pipelines.
Pricing
$2.50 / 1,000 transcripts
Rating
0.0
(0)
Developer
Michael Lip
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
12 hours ago
Last modified
Security
Limited permissionsShare
TubeText YouTube Transcript Scraper
Get YouTube transcripts from videos, playlists and channels as plain text, timestamped segments, SRT or WebVTT, for $0.0025 per saved transcript. Videos that fail cost nothing.
Paste any mix of watch URLs, youtu.be links, Shorts, playlist URLs and @handles, one per line. You get one row per video with the transcript and the video's title, channel, publish date, duration and view count. When a video can't be read (private, age-restricted, no captions, blocked), you get a free row with a clear error code and the run keeps going.
People use it to feed transcripts into RAG pipelines and LLM prompts, to search what a channel has said about a topic, and to make subtitle files.
What you get
One dataset row per video. Every successful row has the full plain text in text. The output format adds segments, SRT or WebVTT on top.
| Field | Type | What it holds |
|---|---|---|
status | string | ok (transcript saved, charged) or error (free) |
videoId, url | string | The 11-character video id and the watch URL |
title | string | Video title |
channelName, channelId, channelUrl | string | The channel that uploaded the video |
publishDate | string | ISO 8601 date with YouTube's timezone offset |
durationSeconds | integer | Video length in seconds |
viewCount | integer | Views when the run read the video |
isLiveContent | boolean | true for past live streams |
category | string | YouTube category, for example Music |
languageCode, languageName | string | The caption track that was used |
isGenerated | boolean | true means YouTube's speech-recognition captions |
languageMatched | boolean | false when none of your languages existed and the original language came back |
availableLanguages | array | Every caption track the video has, auto tracks marked (auto) |
segmentCount, wordCount | integer | Number of caption lines and words |
transcriptEndSeconds | number | Where the last caption ends |
outputFormat | string | segments, text, srt or vtt |
text | string | The full transcript as plain text |
segments | array | { start, duration, text } per caption line, in seconds (segments format) |
srt, vtt | string | A ready subtitle file (srt or vtt format) |
source | object | Which input produced the row. Playlist and channel rows add title and position |
errorCode, errorMessage | string | Why a video failed (error rows only) |
The title, channel, publish date, duration, views and category fields are filled when "Include video metadata" is on, which is the default. The dataset has three views in Console: Transcripts, Errors and Languages. The OUTPUT record in the key-value store holds a run summary with counts per error code.
Example output
Here is a real row from a test run on 2026-11-05 with the prefilled input. The segments list is cut to 2 of its 6 entries. Nothing else is changed.
{"status": "ok","videoId": "jNQXAC9IVRw","url": "https://www.youtube.com/watch?v=jNQXAC9IVRw","title": "Me at the zoo","channelName": "jawed","channelId": "UC4QobU6STFB0P71PMvOGN5A","channelUrl": "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A","publishDate": "2005-04-23T20:31:52-07:00","durationSeconds": 19,"viewCount": 447802112,"isLiveContent": false,"category": "Film & Animation","languageCode": "en","languageName": "English","isGenerated": false,"languageMatched": true,"availableLanguages": ["en", "de"],"segmentCount": 6,"wordCount": 39,"transcriptEndSeconds": 18.881,"outputFormat": "segments","text": "All right, so here we are, in front of the elephants the cool thing about these guys is that they have really... really really long trunks and that's cool (baaaaaaaaaaahhh!!) and that's pretty much all there is to say","segments": [{ "start": 1.2, "duration": 2.16, "text": "All right, so here we are, in front of the elephants" },{ "start": 5.318, "duration": 2.656, "text": "the cool thing about these guys is that they have really..." }],"source": { "type": "video", "input": "https://www.youtube.com/watch?v=jNQXAC9IVRw", "id": "jNQXAC9IVRw" },"errorCode": null,"errorMessage": null}
An error row looks like this (shape from the code, values shown for a private video).
{"status": "error","videoId": "<video id>","url": "https://www.youtube.com/watch?v=<video id>","source": { "type": "video", "input": "<your input line>", "id": "<video id>" },"errorCode": "VIDEO_PRIVATE","errorMessage": "<message from YouTube>"}
Pricing
Pay per event, one event.
| Event | When it's charged | Price |
|---|---|---|
Transcript (video) | Once per video whose transcript is saved to the dataset | $0.0025 |
Error rows are pushed without an event, so they're free. Listing a playlist or channel is free too. A video from a playlist or channel costs the same as one you paste directly.
Some worked examples.
| Run | Transcripts saved | Cost |
|---|---|---|
| The prefilled input (2 videos) | 2 | $0.005 |
| A playlist of 100 videos, all with captions, max videos per playlist set to 100 | 100 | $0.25 |
A channel's latest 50 uploads (max videos per channel set to 50), 40 with captions and 10 NO_CAPTIONS | 40 | $0.10 |
| 1,000 videos for a RAG index | 1,000 | $2.50 |
To cap your spend, set a maximum cost per run (the max total charge option in Console, or maxTotalChargeUsd in the API). The Actor starts a video only when its $0.0025 still fits. When the budget is used up, the run stops cleanly, and OUTPUT.videosNotStarted tells you how many videos were left. Videos run in input order, so put the ones you care about first.
Quick start in Console
- Open the Actor and click Start. The input is prefilled with two short public videos that have English captions.
- Our local test of this input took about 12 seconds. It costs $0.005. Open the Transcripts view, or export the dataset as JSON, CSV or Excel.
- Replace the URLs with your own videos, playlists or channels and run again.
Use from the API
One call runs the Actor and returns the dataset rows.
curl -X POST \"https://api.apify.com/v2/acts/zovo-one~tubetext-youtube-transcript-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \-H "Content-Type: application/json" \-d '{"urls": ["https://www.youtube.com/watch?v=jNQXAC9IVRw","https://www.youtube.com/@TheCodingTrain"],"languages": ["en"],"outputFormat": "text","maxVideosPerSource": 10}'
The sync endpoint waits up to 5 minutes. For large playlists or channels, start the run with POST /v2/acts/zovo-one~tubetext-youtube-transcript-scraper/runs and read the dataset when it finishes.
Use from Python
$pip install apify-client
from apify_client import ApifyClientclient = ApifyClient("<APIFY_TOKEN>")run = client.actor("zovo-one/tubetext-youtube-transcript-scraper").call(run_input={"urls": ["https://www.youtube.com/playlist?list=PLRqwX-V7Uu6ZiZxtDDRCi6uhfTH4FilpH"],"languages": ["en"],"outputFormat": "segments","maxVideosPerSource": 20,})for item in client.dataset(run["defaultDatasetId"]).iterate_items():if item["status"] == "ok":print(item["title"], item["wordCount"])else:print("skipped", item["videoId"], item["errorCode"])
Use from Node.js
$npm install apify-client
import { ApifyClient } from 'apify-client';const client = new ApifyClient({ token: process.env.APIFY_TOKEN });const run = await client.actor('zovo-one/tubetext-youtube-transcript-scraper').call({urls: ['https://www.youtube.com/@TheCodingTrain'],languages: ['en', 'de'],outputFormat: 'srt',maxVideosPerSource: 5,});const { items } = await client.dataset(run.defaultDatasetId).listItems();for (const item of items) {if (item.status === 'ok') console.log(item.title, item.srt.length);}
Use from AI agents through MCP
The Apify MCP server at mcp.apify.com exposes this Actor as a tool to Claude, Cursor, VS Code and other MCP clients. Each client adds servers in its own way. VS Code and Claude Desktop, for example, don't take the JSON below as written. For your client, open the MCP configurator, pick this Actor and copy the setup it gives you. A client that reads an mcpServers block with a url key, such as Cursor, can use this config.
{"mcpServers": {"apify": {"url": "https://mcp.apify.com?tools=zovo-one/tubetext-youtube-transcript-scraper"}}}
On first connect your browser opens so you can sign in to Apify. If your client can't do that, add a "headers": { "Authorization": "Bearer <APIFY_TOKEN>" } entry instead. Agents connected to the default server can also find the Actor with search-actors, run it with call-actor and read the rows with get-dataset-items. Pass maxTotalChargeUsd in the call options to cap what one agent call can spend.
Here's a prompt that works well.
Get the English transcripts of the latest 10 videos on @TheCodingTrain. For each video, give me the title and a two-line summary, and list every video that mentions p5.js.
Transcripts are a good fit for agents. The text field is one clean string per video, and wordCount lets the agent check the size before it puts a transcript into context.
Input reference
Only urls is required. Everything else has a default.
| Field | What it does | Default |
|---|---|---|
urls | Videos, playlists or channels, one per line | prefilled with 2 videos |
languages | Preferred language codes, in order. en also matches en-US and en-GB | ["en"] |
allowAutoGenerated | Use YouTube's automatic captions when no human-made track exists in your languages | true |
fallbackToAnyLanguage | Return the original-language transcript when none of your languages exists | true |
outputFormat | segments, text, srt or vtt. The text field is always included | segments |
includeMetadata | Add title, channel, publish date, duration, views and category | true |
maxVideosPerSource | Playlists: the first N videos. Channels: the latest N uploads | 20 (max 5,000) |
maxTotalVideos | Hard cap for the whole run, after duplicates are removed | 1000 (max 10,000) |
proxyConfiguration | Proxy settings | Apify Proxy, residential |
maxConcurrency | Videos read in parallel | 3 (max 10) |
maxRetries | Extra attempts per video after a block, rate limit or network error | 2 (max 5) |
Accepted formats, one per line.
- Videos.
https://www.youtube.com/watch?v=ID,https://youtu.be/ID,/shorts/ID,/embed/ID,/live/ID,m.andmusic.hosts, or a bare 11-character id. A watch URL with&list=is read as that one video. - Playlists.
https://www.youtube.com/playlist?list=PL...or a bare playlist id (PL...,UU...,OLAK5uy_...). - Channels.
https://www.youtube.com/@handle,@handle,/channel/UC...,UC...,/c/nameor/user/name. The Actor takes the uploads, newest first. Shorts and past live streams count as uploads.
Limits
The Actor reads public captions only. It never signs in, so private, age-restricted and members-only videos give error rows.
It doesn't transcribe audio. A video with no caption track gives NO_CAPTIONS. It doesn't translate either, so if the video lacks your language, you get the original language (or a free error row with the fallback off). Chapters, comments and descriptions aren't included.
One run takes up to 5,000 input lines and 10,000 videos. A run uses at most 10 languages and 10 parallel videos. YouTube Mix playlists (RD...) are made per viewer and can't be listed, so they give INVALID_INPUT.
YouTube blocks many datacenter IPs. The default proxy is Apify residential. If the proxy you pick can't be set up, the Actor logs a warning and runs without one. A blocked video is retried through a new proxy session, up to maxRetries times. Each request times out after 30 seconds.
A traffic guard protects the run. It counts compressed response bodies, the same number as OUTPUT.responseBodyKBPerVideo. Once 3 videos are done, the run stops if the average passes 225 KB per video. In our tests a video used about 30 to 165 KB on the wire, and the 165 KB one was 6 hours long. So the guard mostly catches large fallback pages from YouTube, but a run made only of very long videos (about 10 hours each) can hit it too.
YouTube's endpoints are undocumented and can change. When they do, you'll see error rows with a code, not a crashed run.
Error codes
errorCode | Meaning | Retried |
|---|---|---|
VIDEO_UNAVAILABLE | Deleted, removed or wrong id | no |
VIDEO_PRIVATE | Private video | no |
AGE_RESTRICTED | Needs a signed-in adult account | no |
LOGIN_REQUIRED | Members-only or other sign-in content | no |
VIDEO_UNPLAYABLE | YouTube won't play it, for example region-blocked or still processing. A past live stream whose recording YouTube did not keep gives this code with the message "This live stream recording is not available." | no |
LIVE_NOT_FINISHED | An upcoming stream, or one YouTube reports as live or not yet processed. Captions come after the stream ends | no |
NO_CAPTIONS | The video has no caption tracks | no |
NO_TRANSCRIPT_IN_LANGUAGE | None of your languages exists (fallback off), or only auto captions exist and you turned them off. The message lists the tracks that do exist | no |
EMPTY_TRANSCRIPT | The caption track exists but is empty | no |
BLOCKED | YouTube rate-limited or bot-checked the IP | yes, new proxy session |
PO_TOKEN_REQUIRED | YouTube sent an empty caption file (retried) or a track that needs a proof-of-origin token (not retried) | empty file only |
NETWORK_ERROR, PARSE_ERROR, CONSENT_FAILED | Temporary problems | yes |
HTTP_ERROR | A request to YouTube got an HTTP error | only for 408, 429, 5xx or no status code |
PLAYLIST_NOT_FOUND, CHANNEL_NOT_FOUND | The playlist or channel doesn't exist or is private | no |
INVALID_INPUT | The line isn't a YouTube video, playlist or channel | no |
FAQ
Do I pay for videos without captions?
No. Any video that ends as an error row is free, including videos from a playlist or channel. You pay $0.0025 only when a transcript is saved.
Why did I get a transcript in another language?
None of your languages existed, so the Actor returned the video's original language and set languageMatched to false. Turn off "Fall back to the video's own language" to get a free NO_TRANSCRIPT_IN_LANGUAGE row instead.
Can I get only captions written by a person?
Yes. Turn off "Use auto-generated captions". Auto captions have isGenerated: true, have no punctuation and can contain mistakes.
Do Shorts work?
Yes, if the Short has captions. Many Shorts don't, and those give NO_CAPTIONS.
What is the tubetext-state store in my account?
A small key-value store with one record, PLAYER_API_KEY. It's the public web API key that YouTube puts in every watch page, not your key. The Actor saves it so a later run doesn't have to download a 340 KB watch page again. You can delete it any time.
Is it legal to scrape YouTube transcripts?
The Actor reads only publicly available captions and never signs in. You're responsible for how you use the data, including YouTube's Terms of Service and the video owner's copyright.
Changelog
See ./CHANGELOG.md.