YouTube Transcript API & Scraper — YouTube Subtitles, Speech AI
Pricing
from $1.20 / 1,000 transcripts
YouTube Transcript API & Scraper — YouTube Subtitles, Speech AI
YouTube videos, Shorts and live VODs to text: captions first, speech-to-text when a video has none (on by default; $0.008 per started minute, only when it runs). JSON, plain text, SRT or VTT. From $1.20 per 1,000 transcripts, no API key, no cookies. Never charged for a video we can't transcribe.
Pricing
from $1.20 / 1,000 transcripts
Rating
0.0
(0)
Developer
Steadyfetch Team
Maintained by CommunityActor stats
0
Bookmarked
13
Total users
10
Monthly active users
3 hours ago
Last modified
Share
Never charged for a video we can't transcribe — or for one you already have. Captions first, built-in speech-to-text when a video has none, so caption-less videos still return real text. Videos, Shorts and live VODs, as JSON, plain text, SRT or VTT.
Using an AI agent? Pin this actor in Apify's MCP server with one link: https://mcp.apify.com?tools=steadyfetch/youtube-transcript-scraper
- Actor id:
steadyfetch/youtube-transcript-scraper - Input:
{ "videoUrls": ["https://www.youtube.com/watch?v=jNQXAC9IVRw"] }— the one field you have to set. - Cap the bill: set
maxTotalChargeUsdon the run (a run option, not Actor input), e.g.0.50— the run stops when it reaches it.
Just want to see it work? Click Start with nothing set and the run transcribes one short real video (youtube.com/watch?v=jNQXAC9IVRw, "Me at the zoo"), charged like any run at your plan's per-transcript price. Set only settings — a different Output format, a caption language, speech-to-text off, a limit — and leave Video URLs or IDs empty, and that same sample runs under your settings, charged like any run. Put your own links or IDs in Video URLs or IDs for your own run.

Issues answered in a couple of hours. Unofficial: steadyfetch is not affiliated with, endorsed by, or sponsored by YouTube or Google. "YouTube" is a trademark of Google LLC, used here only to say what this actor reads.
Every row carries charged and statusReason, so you can reconcile the invoice from the
dataset itself without opening the console. Only rows with charged: true were billed.
If your Maximum cost per run is reached while a video's transcript is already in hand,
that transcript still ships in full with charged: false and a statusReason naming the
cap — your cap is never exceeded, and work already done is never thrown away.
What a row looks like

