YouTube transcripts when youtube-transcript-api gets RequestBlocked
youtube-transcript-api works well on a laptop. Its README explains what happens on a server: YouTube has started blocking most IPs known to belong to cloud providers (AWS, Google Cloud, Azure and others), so code deployed there will most likely raise RequestBlocked or IpBlocked, and the same can happen to a home IP after many requests. The README recommends rotating residential proxies as the workaround. Its issues show the error on cloud hosts, in some cases with residential proxies too, for example #303, #421, #504 and #552.
arcadium/youtube-transcripts fetches the captions in the Apify cloud and handles the connection side: it starts on datacenter proxies and moves to residential proxies when YouTube asks to confirm it is not a bot. You need no proxy account; your server makes one call to the Apify API. Each video comes back as one JSON row with the full text, timed segments (text, start, duration, the same names the library uses), the language and whether the captions are auto-generated.
Cost of this example on the Apify Free plan: 1 × $0.00248 = $0.00248 ($0.0018 on the Apify Gold plan and higher), plus $0.001 per GB of memory when the run starts.
What you need
- An Apify account (the Free plan works) and its API token in
APIFY_TOKEN. - Python 3.9 or newer with
apify-client3.x, or any HTTP client. - Video URLs or bare video IDs (the same IDs you pass to youtube-transcript-api).
Step 1: Run it in the Apify Store (no code)
- Open arcadium/youtube-transcripts on the Apify Store and sign in (a free Apify account works).
- Paste video links or bare video IDs into YouTube URLs or video IDs, one per line.
- Set Preferred caption languages in order of preference, for example
enthende(like thelanguagesargument of the library). - Keep Include timed segments on: segments carry the start time and duration of every caption line.
- Optional: set a maximum cost per run in the run options as a hard cap.
- Click Start. Rows appear in the Output tab; export them as JSON, CSV or Excel.
The same input as JSON (paste it in the JSON tab of the input form):
{
"urls": [
"dQw4w9WgXcQ"
],
"languages": [
"en"
],
"includeSegments": true
}
Step 2: Call it over the Apify API
One HTTP call runs the actor and returns its rows. It suits runs up to about five minutes; for longer runs start the run asynchronously and read the dataset when it finishes.
curl -X POST "https://api.apify.com/v2/acts/arcadium~youtube-transcripts/run-sync-get-dataset-items" \
-H "Authorization: Bearer $APIFY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"urls":["dQw4w9WgXcQ"],"languages":["en"],"includeSegments":true}'
Step 3: Run it from Python
With apify-client 3.x, call() returns a Run object: read the dataset id as run.default_dataset_id (older examples use run["defaultDatasetId"], which no longer works).
# pip install "apify-client>=3"
import os
from apify_client import ApifyClient
client = ApifyClient(os.environ["APIFY_TOKEN"])
def fetch_transcripts(video_ids, languages=("en",)):
# Like YouTubeTranscriptApi().fetch(video_id, languages).to_raw_data(), for many videos in one run
run = client.actor("arcadium/youtube-transcripts").call(run_input={
"urls": list(video_ids),
"languages": list(languages),
"includeSegments": True,
})
found = {}
# apify-client 3.x: the run is an object, the dataset id is run.default_dataset_id
for row in client.dataset(run.default_dataset_id).iterate_items():
if row.get("rowType") == "transcript":
found[row["videoId"]] = row["segments"] # [{"text": ..., "start": ..., "duration": ...}]
else:
print("no transcript:", row.get("input"), row.get("code"), row.get("message")) # free row
return found
# Bare IDs or full URLs; put all of them in one call (one start fee)
for video_id, segments in fetch_transcripts(["dQw4w9WgXcQ"]).items():
print(video_id, len(segments), "lines; first:", segments[0]["start"], segments[0]["text"])
Step 4: Run it from JavaScript
// npm install apify-client
import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client.actor('arcadium/youtube-transcripts').call({
"urls": [
"dQw4w9WgXcQ"
],
"languages": [
"en"
],
"includeSegments": true
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
console.log(items);
Step 5: Ask an AI assistant (MCP)
Apify’s hosted MCP server turns the actor into one tool. Add it to Claude, Cursor, VS Code or any MCP client:
{
"mcpServers": {
"apify": {
"url": "https://mcp.apify.com/?tools=arcadium/youtube-transcripts",
"headers": {
"Authorization": "Bearer <APIFY_TOKEN>"
}
}
}
}Then ask, for example:
Use arcadium/youtube-transcripts to get the English transcript of video dQw4w9WgXcQ and list its main points with timestamps.
More clients and the local setup on the MCP page.
Plain HTTP from Python (no client library)
One POST to the run-sync-get-dataset-items endpoint starts the run, waits for it and returns the rows. It suits a few videos per call (runs up to about five minutes); for a channel or a long list use apify-client as above.
# pip install requests
import os
import requests
resp = requests.post(
"https://api.apify.com/v2/acts/arcadium~youtube-transcripts/run-sync-get-dataset-items",
headers={"Authorization": "Bearer " + os.environ["APIFY_TOKEN"]},
json={"urls": ["dQw4w9WgXcQ"], "languages": ["en"], "includeSegments": True},
timeout=330,
)
resp.raise_for_status()
for row in resp.json():
if row.get("rowType") == "transcript":
print(row["videoId"], row["language"], row["isAutoGenerated"], row["transcript"][:200])
else:
print("no transcript:", row.get("code"), row.get("message"))
From youtube-transcript-api calls to actor input
YouTubeTranscriptApi().fetch(video_id, languages=["de", "en"]) becomes {"urls": [video_id], "languages": ["de", "en"]}, and many IDs go into one run. The snippets are segments with the same text, start and duration; TextFormatter output is transcript; SRTFormatter and WebVTTFormatter are subtitleFormat: "srt" or "vtt". is_generated is isAutoGenerated, and the list from .list(video_id) is availableLanguages on every row. Instead of exceptions you get free rows with a code: no_match where the library raises NoTranscriptFound or TranscriptsDisabled, not_found for private or removed videos, unavailable for age-restricted or members-only videos, and blocked when YouTube still refuses after all retries.
Whole channels and playlists
Put a channel URL (https://www.youtube.com/@kurzgesagt) or playlist URL in urls with Max videos per channel or playlist (maxVideosPerSource) and an optional publishedAfter date. Channels are read newest first.
Output fields
Every row has a rowType; filter on it first. Failed inputs come back as free rows with a code and message. The fields this tutorial uses, from the actor’s dataset schema:
| Field | What it holds |
|---|---|
rowType | Kind of row. Filter on this first. Values: 'transcript': one video's transcript (charged). 'error': a video or URL gave no transcript; see code and message (free). |
videoId | YouTube video id, 11 characters, e.g. dQw4w9WgXcQ. |
title | Video title. |
language | Language code of the returned transcript, e.g. en, de-DE, pt-BR. |
isAutoGenerated | True when the transcript is YouTube's speech recognition (no punctuation, may misspell names); false when the creator uploaded it. |
languageMatched | False when none of the requested languages existed and the original-language track was returned instead. |
availableLanguages | Every caption track of the video: code, name, isAutoGenerated. Use it to request another language. |
transcript | The whole transcript as one text: caption lines joined with spaces. Emails and phone numbers are removed. |
segments | Caption lines in order: start (seconds from the start), duration (seconds), text. Present when includeSegments is on. |
code | Error rows: not_found (private, removed, unknown channel), no_match (no captions, or none of the requested type), unavailable (age-restricted, members-only, upcoming, temporary error), blocked (rate limited, retry later), invalid_input. |
All fields: YouTube Transcript & Subtitles Scraper guide.
Cost per run
Pay per event: you pay for the rows delivered, at the actor’s live Apify price on 2026-11-02. Error, coverage and summary rows are free.
| What | Rows | Price per row (Free plan) | Subtotal |
|---|---|---|---|
| Transcript: 1 video with captions | 1 | $0.00248 | $0.00248 |
Cost of this example on the Apify Free plan: 1 × $0.00248 = $0.00248 ($0.0018 on the Apify Gold plan and higher), plus $0.001 per GB of memory when the run starts. Higher Apify plans pay less per row. The Store page always has the current price.
Limitations
- This replaces the network part of youtube-transcript-api, not the code around it: segments keep the same field names, so the step that reads them usually stays as it is.
- Translation works differently: YouTube currently refuses its machine-translated tracks to anonymous clients, so
translateToreturns a native track in that language only when the video has one. - Only captions YouTube already has (uploaded or auto-generated). Videos without any captions give a free
no_matchrow; the actor does not download or transcribe audio. - Auto-generated transcripts have no punctuation and can misspell names.
- Translation: YouTube currently refuses its machine-translated tracks to anonymous clients. With
translateTo, a video that has a native track in that language returns it (translationStatus: "original", common for big channels and TED talks, which carry dozens of languages); otherwise you get the original language withtranslationStatus: "unavailable". - Age-restricted and members-only videos need a signed-in account and are skipped (free
unavailablerow). Premieres and upcoming streams have no captions yet. - Live streams: captions of a running live stream are not available; recorded streams work once YouTube has processed them.
Links
- YouTube Transcript & Subtitles Scraper: fields, price table and sample output
- How to use YouTube Transcript & Subtitles Scraper: every input field, n8n, LangChain
- arcadium/youtube-transcripts on the Apify Store
- All tutorials