# YouTube Transcript Scraper · Channels & Playlists · $5/1K (`gangling_polygon/youtube-transcript-scraper`) Actor

No cookies, no login. Turn YouTube video, channel and playlist URLs into clean, timestamped transcripts (JSON, text, SRT, VTT) with video metadata. Pay per delivered transcript; error rows are free.

- **URL**: https://apify.com/gangling_polygon/youtube-transcript-scraper.md
- **Developed by:** [Michal Patyjewicz](https://apify.com/gangling_polygon) (community)
- **Security:** Limited permissions
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $4.00 / 1,000 transcript delivereds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

### What does YouTube Transcript Scraper do?

YouTube Transcript Scraper turns **YouTube video, channel and playlist URLs into clean, timestamped transcripts** with video metadata, in bulk. It is a **YouTube transcript API alternative** for people who need captions as data: JSON segments, plain text, SRT or WebVTT, plus title, channel, duration, views, likes, publish date and keywords, in one row per video.

It works with **no cookies and no login**. It reads the captions YouTube already publishes for a video (creator-uploaded or auto-generated) and does not download audio or video. You only pay for transcripts that are actually delivered: videos without captions, private or removed videos, live streams and invalid links come back as **free error rows** with a clear code.

### Why use YouTube Transcript Scraper?

- **Batch everything.** Paste hundreds of URLs, or give it a channel or a playlist and let it expand them, newest first, with a per-source limit and an optional date window.
- **Every URL form.** `watch?v=`, `youtu.be`, Shorts, embed, mobile, `music.youtube.com`, live links, `@handles`, channel IDs, playlist IDs. Duplicates across sources are removed.
- **Language control.** A priority list of languages, a choice between creator-uploaded and auto-generated captions, and the full list of available tracks on every row so you can retry with a better choice.
- **Formats for people and for programs.** Timestamped JSON segments for your own pipelines, plain text for search and reading, SRT and VTT for subtitle workflows.
- **Metadata at no extra cost.** Title, channel, duration, views, likes, publish date, description, thumbnail, keywords and category come with every row.
- **Honest pricing.** One price per delivered transcript, no charge for error rows, a stated free allowance, and a run summary with counts and latency.
- **Apify platform extras.** Schedule it, call it from the API, chain it into other Actors, export to JSON, CSV, Excel or HTML, and get monitoring and residential proxies without any setup.

### What data can YouTube Transcript Scraper extract?

| Field | Type | Description |
| --- | --- | --- |
| `status` | string | `ok` (transcript delivered, charged), `limited` (free-plan cap reached, metadata only, free), `error` (free) or, with `listOnly`, `languages` (free). |
| `videoId`, `url` | string | YouTube video ID and canonical watch URL. |
| `sourceUrl` | string or null | The channel or playlist the video came from. |
| `title`, `channelId`, `channelName`, `channelUrl` | string | Video and channel identity. |
| `durationSec`, `viewCount`, `likeCount`, `publishedAt`, `category` | number / string | Video statistics. `null` when YouTube hides a value. |
| `description`, `thumbnailUrl`, `keywords` | string / array | Description, best thumbnail, creator tags. |
| `language`, `languageName`, `isGenerated` | string / boolean | Which caption track was delivered and whether it is auto-generated. |
| `availableLanguages` | array | Every caption track YouTube lists for the video (`code`, `isGenerated`). |
| `segments` | array | `{ start, dur, text }` in seconds, when `json` is among the output formats. Each item also gets an `end` (seconds, or `null`) when `includeSegmentEnd` is on. |
| `text`, `srt`, `vtt` | string | Plain transcript and subtitle files, when requested. |
| `timestampedText` | string | Plain text with a `[mm:ss]` or `[hh:mm:ss]` timestamp on every line, when `timestampedText` is among the output formats. |
| `cleanText` | string | Transcript without sound tags and music symbols, split into paragraphs by a blank line, when `cleanText` is among the output formats. |
| `paragraphs` | array | The same paragraphs as `{ start, end, text }` (seconds), when requested. |
| `chunks` | array | The cleaned transcript in pieces of `chunkSize` words: `{ index, start, end, wordCount, text }`, when requested. |
| `channelHandle`, `subscriberCount` | string / number | Only with `includeChannelStats`. The subscriber count is rounded the way YouTube shows it (`4.55M` becomes `4550000`); `null` when not available. See [Channel handle and subscribers](#channel-handle-and-subscribers). |
| `extrasError` | string | Only when the extra request for those fields failed: its error code. The transcript is still delivered. |
| `words`, `wordTimestampsAvailable` | array / boolean | With `wordTimestamps` on: every word as `{ text, start, end }` in seconds, and whether the captions carried word times. |
| `segmentCount`, `wordCount` | number | Size of the transcript. |
| `isLive`, `isShort`, `isTranslated` | boolean | Flags: stream in progress, Shorts video, translated track (translation is not available yet, so always `false`). |
| `publishedAtApprox` | boolean | Present and `true` when `publishedAt` was estimated from a channel listing's relative date. |
| `charged`, `chargeEvent` | boolean / string | Set just before the row is charged, and the event it is charged under. `true` can still mean the row was delivered free if the charge call failed or the run's cost limit was hit at that moment (rare); see `SUMMARY.charged` and `SUMMARY.chargeFlagMismatches` below. |
| `limitExceeded`, `limitReason`, `message` | boolean / string | On `limited` rows: the free-plan daily cap was reached; `message` explains it. |
| `errorCode`, `errorMessage`, `retryable` | string / boolean | Why a video failed and whether a later run may succeed. |

### How to scrape YouTube transcripts

1. Open the Actor and paste one or more YouTube URLs into **YouTube URLs**, or add channel handles under **Channels** or playlist links under **Playlists**.
2. Optionally set **Languages** (for example `en, pl`), the **Caption type** and the **Output formats** you want.
3. Click **Start**. Each video becomes one dataset row as soon as it is done.
4. Download the dataset as JSON, CSV, Excel or HTML, or read it through the API.

For channels and playlists, **Max videos per source** (default 50) and the **Published after / before** dates control how far back the Actor goes.

### How much does it cost to scrape YouTube transcripts?

The Actor uses **pay-per-event pricing** with a single event, `transcript`, charged once per successfully delivered transcript. Metadata is included. **Error rows and free-plan "limited" rows are never charged.** Apify's standard Actor-start event ($0.00005 per start) applies.

| Plan | Price per transcript | Per 1,000 transcripts |
| --- | --- | --- |
| Free | $0.005 | $5.00 |
| Bronze | $0.005 | $5.00 |
| Silver | $0.0045 | $4.50 |
| Gold | $0.004 | $4.00 |
| Platinum (enterprise) | $0.004 | $4.00 |
| Diamond (enterprise) | $0.004 | $4.00 |

Worked examples on the Free or Bronze plan:

- 10 videos → $0.05
- 1,000 videos → $5.00
- 100,000 videos → $500

#### Free plan allowance

Users on the Apify free plan get **25 transcripts per day** (UTC). Beyond that, rows keep coming with full metadata and `status: "limited"`, so a run never fails because of the cap. The allowance resets at 00:00 UTC. Paid plans have no cap.

You can also set a **Max cost per run** in the run options; the Actor stops cleanly when it is reached.

#### Find out which languages a video has

Set `listOnly` to `true` and every video gives one row with `status: "languages"`: its title, channel, duration and `availableLanguages` (`code`, `isGenerated`), and nothing else. No transcript is fetched and nothing is charged (`charged: false`). A video with no captions at all gives an empty `availableLanguages`, not an error. Private, removed and similar videos give the usual free error rows. `languages`, `captionType` and `outputFormats` are ignored in this mode. For channels and playlists the date filters use the dates shown in the listing (no extra request), so they can be a day or so off for recent videos.

Because it is free, it is capped: at most 500 videos per run, and a daily number of lookups per Apify account (100 on the free plan, 2,000 on paid plans, counted per UTC day). When the daily number is used up the run stops and says so in its status message. Videos already being looked up at that moment finish, so a run can go a few videos over. Use it to check a list or a channel first, then run again with the languages you found.

### Input

See the **Input** tab for all options. The important ones:

| Field | Default | Notes |
| --- | --- | --- |
| `startUrls` | – | Any YouTube URL: video, Shorts, youtu.be, embed, mobile, channel or playlist. |
| `videoIds` | – | Bare 11-character IDs, handy for API calls. |
| `channelUrls`, `playlistUrls` | – | Expanded to videos, newest first. |
| `maxVideosPerSource` | 50 | 1 to 1,000 per channel or playlist. |
| `searchQueries` | – | Keywords (up to 100). Each is expanded to YouTube's matching videos in relevance order. Regular videos only: no Shorts, live or upcoming streams. Rows carry the `youtube.com/results?search_query=…` URL as `sourceUrl`. |
| `maxVideosPerSearch` | 50 | 1 to 300 per query (YouTube lists about 300 results per query at most). |
| `includeShorts` | true | Also take the channel's Shorts tab. |
| `publishedAfter`, `publishedBefore` | – | `YYYY-MM-DD` window for expanded sources. |
| `languages` | `["en"]` | Priority list of language codes. `en` also matches `en-US`; `*` accepts anything. |
| `anyLanguage` | false | When none of your `languages` exists for a video, take any other caption track instead of `LANGUAGE_NOT_FOUND`. Your languages still come first. |
| `listOnly` | false | Free language discovery, see below. |
| `captionType` | `any` | `any` prefers creator captions and falls back to auto-generated; `manual`; `auto`. |
| `outputFormats` | `["json","text"]` | Any of `json`, `text`, `srt`, `vtt`. Also `timestampedText` (a `[mm:ss]` stamp on every line), `cleanText`, `paragraphs` and `chunks` (see [Clean text and chunks](#clean-text-and-chunks)). |
| `timestampStyle` | `auto` | Only used with `timestampedText`: `auto` (`[mm:ss]`, switching to `[hh:mm:ss]` at an hour), `mmss` or `hhmmss`. |
| `chunkSize`, `chunkOverlap` | 300, 30 | Only used with `chunks`: words per chunk (60 to 5000) and words shared with the next chunk (0 up to half of `chunkSize`). |
| `includeChannelStats` | false | Add `channelHandle` and `subscriberCount`. One extra request per channel. |
| `includeSegmentEnd` | false | Add an end time (seconds) to every item in `segments`. |
| `wordTimestamps` | false | Add a `words` field with the start and end of every word. Auto-generated captions only. |
| `includeMetadata` | true | Turn off for smaller rows. |
| `includeChapters` | false | Add `chapters` (title, start, end in seconds) and `chaptersAuto` to every transcript row. |
| `storeFiles` | false | Also save the text, `srt`, `vtt` and `timestampedText` outputs as files in the run's key-value store, with links in each row's `files`. |
| `maxConcurrency` | 5 | 1 to 20. |
| `maxItems` | – | Stop after this many videos attempted (ok, limited and error rows all count). |
| `onlyNewVideos` | `false` | For channels and playlists, skip videos an earlier run already delivered (see [Only new videos](#only-new-videos)). |
| `stateStoreName` | `youtube-transcript-delivered` | Named key-value store that remembers delivered videos. |
| `resetDeliveredVideos` | `false` | Forget the history of the channels and playlists in this run. |
| `proxyConfiguration` | Apify residential | YouTube blocks datacenter IPs for caption requests; keep residential unless you know better. |

### Output

You can download the dataset in various formats such as JSON, HTML, CSV or Excel. A successful row (trimmed):

```json
{
  "status": "ok",
  "videoId": "dQw4w9WgXcQ",
  "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
  "sourceUrl": null,
  "title": "Rick Astley - Never Gonna Give You Up (Official Video) (4K Remaster)",
  "channelId": "UCuAXFkgsw1L7xaCfnd5JJOw",
  "channelName": "Rick Astley",
  "durationSec": 213,
  "publishedAt": "2009-10-25T06:57:33.000Z",
  "viewCount": 1831092906,
  "likeCount": 19492133,
  "category": "Music",
  "language": "en",
  "languageName": "English",
  "isGenerated": false,
  "availableLanguages": [{ "code": "en", "isGenerated": false }, { "code": "en", "isGenerated": true }, { "code": "de-DE", "isGenerated": false }],
  "segments": [{ "start": 18.64, "dur": 3.12, "text": "We're no strangers to love" }],
  "text": "We're no strangers to love You know the rules and so do I ...",
  "segmentCount": 61,
  "wordCount": 380,
  "charged": true,
  "chargeEvent": "transcript",
  "fetchedAt": "2026-10-27T21:40:12.000Z"
}
```

An error row (never charged):

```json
{
  "status": "error",
  "videoId": "jfKfPfyJRdk",
  "url": "https://www.youtube.com/watch?v=jfKfPfyJRdk",
  "errorCode": "LIVE_VIDEO",
  "errorMessage": "This live stream recording is not available.",
  "retryable": true,
  "charged": false,
  "fetchedAt": "2026-10-27T21:40:12.000Z"
}
```

The run's key-value store also holds a `SUMMARY` record with counts per status and error code, charged transcripts, rows whose `charged` flag did not match the real outcome (`chargeFlagMismatches`), videos skipped by the date window, latency percentiles and free-tier usage.

#### Timestamps

Every segment already carries `start` and `dur` in seconds, so you can build your own timestamps from the `json` format. `text` stays plain, with no timestamps at all.

For a readable transcript, add `timestampedText` to `outputFormats`. It gives one line per segment, stamped and ready to paste or skim:

```
[00:01] [♪♪♪]
[00:18] ♪ We're no strangers to love ♪
[00:22] ♪ You know the rules and so do I ♪
```

Stamps use `[mm:ss]` by default (`timestampStyle: auto`), switching to `[hh:mm:ss]` once the video reaches an hour; set `timestampStyle` to `mmss` or `hhmmss` to fix the width yourself.

Turn on `includeSegmentEnd` to add an `end` (seconds) to every item in `segments`, alongside `start` and `dur`. Auto-generated captions often overlap, so `end` can land after the next segment's `start`; that is YouTube's own timing, left as given.

#### Clean text and chunks

Captions often carry sound tags (`[Music]`, `(Applause)`), music symbols and `>>` speaker marks, and they come as short lines. Three optional formats tidy that up. None is on by default, so default rows are unchanged.

| Format | What you get |
| --- | --- |
| `cleanText` | One string. Sound tags, `♪`/`♫` symbols, `>>` marks and simple HTML tags are removed, and the text is split into paragraphs separated by a blank line. |
| `paragraphs` | The same paragraphs as `{ start, end, text }`, with times in seconds. |
| `chunks` | The cleaned text cut into pieces of `chunkSize` words (default 300) that share `chunkOverlap` words with the next one (default 30): `{ index, start, end, wordCount, text }`. `start` and `end` are the times of the first and last word's caption line. Handy for search, summaries and anything with a size limit. |

How it cleans:

- A bracketed group is removed only when every word in it is a known sound word, such as `[Music]`, `(Laughter)` or `[Applause and cheering]`. The list covers English plus common Spanish, German, French, Portuguese, Italian, Dutch and Polish words (music, applause, laughter, coughs, sighs and the like). Anything else stays: `[frankincense]` is a word the speaker said, and `(Ooh, give you up)` is a lyric. Tags in other languages that are not on the list stay too, and so does YouTube's `[ __ ]` mask for a censored word.
- A new paragraph starts at a speaker change, after a pause of 2 seconds or more, after a sentence end once a paragraph reaches 70 words, and at the next caption line once it reaches 140 words (auto-generated captions have little punctuation).
- Words are counted by spaces, so for languages written without spaces (such as Japanese or Chinese) chunks are not useful and paragraphs only break at pauses and speaker changes, not at a length.
- A bracketed group longer than 60 characters is always kept.

Rows get bigger: `cleanText` is about the size of `text`, and `chunks` repeats the overlap words, so it adds about `chunkOverlap / chunkSize` on top (10 percent with the defaults), plus a few bytes per chunk. `wordCount` and `segmentCount` still describe the original transcript.

#### Channel handle and subscribers

`channelHandle` and `subscriberCount` are not in the data the transcript comes from, so they need one more request to YouTube. The option is off by default, so default runs make no extra request.

- `includeChannelStats` adds `channelHandle` and `subscriberCount`. It makes one request per channel and shares the answer between that channel's videos in the run. With `includeMetadata` off the channel is not known beforehand, so it makes one request per video instead.

Each extra request is about 140 to 300 KB through the proxy, against about 27 KB for the average transcript. Each channel costs one such request per run, so a large channel adds little; with `includeMetadata` off it costs one per video.

YouTube rounds large numbers on the page (`4.55M` subscribers), and the value here is that rounded number, not an exact count. `subscriberCount` is `null` when the channel hides it, `channelHandle` is `null` for a channel without a handle. If the extra request fails, the row is still delivered with `null` in those fields and an `extrasError` code; the transcript is never lost to it.

#### Chapters

Set `includeChapters` to `true` to add the video's chapters to every transcript row:

```json
"chapters": [
  { "title": "Intro", "start": 0, "end": 5 },
  { "title": "The cool thing", "start": 5, "end": 17 },
  { "title": "End", "start": 17, "end": 19 }
],
"chaptersAuto": false
```

- `start` and `end` are seconds. `end` is the next chapter's start; the last chapter ends at the video's duration (`null` if that is unknown). A video without chapters gives `chapters: []`.
- The chapters are read from the timestamp lines of the video description, such as `0:00 Intro`. As on YouTube, the list needs at least three entries, the first at `0:00`, in increasing order. Chapters that YouTube generates itself are not in the description, so they are not found (`chaptersAuto` is always `false`).
- There is no extra charge and no extra request: the description is already fetched.

#### Transcript files

Set `storeFiles` to `true` to get the subtitle and text outputs as real files as well. Every requested `text`, `srt`, `vtt` and `timestampedText` output is saved in the run's default key-value store, and the row gets a `files` object with one link each (`json` stays in the row only):

```json
"files": {
  "text": "https://api.apify.com/v2/key-value-stores/<storeId>/records/dQw4w9WgXcQ.en.txt?signature=...",
  "srt": "https://api.apify.com/v2/key-value-stores/<storeId>/records/dQw4w9WgXcQ.en.srt?signature=..."
}
```

- File names are `<videoId>.<language>.<extension>`, with `.auto` after the language for auto-generated captions (`dQw4w9WgXcQ.en.auto.srt`). Text is `.txt`, timestamped text is `.timestamps.txt`.
- The inline `text`, `srt`, `vtt` and `timestampedText` fields stay in the row too.
- The links are the ones the Apify SDK builds for key-value store records; they carry a signature where your account restricts access to storages.
- Files stay for as long as your Apify plan keeps this run's data; copy them elsewhere if you need them longer.
- There is no extra charge. The files use your own storage.
- If a file cannot be saved, the row is still delivered and charged as usual, with links only for the files that were saved (no `files` at all if none were). `SUMMARY.filesSaved` counts the files saved and `SUMMARY.fileSaveFailures` those that failed; both appear only when `storeFiles` is on.
- Files are written just before the row is. In the rare case that a row then fails to be delivered, its files may stay in the store unlinked.
- `storeFiles` needs at least one of `text`, `srt`, `vtt` or `timestampedText` in `outputFormats`.

#### Word-level timestamps

Turn on `wordTimestamps` to get a `words` field: every word of the transcript with its `start` and `end` in seconds.

```json
"words": [
  { "text": "We're", "start": 18.8, "end": 19.039 },
  { "text": "no", "start": 19.039, "end": 19.359 },
  { "text": "strangers", "start": 19.359, "end": 19.84 }
],
"wordTimestampsAvailable": true
```

YouTube only sends word times for auto-generated captions, and only the start of each word. The Actor works out each `end` as the start of the next word (or the end of the caption line). For creator-uploaded captions, `words` is an empty array and `wordTimestampsAvailable` is `false`; the row is still delivered and priced as usual. Joined together, the words give the same text as `text`, spaces aside; YouTube's own punctuation items are attached to the word before them. Rows get larger: about 45 characters per word, so roughly 125 KB for a 20-minute talk. There is no extra charge. If a transcript is ever delivered through the backup caption path, it has line times only: `words` is empty and `wordTimestampsAvailable` is `false`.

#### Error codes

| `errorCode` | Meaning | Retry later? |
| --- | --- | --- |
| `INVALID_URL` | Not a YouTube video, channel or playlist link. | no |
| `PRIVATE_OR_REMOVED` | Private, deleted or never existed; also a channel or playlist that lists no videos. | no |
| `AGE_RESTRICTED` | Needs a signed-in viewer; the Actor does not sign in. | no |
| `MEMBERS_ONLY` | Channel-membership content. | no |
| `LIVE_VIDEO` | Stream in progress or not yet processed; try after it ends. | yes |
| `CAPTIONS_NOT_READY` | YouTube has not finished generating captions (fresh uploads). | yes |
| `NO_CAPTIONS` | The video has no caption track at all. | no |
| `LANGUAGE_NOT_FOUND` | No track matches your `languages` / `captionType`; see `availableLanguages`. | no |
| `IP_BLOCKED` | YouTube rate-limited the request after retries; usually a proxy choice issue. | yes |
| `TIMEOUT` | The video took longer than 60 s. | yes |
| `YOUTUBE_CHANGED` | YouTube changed a response shape. Try again later; if it keeps happening, report it in the Issues tab. | yes |
| `UNKNOWN` | Anything else; the message has details. | yes |

### Using YouTube Transcript Scraper from code

#### JavaScript

```js
import { ApifyClient } from 'apify-client';

const client = new ApifyClient({ token: 'YOUR_APIFY_TOKEN' });
const run = await client.actor('gangling_polygon/youtube-transcript-scraper').call({
    startUrls: [{ url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' }],
    languages: ['en'],
    outputFormats: ['json', 'text'],
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
console.log(items[0].text);
```

#### Python

```python
from apify_client import ApifyClient

client = ApifyClient("YOUR_APIFY_TOKEN")
run = client.actor("gangling_polygon/youtube-transcript-scraper").call(run_input={
    "channelUrls": ["@TED"],
    "maxVideosPerSource": 20,
    "publishedAfter": "2026-01-01",
    "outputFormats": ["text"],
})
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(item["title"], item.get("wordCount"))
```

#### curl

```bash
curl -X POST "https://api.apify.com/v2/acts/gangling_polygon~youtube-transcript-scraper/run-sync-get-dataset-items?token=YOUR_APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"videoIds": ["dQw4w9WgXcQ"], "outputFormats": ["text"]}'
```

#### Use from the API and integrations

The Actor can be started from the Apify API, the Python and JavaScript clients (examples above), an Apify Schedule, a webhook, or an Apify integration such as Make or Zapier. Pass `videoIds` or `startUrls`; `segments` carries `start` and `dur` in seconds so you can link back to a moment in the video, and `text` is the plain transcript.

### Only new videos

Set `onlyNewVideos` to `true` on a scheduled run of a channel or playlist and it delivers only the videos that earlier runs did not:

- The history lives in a named key-value store in your account (`stateStoreName`, default `youtube-transcript-delivered`), one record per channel or playlist. Named stores are kept until you delete them; a record holds up to 50,000 video ids.
- A video counts as delivered once its transcript row is written. A video that gave a permanent error row (`NO_CAPTIONS`, `AGE_RESTRICTED`, `MEMBERS_ONLY` or `PRIVATE_OR_REMOVED`) is remembered too, so it is not looked up again; set `resetDeliveredVideos` to try those videos again, for example when YouTube has added captions. Videos that gave any other error row (such as `LANGUAGE_NOT_FOUND`, which depends on the languages you ask for, or a temporary failure) or a free-plan limit row are tried again next time.
- Skipped videos do not count toward `maxVideosPerSource`: with a limit of 50 and 5 new videos you get those 5.
- Videos you give directly (URLs or ids) are always delivered.
- The history is kept per source as you wrote it: the same channel entered as `@name` and as its `UC...` id has two histories.
- A video that two sources of one run both list is delivered once, and counts as delivered for the source that delivered it only.
- Use a different `stateStoreName` for an independent history, or set `resetDeliveredVideos` to start a source over. Runs that use the same store at the same time mostly merge their histories when they save, but the store has no locking, so two runs saving in the same instant can still overwrite each other's newest ids; avoid scheduling overlapping runs on one store.
- When nothing is new the run ends without rows. `SUMMARY` shows `skippedAlreadyDelivered`.

### Tips

- **Cheaper runs.** Leave `outputFormats` at `json,text` unless you need subtitle files; SRT and VTT roughly double the row size.
- **Faster runs.** Raise `maxConcurrency` to 10 or 20 for large channels. Residential proxy bandwidth is the main cost driver, about 27 KB per transcript on average in the soak runs, more for long videos (a 2-hour video took about 200 KB).
- **Picking a language.** If you get `LANGUAGE_NOT_FOUND`, look at `availableLanguages` on that row and re-run with one of those codes, or use `*`.
- **Scheduling.** Combine a channel URL with `onlyNewVideos` on an Apify Schedule to collect only new transcripts, or with `publishedAfter` to use a date window.

### Limitations

- Translation into languages the video does not have is not available yet.
- Live streams are delivered once the recording is processed, not while live.
- Age-restricted and members-only videos are not supported; the Actor never signs in.

### FAQ, disclaimers and support

**Is scraping YouTube transcripts legal?** Captions are public data that YouTube shows to every viewer. Our Actors are ethical and do not extract any private user data, such as email addresses, gender, or location. They only extract what the user has chosen to share publicly. We therefore believe that our Actors, when used for ethical purposes by Apify users, are safe. However, you should be aware that your results could contain personal data. Personal data is protected by the GDPR in the European Union and by other regulations around the world. You should not scrape personal data unless you have a legitimate reason to do so. If you're unsure whether your reason is legitimate, consult your lawyers.

**Why did a video come back as `IP_BLOCKED`?** YouTube blocks datacenter IP ranges for caption requests. Keep the default Apify residential proxy; if you switched to your own proxies, switch back.

**Does it work on fresh uploads?** Auto-generated captions can take minutes to hours to appear. `CAPTIONS_NOT_READY` is retryable; run again later.

**Success rate.** From a soak test of build 0.1.7: 64 hourly scheduled runs between 28 and 30 October 2026, each taking the 25 newest videos of one channel at concurrency 10. Of 1,600 videos attempted, 1,599 transcripts were delivered and there was 1 error, a private or removed video (an expected free error row). There were no `IP_BLOCKED` rows, the average was 1.09 attempts per video, and a typical run's median time per video (p50) was about 5 seconds. This covers one channel over three days; results depend on YouTube and can change.

Found a problem or missing a feature? Use the **Issues** tab. For programmatic access see the **API** tab.

This Actor is not affiliated with, endorsed by or sponsored by YouTube or Google.

# Actor input Schema

## `startUrls` (type: `array`):

Video, Shorts, youtu.be, embed, mobile, channel or playlist URLs. Channels and playlists are expanded into their videos (newest first). Invalid URLs produce an uncharged error row.

## `videoIds` (type: `array`):

Bare 11-character YouTube video IDs (e.g. dQw4w9WgXcQ). Convenient for API calls.

## `channelUrls` (type: `array`):

Channel handles or URLs: @handle, https://www.youtube.com/@handle, /channel/UC…, /c/name or /user/name. Each is expanded to its newest videos, limited by 'Max videos per source'.

## `playlistUrls` (type: `array`):

Playlist URLs (…/playlist?list=PL…) or bare playlist IDs. Expanded in playlist order, limited by 'Max videos per source'.

## `searchQueries` (type: `array`):

Keywords to search YouTube for (up to 100). Each query is expanded to the matching videos in YouTube's relevance order, limited by 'Max videos per search'. Only regular videos are taken: Shorts, live and upcoming streams are not.

## `maxVideosPerSearch` (type: `integer`):

Maximum number of videos taken from each search query (1–300). YouTube lists about 300 results per query at most.

## `maxVideosPerSource` (type: `integer`):

Maximum number of videos taken from each channel or playlist (1–1000).

## `includeShorts` (type: `boolean`):

When expanding a channel, also include its Shorts.

## `publishedAfter` (type: `string`):

Only videos published on or after this date (YYYY-MM-DD, UTC). Applies to channel and playlist sources.

## `publishedBefore` (type: `string`):

Only videos published on or before this date (YYYY-MM-DD, UTC). Applies to channel and playlist sources.

## `languages` (type: `array`):

Caption languages to try, in order, as BCP-47 codes (en, en-US, pl, de, …). The first available one is used. Use \* to accept any language. Default: en.

## `captionType` (type: `string`):

any = prefer creator-uploaded captions, fall back to auto-generated; manual = creator-uploaded only; auto = auto-generated only.

## `anyLanguage` (type: `boolean`):

When none of the languages above is available for a video, take any other caption track instead of returning LANGUAGE_NOT_FOUND. The languages you list still come first; the row says which language was delivered.

## `translationFallback` (type: `boolean`):

Not available yet. Reserved for YouTube's built-in translation into the first requested language.

## `listOnly` (type: `boolean`):

Free. For every video, return one row with status 'languages' and the list of caption tracks (availableLanguages), without fetching or charging a transcript. Languages, caption type and output formats are ignored. Limits: 500 videos per run, and a daily number of lookups per Apify account (100 on the free plan, 2,000 on paid plans).

## `outputFormats` (type: `array`):

json = timestamped segments; text = plain transcript; srt and vtt = subtitle files as strings; timestampedText = plain text with a \[mm:ss] timestamp on every line; cleanText = transcript without sound tags such as \[Music], split into paragraphs; paragraphs = the same paragraphs as {start, end, text}; chunks = the cleaned transcript cut into pieces of chunkSize words (see chunkSize and chunkOverlap). Default: json and text.

## `timestampStyle` (type: `string`):

Only used when timestampedText is among the output formats. auto = \[mm:ss], switching to \[hh:mm:ss] for videos an hour or longer; mmss = always \[mm:ss] (minutes are not wrapped at 60); hhmmss = always \[hh:mm:ss].

## `includeSegmentEnd` (type: `boolean`):

Add an end time (seconds) to every item in segments, alongside start and dur.

## `chunkSize` (type: `integer`):

Only used when chunks is among the output formats: words per chunk.

## `chunkOverlap` (type: `integer`):

Only used when chunks is among the output formats: words each chunk shares with the next one. At most half the chunk size. Overlapping words are repeated in the row, so it grows by about overlap divided by chunk size.

## `includeChannelStats` (type: `boolean`):

Add channelHandle (such as @RickAstleyYT) and subscriberCount to every row. Needs one extra request per channel (shared by its videos), about 140 to 300 KB through the proxy. Subscriber counts are rounded the way YouTube shows them (4.55M).

## `wordTimestamps` (type: `boolean`):

Add a words field: every word with its start and end time (seconds). Only auto-generated captions carry word times; for manual captions words is empty and wordTimestampsAvailable is false. Makes rows larger (about 45 characters per word). Same price.

## `includeMetadata` (type: `boolean`):

Add title, channel, duration, views, likes, publish date, description, thumbnail and keywords to every row. Free of charge; turn off only to get smaller rows.

## `includeChapters` (type: `boolean`):

Add `chapters` (title, start, end in seconds) and `chaptersAuto` to every transcript row. No extra charge. The chapters are read from the timestamp lines of the video description, so only chapters the creator wrote are found.

## `storeFiles` (type: `boolean`):

Also save each text, SRT, VTT or timestamped-text output as a file in this run's key-value store, and add a `files` object with a link for each to the row. Needs at least one of those formats in Output formats. No extra charge; the files use your storage and are kept for as long as your Apify plan keeps this run's data.

## `maxConcurrency` (type: `integer`):

Videos processed in parallel (1–20).

## `maxItems` (type: `integer`):

Stop after this many videos have been attempted (ok, limited and error rows all count; videos outside the date window do not). Leave empty for no limit; the run also stops at your 'Max cost per run'.

## `onlyNewVideos` (type: `boolean`):

For channels and playlists: skip videos that an earlier run (using the same state store) already delivered, so a scheduled run returns only what is new. Skipped videos do not count toward Max videos per source. Videos given directly (URLs or ids) are never skipped. A video counts as delivered once its transcript row is written; videos that gave an error row are tried again.

## `stateStoreName` (type: `string`):

Name of the key-value store that remembers delivered videos (used with Only new videos). 3 to 63 characters: lower-case letters, digits and hyphens, not starting or ending with a hyphen. Use a different name for an independent history. The store is created in your account and kept until you delete it.

## `resetDeliveredVideos` (type: `boolean`):

Used with Only new videos: forget what was delivered for the channels and playlists in this run, so all their videos count as new again. Other channels and playlists in the store are not touched.

## `proxyConfiguration` (type: `object`):

YouTube blocks datacenter IPs for caption requests, so Apify residential proxies are used by default. Changing this is likely to lower the success rate.

## Actor input object example

```json
{
  "startUrls": [
    {
      "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
    }
  ],
  "videoIds": [],
  "channelUrls": [],
  "playlistUrls": [],
  "searchQueries": [],
  "maxVideosPerSearch": 50,
  "maxVideosPerSource": 50,
  "includeShorts": true,
  "languages": [
    "en"
  ],
  "captionType": "any",
  "anyLanguage": false,
  "translationFallback": false,
  "listOnly": false,
  "outputFormats": [
    "json",
    "text"
  ],
  "timestampStyle": "auto",
  "includeSegmentEnd": false,
  "chunkSize": 300,
  "chunkOverlap": 30,
  "includeChannelStats": false,
  "wordTimestamps": false,
  "includeMetadata": true,
  "includeChapters": false,
  "storeFiles": false,
  "maxConcurrency": 5,
  "onlyNewVideos": false,
  "stateStoreName": "youtube-transcript-delivered",
  "resetDeliveredVideos": false,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}
```

# Actor output Schema

## `results` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "startUrls": [
        {
            "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
        }
    ],
    "proxyConfiguration": {
        "useApifyProxy": true,
        "apifyProxyGroups": [
            "RESIDENTIAL"
        ]
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("gangling_polygon/youtube-transcript-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "startUrls": [{ "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ" }],
    "proxyConfiguration": {
        "useApifyProxy": True,
        "apifyProxyGroups": ["RESIDENTIAL"],
    },
}

# Run the Actor and wait for it to finish
run = client.actor("gangling_polygon/youtube-transcript-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "startUrls": [
    {
      "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
    }
  ],
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ]
  }
}' |
apify call gangling_polygon/youtube-transcript-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,gangling_polygon/youtube-transcript-scraper"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/bXEVne7LTQ2simSHL/builds/mpW8EbHj6c3OmiGyU/openapi.json
