# Video Transcript API — YouTube, TikTok, Instagram, X, Facebook (`airtune/universal-transcript-api`) Actor

Any video URL to a timestamped transcript — including videos with no captions, which are transcribed with AI. Playlists, channels, and your own files too. JSON, text, SRT, VTT or LLM-ready output.

- **URL**: https://apify.com/airtune/universal-transcript-api.md
- **Developed by:** [Mustafa](https://apify.com/airtune) (community)
- **Categories:** AI, Videos, Social media
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $3.00 / 1,000 transcript from captions

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

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

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Universal Transcript API — YouTube, TikTok, Instagram, X, Facebook

Turn a video URL into a timestamped transcript. Works on videos that have no captions at all, by
transcribing the audio.

Paste a video, a playlist, or a whole channel. Get JSON, plain text, SRT, WebVTT, or LLM-ready
text. No YouTube API key, no account, no cookies.

### What it does

| | |
|---|---|
| **Platforms** | YouTube (videos, Shorts, playlists, channels), TikTok, Instagram, X, Facebook, **and your own files** |
| **Captions** | Any language YouTube publishes, human-written preferred over auto-generated |
| **No captions?** | Falls back to speech recognition |
| **Output** | `json`, `text`, `llm`, `srt`, `vtt` — request any combination |
| **Errors** | Typed `errorCode` on every failed item, never a silent empty result |
| **Billing** | Per successful transcript. Failures are free. |

### Your own audio and video

`mediaUrls` takes uploaded files or direct links to media — MP3, MP4, M4A, WAV, WEBM and the rest.
No platform is involved, so nothing here can break when a website changes, and it is the cheapest
path per minute of content because it needs no proxy at all. Useful for podcast archives, meeting
recordings, and lecture audio that never went near a video platform.

```jsonc
{
  "mediaUrls": ["https://cdn.example.com/podcast/ep12.mp3"],
  "outputFormats": ["llm", "srt"]
}
```

Billed per audio minute, measured from the audio itself.

### Why the AI fallback matters

Most transcript tools read the caption track and stop. If the uploader never enabled captions,
they return an error — and that is a large share of podcasts, lecture recordings, and almost every
TikTok. This Actor downloads the audio and transcribes it instead, returning the same structure
with `source: "asr"` so you always know where the text came from.

Speech recognition runs on hosted models rather than one bundled into the image, which is why it
does not need 4 GB of memory and does not add minutes of model-loading time to your run. When the
primary provider is at capacity the Actor moves to a second one automatically — the price is the
same either way, and `metadata.asrProvider` records which one produced each transcript.

### Quick start

```jsonc
{
  "startUrls": ["https://www.youtube.com/watch?v=aircAruvnKk"],
  "outputFormats": ["json", "llm"]
}
```

Playlists and channels expand automatically:

```jsonc
{
  "startUrls": [
    "https://www.youtube.com/playlist?list=PLZHQObOWTQDNU6R1_67000Dx_ZCJB-3pi",
    "https://www.youtube.com/@3blue1brown",
    "https://www.tiktok.com/@nasa/video/1234567890"
  ],
  "maxVideosPerSource": 50,
  "languages": ["en", "es"],
  "outputFormats": ["llm", "srt"]
}
```

A `watch?v=…&list=…` URL is treated as **one video**, not the whole playlist — pasting a video
should never turn into a several-hundred-video bill. Use a `/playlist?list=…` URL when you want
the list.

### Output

Every item carries metadata plus whichever formats you requested.

```jsonc
{
  "url": "https://www.youtube.com/watch?v=aircAruvnKk",
  "platform": "youtube",
  "videoId": "aircAruvnKk",
  "succeeded": true,
  "source": "manual_captions",     // manual_captions | auto_captions | asr
  "language": "en",                 // ISO 639-1, whatever the source
  "title": "But what is a neural network?",
  "durationSeconds": 1120,
  "segmentCount": 286,
  "characterCount": 18430,
  "availableLanguages": ["ar", "de", "en", "es", "..."],
  "transcriptJson": [{ "start": 4.22, "end": 5.4, "text": "This is a 3." }],
  "transcriptText": "This is a 3. It's sloppily written…",
  "transcriptLlm":  "This is a 3. It's sloppily written…",
  "transcriptSrt":  "1\n00:00:04,220 --> 00:00:05,400\nThis is a 3.\n",
  "metadata": { "channel": "3Blue1Brown", "viewCount": 23876902, "captionRoute": "direct" },
  "error": null,
  "errorCode": null
}
```

#### Output formats

| Format | Field | Use it for |
|---|---|---|
| `json` | `transcriptJson` | Timestamped segments `[{start, end, text}]` |
| `text` | `transcriptText` | The words, joined |
| `llm` | `transcriptLlm` | RAG and summarisation — `[Music]`, `(laughter)` and hesitation tokens removed |
| `srt` | `transcriptSrt` | Subtitle files for players and editors |
| `vtt` | `transcriptVtt` | WebVTT for HTML5 `<video>` |

`llm` is deliberately conservative: it strips bracketed non-speech annotations, `♪` lyric markers,
and standalone hesitations (`um`, `uh`, `erm`, `hmm`). It does **not** strip words like *like* or
*so*, which are real words far more often than they are filler — removing them would corrupt the
transcript rather than clean it.

### Languages

`languages` picks a **caption track**, in priority order, e.g. `["tr", "en"]`. A bare code matches
regional variants, so `pt` accepts `pt-BR`. With no preference the Actor returns the video's own
language — not whichever translation happens to sort first alphabetically, which is a common bug
in this category.

`forceAsrLanguage` is separate: it is handed straight to the speech recognition model for videos
with no captions and skips its auto-detection window. Set it when you know the channel's language — detection
is unreliable on short or music-heavy clips.

### Ask the price first

Set `estimateOnly: true` and the Actor tells you what the job would cost without doing it and
without charging anything:

```jsonc
{ "startUrls": ["https://www.youtube.com/watch?v=..."], "estimateOnly": true }
```

```jsonc
{
  "isEstimate": true,
  "durationSeconds": 221,
  "predictedSource": "asr",
  "estimateAccuracy": "exact",
  "priceBreakdown": [
    { "event": "transcript-asr-minute", "count": 4, "unitPriceUsd": 0.01, "subtotalUsd": 0.04 }
  ],
  "estimatedPriceUsd": 0.04
}
```

The run summary carries `quotedTotalUsd` for the whole batch. Re-run with `estimateOnly: false`
to process.

Quotes for platform videos are **exact**: price turns on whether a caption track exists and how
long the audio is, and both are in the metadata the Actor fetches anyway. Quotes for uploaded
files are marked **approximate**, because a file exposes no duration until it is decoded — those
are inferred from size at a deliberately low assumed bitrate, so the real charge usually lands
below the quote rather than above it.

Prices in a quote come from the Actor's live configured pricing, not from numbers baked into the
code.

### Videos you have already transcribed

Speech-recognition results are remembered in a key-value store **in your own account**, and reused
when the same video comes round again. Nothing is shared between users — there is no central pool
of other people's transcripts, and your storage only ever answers your own runs.

A repeat costs the same as the first time, and skips everything that made the first time slow:

| | First run | Repeat |
|---|---|---|
| Media downloaded | yes | no |
| Speech-recognition quota used | yes | **no** |
| Can fail on a download or a busy provider | yes | **no** |
| Typical wait | seconds to minutes | immediate |

That matters most on a schedule. Pointing this Actor at a channel every morning re-reads the same
back catalogue every time; with the cache, only genuinely new videos are transcribed, and a spent
daily quota no longer takes the whole run down with it.

Only speech recognition is cached. Caption tracks are not: they are one cheap request that always
returns the platform's current text, and a stored copy would just go stale.

Rows served this way carry `metadata.fromCache: true`, and `estimateOnly` says so before you run.
Set `cacheTranscripts: false` to force a fresh transcription.

### Error codes

Failed items appear in the dataset with `succeeded: false` and a stable `errorCode`. Filter on the
code rather than parsing the message. **No failed item is ever charged.**

#### Your input

| Code | Meaning | What to do |
|---|---|---|
| `INVALID_VIDEO_ID` | The URL contains no recognisable video ID | Check the URL |
| `UNSUPPORTED_URL` | Not a URL for the platform it was routed to | Check the URL |
| `UNSUPPORTED_PLATFORM` | No adapter for that host | Use a supported platform |
| `PLATFORM_NOT_IMPLEMENTED` | Recognised platform, not built yet | See the live platform list in the message |
| `LANGUAGE_UNAVAILABLE` | Captions exist, but not in your languages | `availableLanguages` lists what exists |
| `SOURCE_NOT_FOUND` | Playlist or channel rejected by the platform | Check the ID and that it is public |
| `MEDIA_NOT_AUDIO` | The URL serves a web page, not media | Link the media file itself |
| `MEDIA_NOT_FOUND` | The media URL returned 404 | Check the link |
| `MEDIA_FORBIDDEN` | The media URL needs authentication | Use a public link, or upload the file |
| `MEDIA_TOO_LARGE` | File above the upload limit | Re-encode at a lower bitrate — recognition downsamples to 16 kHz mono anyway |
| `PLAYLIST_EMPTY` | No videos found in the list | Private, deleted, or region-locked |
| `CHANNEL_NOT_RESOLVED` | The handle could not be resolved to a channel | Try the `/channel/UC…` form |

#### The video

| Code | Meaning | What to do |
|---|---|---|
| `NO_CAPTIONS` | No caption track, and AI fallback is off | Turn on `asrFallback` |
| `EMPTY_TRANSCRIPT` | Caption track exists but has no readable text | Use `asrFallback` |
| `EMPTY_CAPTION_BODY` | The caption endpoint returned nothing | Usually transient; `asrFallback` covers it |
| `CAPTION_PARSE_FAILED` | Caption payload was not in the expected format | Report it |
| `NO_AUDIO_STREAM` / `NO_VIDEO_STREAM` | No downloadable media | Often age-restricted, or an HLS-only tweet |
| `NOT_A_VIDEO` | The post is a photo or text | Nothing to transcribe, nothing charged |
| `POST_NOT_AVAILABLE` | Deleted, private or age-restricted post | Check the link is public |
| `ASR_VIDEO_TOO_LONG` | Longer than `maxAsrDurationSeconds` | Raise the limit |
| `ASR_REQUIRED` | Direct media has no captions to read | Enable `asrFallback` |
| `AUDIO_TOO_LARGE` | Media above the upload limit | Not transcribable in one pass |

#### Budget

| Code | Meaning | What to do |
|---|---|---|
| `BUDGET_EXHAUSTED` | Your spending limit was reached mid-run | Raise `maxTotalChargeUsd` and re-run |

Remaining URLs stop immediately rather than doing unpaid work.

#### Platform or infrastructure — not your input, and not charged

| Code | Meaning | What to do |
|---|---|---|
| `TIKTOK_CHALLENGE` | TikTok served a challenge page repeatedly | Rate limiting — retry later, lower `maxConcurrency` |
| `INSTAGRAM_BLOCKED` | Instagram refused the request | Rate limiting or IP reputation — retry later |
| `TWITTER_BLOCKED` | X refused the request | Rate limiting — retry later |
| `FACEBOOK_BLOCKED` | Facebook rejected the request | Usually a non-public link |
| `FACEBOOK_LOGIN_REQUIRED` | Facebook served a login wall | The video is not public |
| `VISITOR_DATA_UNAVAILABLE` | Could not establish a session | Transient; retry |
| `AUDIO_FORBIDDEN` | Media URL rejected mid-run | Transient; retry |
| `AUDIO_INCOMPLETE` | Download truncated | Deliberately refused rather than returning a partial transcript |
| `AUDIO_DOWNLOAD_FAILED` / `VIDEO_DOWNLOAD_FAILED` | Media download failed after retries | Retry later |
| `AUDIO_EMPTY` / `MEDIA_EMPTY` | Media stream returned no bytes | Retry later |
| `MEDIA_FETCH_FAILED` | The media URL could not be fetched | Check the link is reachable |
| `ASR_FAILED` / `ASR_EMPTY` / `ASR_RATE_LIMITED` | Speech recognition failed or returned nothing | Retry later |
| `ASR_AUTH_FAILED` | Transcription service rejected its key | Report it |
| `EXPANSION_HTTP_ERROR` / `EXPANSION_FAILED` | Listing a playlist or channel failed | Retry later |
| `NETWORK_ERROR` | Proxy or transport failure | Transient — retry |
| `ASR_QUOTA_EXHAUSTED` | Speech recognition quota spent for now | Retry later; caption transcripts are unaffected |
| `UNEXPECTED_ERROR` | Unhandled error on that item | Report it — other items continue |

One bad URL never aborts a run.

### Input reference

| Option | Default | Notes |
|---|---|---|
| `startUrls` | — | Videos, playlists, channels. Bare 11-character YouTube IDs accepted. |
| `maxVideosPerSource` | 50 | Caps each playlist/channel. Direct video URLs are never capped. |
| `languages` | auto | Caption languages in priority order |
| `outputFormats` | `["json","text"]` | Any of `json`, `text`, `llm`, `srt`, `vtt` |
| `maxConcurrency` | 5 | Higher is faster, more likely to be rate-limited |
| `asrFallback` | `true` | Transcribe audio when no caption track exists |
| `asrModel` | `whisper-large-v3-turbo` | Primary-provider model. Ignored when the fallback provider runs. |
| `maxAsrDurationSeconds` | 3600 | Skip AI on longer videos instead of running up a bill |
| `forceAsrLanguage` | auto-detect | ISO 639-1 code handed to the speech recognition model |
| `proxyCountryCode` | — | Two-letter country for the residential proxy |
| `captionEgress` | `auto` | Caption download route; `auto` uses the cheaper one and falls back |
| `reuseSession` | `true` | Reuse the platform session between runs — cheaper on single-video calls |

### Notes on cost

- **Batch when you can.** Session setup is a fixed cost per run, so 50 videos in one run is
  roughly three times cheaper per video than 50 single-video runs.
- **Playlist and channel expansion has its own cost**, fixed per source. Taking 4 videos from a
  channel costs far more per video than taking 50.
- **TikTok is more expensive than YouTube** — it has no audio-only stream, so a 10-second clip
  means downloading about 1.3 MB where a 10-minute YouTube video needs 3.6 MB.
- **AI transcription is billed per audio minute** and only runs when a video has no captions.

### Limitations

- Public videos only. Nothing that needs a login, age verification, or a purchase.
- No translation — you get the language that was spoken or captioned.
- Live streams have no static transcript.
- The *reported* language on `asr` rows can be wrong even when the text is right. Set
  `forceAsrLanguage` when it matters.
- TikTok rate-limits aggressively; the Actor retries, but large batches will see
  `TIKTOK_CHALLENGE` on some items.

### Legal

This Actor reads publicly available captions and metadata — the same data any viewer sees via the
player's transcript button. It does not bypass authentication or age gates, does not use an
account, and collects no personal data. You are responsible for complying with each platform's
terms and the law in your jurisdiction.

# Actor input Schema

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

YouTube video, playlist or channel links. Videos accept watch URLs, youtu.be short links, /shorts/, /embed/, /live/, or bare 11-character IDs. Playlists (/playlist?list=...) and channels (/@handle, /channel/UC..., /c/..., /user/...) are expanded into their videos. A watch URL that also carries \&list= is treated as one video, not the whole playlist.

## `mediaUrls` (type: `array`):

Upload your own audio or video files, or paste direct links to them (MP3, MP4, M4A, WAV, WEBM…). These are transcribed with AI — no platform involved, so nothing here can break when a website changes. Cheapest path per minute of content.

## `estimateOnly` (type: `boolean`):

Return what each item would cost, and charge nothing. The Actor fetches only the metadata it needs to price the job: whether captions exist, and how long the audio is. Re-run with this off to actually transcribe. Quotes for platform videos are exact; for uploaded files they are approximate, because a file exposes no duration until it is decoded.

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

Caps how many videos each playlist or channel contributes, so one channel link cannot turn into a thousand-video bill. Videos passed directly are never capped.

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

ISO 639-1 codes in priority order, e.g. \["tr", "en"]. Human-written captions win over auto-generated ones within the same language. Leave empty to take the best available track.

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

Which representations to write. 'llm' is plain text with \[Music], (laughter) and hesitation tokens removed — ready to index in a RAG pipeline without post-processing.

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

Higher is faster but more likely to draw rate limiting.

## `proxyCountryCode` (type: `string`):

Optional two-letter country for the residential proxy, e.g. US. Affects which regional caption tracks and metadata YouTube returns.

## `reuseSession` (type: `boolean`):

Caches the YouTube session token in a named key-value store so short runs do not re-acquire it. Cuts the cost of single-video runs by roughly half. Turn off only when debugging session problems.

## `captionEgress` (type: `string`):

Caption URLs are signed with ip=0.0.0.0, so the free datacenter route usually serves them (measured 15/15) and costs about 17% less overall. 'Auto' uses it and falls back to residential if it ever stops working.

## `asrFallback` (type: `boolean`):

When a video has no caption track at all, download its audio and transcribe it with Whisper. This is the one thing other transcript tools cannot do. Billed per audio minute, and only when it actually runs.

## `asrModel` (type: `string`):

Turbo is the default: same family, a fraction of the cost, and no meaningful accuracy loss for speech.

## `maxAsrDurationSeconds` (type: `integer`):

Videos longer than this skip speech recognition instead of running up a bill. Default one hour.

## `forceAsrLanguage` (type: `string`):

ISO 639-1 code handed straight to Whisper for videos with no captions. Skips its 30-second auto-detection window, which is what mis-reads short or music-heavy clips. Leave empty to auto-detect. Separate from 'Preferred languages', which only selects a caption track.

## `cacheTranscripts` (type: `boolean`):

Speech-recognition results are remembered in your own key-value store and reused when the same video comes round again. A repeat is returned immediately, without re-downloading the media and without using speech-recognition quota — the price is the same, the wait and the failure modes are not. Only AI transcriptions are cached, never caption tracks, and the store belongs to your account: nothing is shared between users. Turn this off to force a fresh transcription.

## Actor input object example

```json
{
  "startUrls": [
    "https://www.youtube.com/watch?v=aircAruvnKk"
  ],
  "mediaUrls": [],
  "estimateOnly": false,
  "maxVideosPerSource": 50,
  "languages": [],
  "outputFormats": [
    "json",
    "text"
  ],
  "maxConcurrency": 5,
  "proxyCountryCode": "",
  "reuseSession": true,
  "captionEgress": "auto",
  "asrFallback": true,
  "asrModel": "whisper-large-v3-turbo",
  "maxAsrDurationSeconds": 3600,
  "forceAsrLanguage": "",
  "cacheTranscripts": true
}
```

# 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": [
        "https://www.youtube.com/watch?v=aircAruvnKk"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("airtune/universal-transcript-api").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": ["https://www.youtube.com/watch?v=aircAruvnKk"] }

# Run the Actor and wait for it to finish
run = client.actor("airtune/universal-transcript-api").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": [
    "https://www.youtube.com/watch?v=aircAruvnKk"
  ]
}' |
apify call airtune/universal-transcript-api --silent --output-dataset

```

## MCP server setup

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

```

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/tJfBcOypaV9BOeq0i/builds/NfeJyoLynsQ6MeoTE/openapi.json