Real output, unedited apart from trimming the segment list:
{"status": "ok","charged": true,"statusReason": null,"videoId": "jNQXAC9IVRw","url": "https://www.youtube.com/watch?v=jNQXAC9IVRw","inputUrl": "https://www.youtube.com/watch?v=jNQXAC9IVRw","title": "Me at the zoo","channelName": "jawed","channelId": "UC4QobU6STFB0P71PMvOGN5A","durationSeconds": 19,"viewCount": 406463143,"isLiveContent": false,"language": "en","source": "captions","captionKind": "manual","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, "end": 3.36, "text": "All right, so here we are, in front of the elephants" },{ "start": 5.318, "end": 7.974, "text": "the cool thing about these guys is that they have really..." }],"srt": null,"vtt": null,"chargeEvents": { "transcript": 1, "speechMinutes": 0 }}
| field | notes |
|---|---|
text · segments[{start,end,text}] | the transcript, and the same text with timestamps in seconds |
srt · vtt | filled only when you ask for that format; otherwise null |
source | captions or speech_ai — how this transcript was produced |
captionKind | manual (uploaded by the channel) or auto (YouTube's own auto-captions); null on the speech route |
language | ISO code, same vocabulary on both routes |
title · channelName · channelId · durationSeconds · viewCount · isLiveContent | as YouTube reports them |
url · inputUrl · sourceIndex | the canonical watch URL, the exact link you passed, and its position in your input |
charged · statusReason · chargeEvents | the reconciliation trio |
repeat · firstSeenAt · firstSeenRunId | true when this account already had this transcript: it was handed back from the run named here, and nothing was charged for it |
retryable | on a row that did not deliver: true means YouTube refused us this time and the same input is worth running again, false means the answer will not change. It always agrees with the note in statusReason, so code can branch on the column instead of parsing the sentence |
Sample dataset — six real transcripts from one run, no sign-in: open the JSON
Agent / API paste-block
Actor: steadyfetch/youtube-transcript-scraperRequired: videoUrls (array of YouTube video links or 11-character video IDs)Optional: language (string, e.g. "en", "es", "pt-BR" — preferred caption track)format (json | text | srt | vtt, default json)enableSpeechFallback (boolean, default true — off = captions only, no speech minutes)maxSpeechMinutes (integer, default 60 — hard cap for the whole run, up to 100,000)maxItems (integer, default 100 — hard cap on videos, up to 5,000 per run)Over-ask: never refused — ask past a cap and the run still starts, continues at theceiling above, and one uncharged row says what you asked for and what bound itCharges: transcript once per delivered transcript, captions or speech-to-textspeech_minute per started minute, only when speech-to-text actually ranBuild spec: https://apify.com/steadyfetch/youtube-transcript-scraper/apiToken: https://console.apify.com/settings/integrationsMCP: pinned in one link — see the top of this page
curl -X POST "https://api.apify.com/v2/acts/steadyfetch~youtube-transcript-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \-H 'Content-Type: application/json' \-d '{"videoUrls":["https://www.youtube.com/watch?v=jNQXAC9IVRw","https://youtu.be/dQw4w9WgXcQ"],"format":"srt"}'
Calling from an agent or MCP client: every optional field accepts an explicit null and reads it
as "use the default", so a template that renders one body per run and leaves the unset fields
null still runs — including a body where every optional field is null. videoUrls is the one
required field and needs a real value. If you send an output format we do not recognise, the run
transcribes nothing, charges nothing, and returns one row naming the formats we do support.
Sending the same list under a sibling actor's field name works too: urls and channels are read
as this actor's video list, and one uncharged note row tells you the field is called videoUrls
here. A channel link inside either field still gets the row that points you at
steadyfetch/youtube-channel-transcripts, which is the actor that walks a whole channel.
MCP: one link pins this actor in Claude, Cursor, or any MCP client — the link is at the top of this page — or ask Apify's MCP server for "youtube transcript".
Links you can paste
Watch links, youtu.be links, /shorts/, /live/, /embed/, m.youtube.com,
music.youtube.com, youtube-nocookie.com, and bare 11-character video IDs. Casing in the
link does not matter — chat apps and link shorteners rewrite it constantly — but the video
ID itself is case-sensitive, because YouTube treats it that way.
A channel link gets an uncharged row pointing you at steadyfetch/youtube-channel-transcripts, which does whole channels in one run. A playlist link gets an uncharged row too — paste the individual video links here instead.
What you are charged for
Pricing: from $1.20/1,000 transcripts. Two events, and nothing else:
- Transcript — once per video that returns real text, from captions or from speech-to-text.
- Speech-to-text minute — per started minute, and only when a video had no usable
captions so speech-to-text had to run. A captioned video never triggers it, and a
caption-less video too long for the speech route (see
audio_too_long_for_speechbelow) is reported uncharged instead of being charged for a partial transcript.
What can fail, and what it costs you: nothing. The two most common non-deliveries are
no_speech (the audio is music or silence, so there is no transcript to sell) and
blocked_retry (YouTube challenged or throttled the fetch, or was still processing the video).
Both come back as a row with charged: false naming the reason — as do no_captions,
private_or_members_only, removed_or_unavailable, age_restricted, region_blocked and
live_no_transcript_yet. You pay for delivered transcripts and nothing else.
Switch "Use speech-to-text when a video has no captions" off and you will never be
charged a speech-to-text minute; caption-less videos come back as uncharged rows instead.
Max speech-to-text minutes and Max videos are hard stops, not suggestions: the run
finishes successfully and each skipped row names the limit that stopped it.
You are never charged twice for the same video
Re-run the same list, poll a video every week, paste a link you already had months ago — a transcript this account has already been charged for is handed back from the run that produced it, with nothing charged for it a second time. Nothing is fetched and no speech-to-text minute is spent: the check happens before any of that.
Those rows carry repeat: true, charged: false, firstSeenAt (when you first got it)
and firstSeenRunId (which run), and statusReason says the same thing in words. They do
not count against Max videos either — your cap buys new videos.
The memory is a key-value store in your own Apify account, so a run started with a scoped API token in restricted-access mode needs key-value store Read, Write and Create permission (or Actor runs set to Full access) — set under Settings → API & Integrations (Write is what lets the run remember what it delivered; without it every run pays again). Without it the run says so on its status line and in an uncharged first row, and it cannot skip what you already have. A run started from the console, or with a full-access token, is unaffected.
The memory lives in your own account, in a key-value store called
yt-transcripts-account on your Storage tab. Delete it to start over and be charged
again. Transcripts drop out of it after 90 days on their own. If it cannot be read on a
given run, the run still delivers — it charges as it always did and the status line says
the check was unavailable, so you know that run could have billed a repeat. When the reason
is the token scope above, the line names the permission to grant instead.
Want the same thing for a whole channel every week — new uploads transcribed, the rest handed back uncharged? That is steadyfetch/youtube-channel-transcripts, the same engine with a channel as its input.
Run it on a schedule — what a second run of the same list is worth
A published video's transcript does not change. Captions that exist today read the same
next week, and speech-to-text on the same audio returns the same words — so re-running a link
you already have brings back nothing new. Two things do change, and both come back uncharged:
a live stream has no transcript until it ends (live_no_transcript_yet), and blocked_retry
means YouTube throttled the fetch or was still processing that minute. Those are the rows worth
running again.
A second run of the same list is not charged — for 90 days. The account memory above is on
by default and there is no switch that turns it off: every video your account already has comes
back from the run that transcribed it, repeat: true and charged: false, with nothing fetched
and no speech-to-text minute spent. One thing can stop it: a scheduled run started with a scoped
API token that has no key-value store permission cannot open the memory, and then repeats are
transcribed and charged again — give the token key-value store Read, Write and Create, or set
Actor runs to Full access. It is per account, not per run or per Task, so a
colleague's Task on the same account sees it too. The edge worth knowing is the far end of that
window: entries drop out of yt-transcripts-account after 90 days, so a schedule pointed at a
fixed list runs uncharged for three months and then transcribes and charges those same videos
again. If your list is settled, run it once rather than on a timer.
The schedule that pays is one whose list grows. Save your input as a Task, put the Task
on an Apify Schedule, and add an integration or a webhook on run succeeded — a Google
Sheet, Slack, n8n, Make, or your own endpoint. Have whatever finds your videos write a fresh
videoUrls list into the Task each run — start it through the API, n8n or Make with the new
links — and every run transcribes only what your account has not had before; the links that
repeat are handed back, not charged, so the list can carry your whole history without the bill
growing with it. Watching one channel's uploads is the sibling actor above, which lists the
channel for you.
Turn on newItemsOnly and a scheduled run delivers only the new videos. By default a
repeat still arrives as a row — uncharged, repeat: true — which is right for an archive and
wrong for a webhook: your Sheet gets the whole list again every week and whatever reads it has
to de-duplicate. With New items only (newItemsOnly: true) a video your account already
has is left out of the dataset entirely: nothing fetched, nothing charged, no row, and the run's
status line says how many were skipped — 12 already yours, skipped (uncharged). A run whose
whole list was already yours succeeds with that count and an empty dataset, which is exactly
what an integration wants to see. Cost is unchanged either way: a repeat is never charged. The
one case it refuses is a run that cannot read the account memory at all — a scoped token with no
key-value store permission — because it cannot keep the promise, and charging you for the whole
back catalogue you asked it to skip is the wrong way to find out. Nothing is fetched or charged
then, and the rows say which permission to grant.
Already running another scraper? Chain it — no script. Put a finished run's dataset ID in
Dataset ID (chain a scraper run) (datasetId) and every row is searched for YouTube links,
however deeply your scraper nested them and whatever it called the field. In the Apify console,
Connect actor fills that field in for you, so a search-or-feed scraper plus this one becomes
a single two-step Task you can put on a Schedule. Rows that hold no YouTube link are counted and
said, never charged; a link already in Video URLs or IDs is one video and one charge; and a
bare 11-character string in someone else's row is never read as a video ID, so a dataset full of
post IDs and cursors cannot cost you anything. datasetItems takes the rows pasted directly if
you would rather not chain by ID. Both doors run through the same rules as a pasted link, so a
channel link inside a chained row still comes back with the pointer to the channel actor.
repeat is the field to read. repeat: false with charged: true is a transcript new to
your account; repeat: true means it was handed back from an earlier run, and firstSeenRunId
names that run. The status line carries the count for the whole run — 3 you already had —
handed back, not charged.
When a video is not charged
status | what happened | temporary? |
|---|---|---|
no_speech | the audio is music or silence — there is no transcript to sell | no |
no_audio_stream | the video exposes no audio track at all | no |
no_captions | no captions, and you switched speech-to-text off for this run | no |
asr_unavailable | our speech-to-text service refused this actor's access mid-run — that is on us, not you. Captioned videos still delivered; caption-less ones came back uncharged | yes — try again later |
audio_too_long_for_speech | no captions, and the video is too long for the speech-to-text route — YouTube does not release enough of its audio for a complete transcript, and we never sell a partial one as whole. Captioned videos are unaffected at any length | no |
private_or_members_only | private or members-only | no |
removed_or_unavailable | YouTube says the video is gone; its own words are in the row | no |
age_restricted | YouTube requires a signed-in, age-verified account | no |
region_blocked | the uploader has not published it in the country we fetched from | no |
live_no_transcript_yet | a live stream — no transcript exists until it ends | re-run after it ends |
blocked_retry | YouTube challenged, throttled or was still processing | yes — re-run |
skipped_too_large · skipped_budget · skipped_deadline · skipped_speech_cap | a limit stopped it before the video was fetched, and the row names which one: the file-size guard, your maximum cost per run, the run timeout, or the speech-minute cap (a transcript already in hand when the cost cap is reached is delivered instead, uncharged) | yes |
input_error | the link was not a YouTube video link | fix and re-run |
sample_note | you set settings but no videos, so the sample ran under them — the row names what you set, and is not charged | — |
A temporary problem is never reported as a permanent one. Bare video IDs are the one thing
we cannot sanity-check: a typo in an 11-character ID is indistinguishable from a real ID
that has been deleted, so it comes back as removed_or_unavailable, uncharged.
This actor may fail when the platform changes things — failed items are never charged.
FAQ
How do I get a YouTube transcript without an API key? Paste the video links and run it. There is no YouTube API key, no cookies and no sign-in.
Can I download YouTube subtitles as SRT?
Set format to srt (or vtt) and each row carries a ready-to-save subtitle string.
What if a video has no captions?
Speech-to-text runs on the audio and you get real text, marked source: "speech_ai". If
the audio has no speech at all, the row comes back no_speech and uncharged. The speech
route works on short videos only: past a few minutes YouTube stops releasing the audio to
anything but its own player, so a long caption-less video comes back
audio_too_long_for_speech and uncharged rather than half-transcribed. Videos that have
captions — the large majority, including nearly every spoken upload — are unaffected at
any length.
Can I pick the caption language?
Set language. If that language is not published for the video, the default track is used
and the row's language field tells you what you actually got.
Can I transcribe a whole channel?
Use steadyfetch/youtube-channel-transcripts — paste a channel URL, @handle or channel ID
and it returns every video's transcript in one run. Playlists are not supported by either actor
yet; for a playlist, paste its video links here.
Can I use this through an MCP server? Yes. It is a standard Apify actor, so any MCP client that can call Apify actors can call it.
Why does a run cost more than the transcripts?
Apify bills platform usage (compute and proxy) for what a run actually consumes, separately
from these events. Maximum cost per run is the ceiling that covers both.
Steadyfetch YouTube suite
Same transcript engine, different way in. All-inclusive pay per event, no start fee, charged only on delivery.
| What you paste | Actor |
|---|---|
| Video URLs or IDs | this actor |
A channel URL, @handle or channel ID | YouTube Channel Videos Transcript Scraper — Bulk Video to Text |
The rest of the steadyfetch shelf — same contract everywhere: all-inclusive pay per event, no start fee, charged only on delivery.
| Family | Actors |
|---|---|
| Ad creative intelligence | Facebook · Google Ads video · TikTok · LinkedIn · Google Ads text & OCR |
| Trends & keywords | Google Trends · Trends Now · Breakout keywords · Autocomplete keywords · Keyword volume & CPC · Social trends |
| YouTube transcripts | YouTube videos · YouTube channels |
| Reel transcripts · Profile posts | |
| Jobs | Indeed · Career sites by domain · Glassdoor · Multi-board · Google Jobs |
| Amazon | Products · Search · Bestsellers · Sellers |
| Any media file | Speech to Text · any link or file |
Feedback & support
Found an issue? Open it on the Issues tab — we usually reply within a couple of hours, always within a day.
If it earned its keep, a rating helps other buyers find it, and saving the actor keeps it one click away.