# TikTok Transcript Scraper: Captions + AI Speech-to-Text (`hyperbach/tiktok-transcript-scraper`) Actor

TikTok video transcripts, no login: video links or creator handles in, the spoken words out. TikTok's own captions with timed cues, SRT and WebVTT; AI speech-to-text where a video has none. Likes, comments, shares, saves, the author, the sound. Pinned videos told apart. No words, no charge.

- **URL**: https://apify.com/hyperbach/tiktok-transcript-scraper.md
- **Developed by:** [Hyperbach](https://apify.com/hyperbach) (community)
- **Categories:** Social media, Videos, AI
- **Stats:** 3 total users, 0 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.14 / 1,000 caption transcripts

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

## TikTok Transcript Scraper: Captions + AI Speech-to-Text

**TikTok video transcripts, no login: video links or creator handles in, the spoken words out.** TikTok's own caption track first — the clean text, its timed cues, the raw WebVTT, SRT and the language on every captioned video — and AI speech-to-text where TikTok has no track (most ordinary videos), priced apart and only where it runs. Every row carries the video's metadata: the description, the posting date to the second, duration, plays, likes, comments, shares and saves as TikTok's page shows them, the author with followers, the sound. A creator's newest videos by date with the pinned ones told apart, depth past the newest ten through a browser walk, a date window, a watchlist that delivers only new videos; TikTok's machine-translated tracks per language; the timed words of a model row; the video file. A video without words — music only, a photo post — is an honest free row, and so is every miss.

### Why this TikTok transcript scraper

- **TikTok's own words first, at a caption price.** Where TikTok carries a caption track (its automatic speech recognition, or the creator's own captions), the row gets that track: the clean text with one space between cues (no glued words), the 15 timed cues of a 36-second clip, the raw WebVTT, SRT, the language and the track's source (ASR, MT or creator). The leader of the lane charges the same price for a `transcript: null` row on a video it cannot read; here a video without words is free, and the words are on the row, never behind a file link that expires.
- **Speech-to-text where there is no track — the default, not an afterthought.** 77 % of ordinary TikToks carry no caption track and 81 % of those have words (measured over 130 videos of 14 ordinary creators). By default such a video's audio goes to a speech model (OpenAI's gpt-4o-mini-transcribe, the dated snapshot) and the row says so (`transcript_source` = model) at its own price; TikTok's captioned videos stay at the caption price. Choose `always` for the model on every video, or `off` for captions only.
- **No invented words.** The model answers empty on music-only clips where other models invent text; a fragment of fewer than five words over music is treated as no speech (the fragment is kept in `notes`); a word the model may have misheard for a slur is read twice before it stands. A clip without speech is a free `no_speech` row.
- **A creator's newest videos, by date — the pinned ones told apart.** Three rivals returned a creator's pinned videos when asked for the two newest. Here the newest N count unpinned videos only, `is_pinned` is on every creator row, `includePinned` adds the pinned ones marked. Up to ten come without a browser; more are read by walking the creator's list in a browser, with a COMPLETE or TRUNCATED verdict against TikTok's own count. `onlyNew` turns a scheduled creator task into a feed of new videos only.
- **The video's metadata on every row.** The posting date to the second (`created_at`), the description with its line breaks, duration, likes, comments, shares, saves and plays as TikTok's page shows them (`statsV2`: exact up to 10,000, rounded by TikTok above it, e.g. 45,200 where the app shows 45,171), the author with followers, the sound and TikTok's original-sound flag. The row says which country read it (`read_country`): TikTok shows a different comment count per region.
- **Honest and bounded.** No start fee, no platform usage billed to you, no plan cap: a FREE account runs 100 videos. The log says the most a run can cost before the first request, `maxItems` is exact, the charge limit is kept before the model is called, and a run cut by its own timeout delivers what it read and names what it did not. Every miss — a removed video, a missing creator, a bad value, a refused exit — is a free row with `status` and `reason`.

### Who it's for

- **Creators, agencies and ghostwriters** — the scripts of a creator's latest TikToks with the hook, the date and the counts, to study what works and write the next one.
- **Researchers and analysts** — what is said across many videos, with exact dates, counts and the spoken language, from video links or whole creators.
- **Content and ad teams** — subtitles in SRT or WebVTT straight from TikTok's own cues, the timed words of a model row, the video file, ready for an editor.
- **Developers and agents** — one row shape, typed fields, a free labelled row for every video without a transcript, a run summary, and a dataset-chaining input for a pipeline.

### Quick start

**One video, the words**

```json
{
  "videoUrls": [
    "https://www.tiktok.com/@hubermanlab/video/7578955632024309022"
  ]
}
```

**A creator's five newest videos**

```json
{
  "handles": [
    "garyvee"
  ],
  "maxVideosPerHandle": 5
}
```

**A creator's last 30 days, pinned videos included**

```json
{
  "handles": [
    "garyvee"
  ],
  "maxVideosPerHandle": 50,
  "dateFrom": "30 days",
  "includePinned": true
}
```

**TikTok's Spanish translation beside the words**

```json
{
  "videoUrls": [
    "https://www.tiktok.com/@hubermanlab/video/7578955632024309022"
  ],
  "captionLanguages": [
    "es"
  ]
}
```

**The speech model on every video, with timed words**

```json
{
  "videoUrls": [
    "https://www.tiktok.com/@hubermanlab/video/7578955632024309022"
  ],
  "aiTranscription": "always",
  "timestamps": true
}
```

**Chain another scraper's dataset**

```json
{
  "datasetId": "<the dataset id of a TikTok scraper run>"
}
```

### Output

One row per video: the transcript and its labels, the video's metadata, the options you asked for — or, for a video without a transcript, the same row with `status` and `reason` saying why (free):

| field | meaning |
|---|---|
| `status` | `ok` = the row carries a transcript (charged: `caption_transcript` or `ai_transcript` by `transcript_source`). Otherwise free, with `reason` saying why: `no_caption`, `no_speech`, `photo_post`, `not_found`, `unavailable`, `no_videos`, `invalid`, `audio_too_large`, `audio_failed`, `transcription_failed`, `refused`, `skipped`, `failed`. |
| `reason` | Why the row has no transcript, in words (null on `ok` rows). |
| `input` | The value you gave that brought the video into the run (a video link, an id, a share link, a creator's handle). |
| `input_kind` | What the input was read as: `video`, `share`, `handle`, `invalid`. A creator's listed videos carry `video` with the handle in `input`. |
| `input_index` | The input's position in your list (1 = first); null for a video that came from a dataset. |
| `video_id` | TikTok's video id (19 digits; stable for the video's life; its top 32 bits are its creation second). Deduplicate on this. |
| `video_url` | The video's link (`/photo/` for a photo post). |
| `author_username` | The creator's handle. |
| `author_id` | The creator's user id. |
| `author_nickname` | The creator's display name. |
| `author_verified` | True when the creator's account is verified. |
| `author_followers` | The creator's follower count as the video page shows it (rounded by TikTok above 10,000). |
| `description` | The video's written description (the caption text under the video, with its hashtags), as written; not the transcript. |
| `created_at` | When the video was posted, to the second (ISO 8601, UTC). |
| `created_at_timestamp` | The posting time as a Unix timestamp. |
| `duration_s` | The video's length in seconds (0 for a photo post). |
| `plays` | Plays as the TikTok web page shows them: rounded by TikTok (99,900 where the app shows 99,885). |
| `likes` | Likes at the time of the run, as the web page shows them (exact up to 10,000, rounded by TikTok above). |
| `comments` | Comments count as shown to the reading region (`read_country`): TikTok's count differs by region; rounded by TikTok above 10,000. |
| `shares` | Shares (exact up to 10,000, rounded by TikTok above). |
| `saves` | Saves (bookmarks; exact up to 10,000, rounded by TikTok above). |
| `is_pinned` | On a creator run: true when the video is pinned to the top of the creator's profile (delivered only with `includePinned`), false for one of the newest. Null on a pasted link: TikTok's page carries no pinned flag. |
| `is_photo_post` | True for a photo post (a slideshow of images): no spoken track, a free `photo_post` row. |
| `image_count` | For a photo post: how many images it has. |
| `text_language` | The language TikTok assigns to the video's text (ISO 639-1), as the page carries it. |
| `music_id` | The sound's id. |
| `music_title` | The sound's title ("original sound - <creator>" for the creator's own audio, the track's title for a licensed song). |
| `music_author` | The sound's author as TikTok names it. |
| `music_original` | TikTok's original-sound flag: true for an "original sound" (a creator's own audio, this video's or another video's — TikTok sets it for another creator's original sound too), false for a licensed song. The speech model reads the sound's mp3 only when the sound was made with this video and lasts as long (`audio_source`). |
| `music_url` | The sound's mp3 on TikTok's CDN (open; expires). |
| `cover_url` | The video's cover image on TikTok's CDN (expires). |
| `play_url` | The video file on TikTok's CDN (signed; served with the page session's cookie; expires at `media_expires_at`). |
| `media_expires_at` | When the first of TikTok's signed media links on the row expires, decoded from the links themselves. |
| `transcript` | The spoken words of the whole video, as one text: TikTok's caption track (its cues joined with single spaces) or the speech model's words (`transcript_source`). |
| `transcript_source` | Where the words came from: `caption` (TikTok's own caption track) or `model` (the speech model). |
| `transcript_model` | The speech model that wrote the words of a model row (`gpt-4o-mini-transcribe-2025-12-15`); null on a caption row. |
| `word_count` | Words in `transcript`. |
| `language` | The spoken language's code (ISO 639-1): the caption track's language on a caption row; on a model row by the timing model with `timestamps`, else detected from the text (see `language_source`). Null when there is too little text to judge. |
| `language_name` | The spoken language in words ("English"). |
| `language_source` | Where `language` came from: `caption` (TikTok's track code), `whisper-1` (the timing model, with `timestamps`) or `text-detection` (a detector over the transcript's text). |
| `caption_language` | TikTok's own code of the caption track the words came from (`eng-US`, `spa-ES`); null on a model row without a track. |
| `caption_source` | The caption track's source as TikTok labels it: `ASR` (TikTok's speech recognition), `MT` (a machine translation), `LC` (the creator's own captions). |
| `caption_tracks` | Every caption track TikTok lists for the video on the page as read: `language` (TikTok's code), `language_code` (ISO 639-1), `source` (ASR / MT / LC). Null when the video has none. |
| `caption_text` | With `aiTranscription: always` on a video that has a TikTok track: the track's clean text beside the model's words. |
| `segments` | The timed segments, `start` and `end` in seconds and `text`: TikTok's own cues on a caption row; the timing model's segments on a model row with `timestamps`. |
| `segment_count` | How many timed segments the row has. |
| `words` | With `timestamps` on a model row: every word with its `start` and `end` in seconds (OpenAI whisper-1). |
| `srt` | The transcript as SRT subtitles, built from the segments (valid timecodes). |
| `vtt` | The WebVTT: TikTok's caption file as served on a caption row; built from the timing model's segments on a model row with `timestamps`. |
| `speech_seconds` | With `timestamps` on a model row: seconds of the video that carry speech (the segments' lengths summed). |
| `timing_model` | With `timestamps` on a model row: the model that timed the words and segments (`whisper-1`). |
| `hook_3s` | The opening hook: the cues that start inside the first 3 seconds of speech on a caption row, the words of the first 3 seconds of speech with `timestamps`, else the transcript's first 8 words (see `hook_source`). |
| `hook_start_s` | With `timestamps` on a model row: the second the first word is spoken at. |
| `hook_source` | How the hook was cut: `caption_cues` (TikTok's cues inside the first 3 s of speech), `timed_words` (the first 3 s of timed words) or `first_words` (the first 8 words). |
| `translations` | With `captionLanguages`: TikTok's machine-translated caption tracks in the requested languages, each with `language` (TikTok's code), `language_code`, `source` (MT), `text`, `segment_count` and the raw `vtt`. A requested language TikTok has no track for is named in `notes`. |
| `translation_count` | How many translated tracks the row carries (the `caption_language` events charged); null when none. |
| `audio_source` | On a model row: which file carried the audio to the model: `original_sound_mp3` (the video's own original sound as mp3, made with the video and as long, 17 % of the video's bytes) or `video_file` (the video itself: a licensed song, another creator's sound, or a creator's earlier sound re-used, whose mp3 holds another video's words). |
| `audio_url` | The file that was transcribed, on TikTok's CDN (signed; expires). |
| `transcript_from` | `live` = read or transcribed in this run; `cache` = a model transcript served from the shared transcript cache by the video's id (the metadata on the row is still this run's). |
| `transcribed_at` | On a model row: when the words were transcribed (UTC): the model call's time, or the cached answer's. |
| `long_video_minutes` | Started minutes of audio past the first on a model row (the `long_video_minute` events charged); null when none. |
| `timestamp_minutes` | With `timestamps` on a model row: started minutes of audio (the `timestamps` events charged); null when none. |
| `walk_units` | 1 on the first charged row of a creator whose videos came from a browser walk (the `video_walk` event charged once per creator); null otherwise. |
| `media_files` | With `downloadMedia`: the files written to the run's key-value store, each with `kind`, `key`, `url`, `bytes`, `content_type`, `source_url`, `reused`. |
| `video_file_url` | With `downloadMedia: video`: a link to the stored video file that does not expire. |
| `is_new` | On a memory run (`onlyNew` or `stateStoreName`): true when no earlier run of this memory delivered the video. |
| `first_seen_at` | On a memory run: the day this memory first saw the video. |
| `read_country` | The country the video page was read from (`readCountry`): the comment count and the caption CDN host follow it. |
| `notes` | Anything the row could not carry in full, in words (which egress read the caption or audio, a requested translation TikTok has no track for, a fragment the model heard on music, a region TikTok answered for). |
| `scraped_at` | When the row was read (UTC). |

Example record:

```json
{
  "status": "ok",
  "reason": null,
  "input": "https://www.tiktok.com/@hubermanlab/video/7578955632024309022",
  "input_kind": "video",
  "input_index": 1,
  "video_id": "7578955632024309022",
  "video_url": "https://www.tiktok.com/@hubermanlab/video/7578955632024309022",
  "author_username": "hubermanlab",
  "author_id": "6681031681057506309",
  "author_nickname": "Andrew Huberman",
  "author_verified": true,
  "author_followers": 667900,
  "description": "Don't Start a Conversation With an Apology @thinkfasttalksmart #huberman #hubermanlabpodcast #hubermanlab #publicspeaking #speakingtips",
  "created_at": "2025-12-01T18:19:13Z",
  "created_at_timestamp": 1764613153,
  "duration_s": 36,
  "plays": 99900,
  "likes": 4794,
  "comments": 27,
  "shares": 155,
  "saves": 790,
  "is_pinned": null,
  "is_photo_post": false,
  "image_count": null,
  "text_language": "en",
  "music_id": "7578959635990858527",
  "music_title": "original sound - Andrew Huberman",
  "music_author": "Andrew Huberman",
  "music_original": true,
  "music_url": "https://v16m.tiktokcdn-us.com/7c94ea1a0315b6e1d35f4297bb29dfe1/6ad1e130/video/tos/useast8/tos-useast8-v-27dcd7-tx2/ocB3CI6P04iPiOEtA5iiA4BSKlBEvgPMIof6qu/?a=1233&bti=ODszNWYuMDE6&&bt=125&ft=GSDrKInz7ThzpqKGXq8Zmo&mime_type=audio_mpeg&rc=OWlnODM7Zmg6aTxkaWk4aEBpamhtM3M5cjx3NzMzaTU8NEA2NC5fNC8xNS8xXjY2M2FiYSM0aDVoMmRjbGphLS1kMTJzcw%3D%3D&vvpl=1&l=20261016023211F8091DC3120DD73AE098&btag=e000c8000",
  "cover_url": "https://p19-common-sign.tiktokcdn-us.com/tos-useast8-p-0068-tx2/os0AKNtRMBLEuAiKxiwI1BkAAlBaAxfAT43BiP~tplv-tiktokx-origin.image?dr=9636&x-expires=1792288800&x-signature=Q%2BZGwmpRLfWC%2BK5Kz5IP%2BIzBHWU%3D&t=4d5b0474&ps=13740610&shp=81f88b70&shcp=43f4a2f9&idc=useast8",
  "play_url": "https://v16-webapp-prime.us.tiktok.com/video/tos/useast8/tos-useast8-ve-0068c003-tx2/oADhKE0QbE3WfFMEUqofEfQZhPDRCrA2tSI7AI/?a=1988&bti=ODszNWYuMDE6&&bt=710&ft=4KJMyMzm8Zmo0ZFjIa4jV6tZQpWrKsd.&mime_type=video_mp4&rc=OTlmaThpaDtnODc6MzM2OkBpam52bnQ5cnl3NzMzaTgzNEBiMjIyMl8yNS8xMGMvMzZjYSM1cjRhMmRzamphLS1kLzJzcw%3D%3D&expire=1792290768&l=20261016023211F8091DC3120DD73AE098&ply_type=2&policy=2&signature=47ef8078de46f5e3806d40ade6d3171f&tk=tt_chain_token&btag=e00088000",
  "media_expires_at": "2026-10-18T02:00:00Z",
  "transcript": "Apologizing is a way of trying to connect, yet I think it can backfire. So instead of saying, I'm sorry or I didn't do work, start with something that you're confident in. And if you do need to apologize because there's something you didn't do or you need to do, have that come later. The way you start an interaction sets the tone for the whole interaction. And if I start by putting myself one down because I apologize, oh, I do, I'm not prepared, or I should have done this differently, then our interaction is going to be very different. So start with something that you're comfortable with and confident in, and then if you need to apologize, apologize later. It really makes a big difference.",
  "transcript_source": "caption",
  "transcript_model": null,
  "word_count": 127,
  "language": "en",
  "language_name": "English",
  "language_source": "caption",
  "caption_language": "eng-US",
  "caption_source": "ASR",
  "caption_tracks": [
    {
      "language": "eng-US",
      "language_code": "en",
      "source": "ASR"
    }
  ],
  "caption_text": null,
  "segments": [
    {
      "start": 0.02,
      "end": 4.0,
      "text": "Apologizing is a way of trying to connect,"
    },
    {
      "start": 4.74,
      "end": 6.34,
      "text": "yet I think it can backfire."
    },
    {
      "start": 6.341,
      "end": 9.701,
      "text": "So instead of saying, I'm sorry or I didn't do work,"
    },
    {
      "start": 9.702,
      "end": 11.981,
      "text": "start with something that you're confident in."
    },
    {
      "start": 11.982,
      "end": 13.421,
      "text": "And if you do need to apologize"
    },
    {
      "start": 13.422,
      "end": 15.741,
      "text": "because there's something you didn't do or you need to do,"
    },
    {
      "start": 15.742,
      "end": 19.401,
      "text": "have that come later. The way you start an interaction"
    },
    {
      "start": 19.78,
      "end": 21.5,
      "text": "sets the tone for the whole interaction."
    },
    {
      "start": 21.501,
      "end": 24.661,
      "text": "And if I start by putting myself one down because I apologize,"
    },
    {
      "start": 24.662,
      "end": 25.021,
      "text": "oh, I do,"
    },
    {
      "start": 25.022,
      "end": 27.201,
      "text": "I'm not prepared, or I should have done this differently,"
    },
    {
      "start": 27.46,
      "end": 29.74,
      "text": "then our interaction is going to be very different."
    },
    {
      "start": 29.741,
      "end": 32.581,
      "text": "So start with something that you're comfortable with and confident in,"
    },
    {
      "start": 32.582,
      "end": 34.021,
      "text": "and then if you need to apologize,"
    },
    {
      "start": 34.022,
      "end": 36.47,
      "text": "apologize later. It really makes a big difference."
    }
  ],
  "segment_count": 15,
  "words": null,
  "srt": "1\n00:00:00,020 --> 00:00:04,000\nApologizing is a way of trying to connect,\n\n2\n00:00:04,740 --> 00:00:06,340\nyet I think it can backfire.\n\n3\n00:00:06,341 --> 00:00:09,701\nSo instead of saying, I'm sorry or I didn't do work,\n\n4\n00:00:09,702 --> 00:00:11,981\nstart with something that you're confident in.\n\n5\n00:00:11,982 --> 00:00:13,421\nAnd if you do need to apologize\n\n6\n00:00:13,422 --> 00:00:15,741\nbecause there's something you didn't do or you need to do,\n\n7\n00:00:15,742 --> 00:00:19,401\nhave that come later. The way you start an interaction\n\n8\n00:00:19,780 --> 00:00:21,500\nsets the tone for the whole interaction.\n\n9\n00:00:21,501 --> 00:00:24,661\nAnd if I start by putting myself one down because I apologize,\n\n10\n00:00:24,662 --> 00:00:25,021\noh, I do,\n\n11\n00:00:25,022 --> 00:00:27,201\nI'm not prepared, or I should have done this differently,\n\n12\n00:00:27,460 --> 00:00:29,740\nthen our interaction is going to be very different.\n\n13\n00:00:29,741 --> 00:00:32,581\nSo start with something that you're comfortable with and confident in,\n\n14\n00:00:32,582 --> 00:00:34,021\nand then if you need to apologize,\n\n15\n00:00:34,022 --> 00:00:36,470\napologize later. It really makes a big difference.\n",
  "vtt": "WEBVTT\n\n00:00:00.020 --> 00:00:04.000\nApologizing is a way of trying to connect,\n\n00:00:04.740 --> 00:00:06.340\nyet I think it can backfire.\n\n00:00:06.341 --> 00:00:09.701\nSo instead of saying, I'm sorry or I didn't do work,\n\n00:00:09.702 --> 00:00:11.981\nstart with something that you're confident in.\n\n00:00:11.982 --> 00:00:13.421\nAnd if you do need to apologize\n\n00:00:13.422 --> 00:00:15.741\nbecause there's something you didn't do or you need to do,\n\n00:00:15.742 --> 00:00:19.401\nhave that come later. The way you start an interaction\n\n00:00:19.780 --> 00:00:21.500\nsets the tone for the whole interaction.\n\n00:00:21.501 --> 00:00:24.661\nAnd if I start by putting myself one down because I apologize,\n\n00:00:24.662 --> 00:00:25.021\noh, I do,\n\n00:00:25.022 --> 00:00:27.201\nI'm not prepared, or I should have done this differently,\n\n00:00:27.460 --> 00:00:29.740\nthen our interaction is going to be very different.\n\n00:00:29.741 --> 00:00:32.581\nSo start with something that you're comfortable with and confident in,\n\n00:00:32.582 --> 00:00:34.021\nand then if you need to apologize,\n\n00:00:34.022 --> 00:00:36.470\napologize later. It really makes a big difference.\n",
  "speech_seconds": null,
  "timing_model": null,
  "hook_3s": "Apologizing is a way of trying to connect,",
  "hook_start_s": null,
  "hook_source": "caption_cues",
  "translations": null,
  "translation_count": null,
  "audio_source": null,
  "audio_url": null,
  "transcript_from": "live",
  "transcribed_at": null,
  "long_video_minutes": null,
  "timestamp_minutes": null,
  "walk_units": null,
  "media_files": null,
  "video_file_url": null,
  "is_new": null,
  "first_seen_at": null,
  "read_country": "US",
  "notes": [
    "caption read proxy"
  ],
  "scraped_at": "2026-10-16T02:32:09Z"
}
```

### Pricing

**Pay only for the transcripts a run delivers**, with no start fee and no platform usage billed to you. Prices fall with your Apify plan.

| per 1,000 | Free | Starter | Scale | Business |
|---|---|---|---|---|
| Caption transcript (`caption_transcript`) | $3.00 | $2.55 | $2.14 | $2.14 |
| AI transcript (`ai_transcript`) | $28.79 | $24.47 | $20.15 | $14.49 |
| Caption translation (`caption_language`) | +$2.00 | +$1.70 | +$1.40 | +$1.00 |
| Video file (`media_file`) | +$1.84 | +$1.84 | +$1.84 | +$1.84 |

| per unit | Free | Starter | Scale | Business |
|---|---|---|---|---|
| Long video minute (`long_video_minute`) | +$0.015 | +$0.01275 | +$0.0105 | +$0.00926 |
| Timestamps, per minute (`timestamps`) | +$0.02 | +$0.0185 | +$0.0185 | +$0.0185 |
| Video walk (`video_walk`) | +$0.10 | +$0.10 | +$0.10 | +$0.10 |

A video with TikTok's own caption track is **$3.00 per 1,000** on the Free plan ($0.003 a video) and $2.14 on Business, whatever its length. A video whose words come from the speech model is $28.79 per 1,000 on Free ($0.02879 a video) and $14.49 on Business, the first minute of audio included. **A walk is $0.10 per creator handle whose list is read past the 10 newest videos** (`maxVideosPerHandle` over 10), once per handle on every plan; **a walk that gets no list from TikTok is not charged** (the run says PARTIAL and the embed's 10 newest stand in). `+` fees are charged **only on a row that carries the thing**: a long-video minute per started minute of audio past the first on a model row; timestamps per started minute of audio on a model row that carries the timed words (with `timestamps` on); a caption translation per machine-translated track delivered (with `captionLanguages`); a video file per file written to the run's store this run (with `downloadMedia`); a video walk once per creator whose list is read past the newest ten (`maxVideosPerHandle` over 10, or a date window past them), charged on that creator's first charged row. A video without speech, a photo post, a video without a track when the model is off, a removed or private video, a creator with no videos, a bad value, a model failure and a video `onlyNew` skipped are labelled rows and free; so is a run that failed. `maxItems` counts transcript rows and is exact; before the first request the log says the most the run can cost at your plan's prices and names the fees of the options you switched on. Enterprise plans have their own rates — the Actor's Pricing tab shows the price for your plan.

### Usage patterns

- **Captions first, the model where there is none** — By default (`aiTranscription: fallback`) a video with a TikTok caption track gets that track (the `caption_transcript` event) and a video without one goes to the speech model (the `ai_transcript` event, priced apart). `always` sends every video to the model and keeps TikTok's track beside as `caption_text`; `off` delivers captions only and makes a video without one a free `no_caption` row. In every mode a video the model hears no speech in is a free `no_speech` row, and a photo post a free `photo_post` row.
- **A creator feed** — Save a task with a creator, `onlyNew` and `maxVideosPerHandle`, and schedule it daily: each run lists the creator's newest videos and delivers and charges only those it has not delivered before — a day without a new video delivers nothing. A video without words is remembered too (its verdict is final); a video that could not be read is tried again on the next run. Apify's integrations send the rows to a sheet, a webhook or Slack.
- **Depth: more than the ten newest** — Up to ten videos per creator come from TikTok's embed with no browser. `maxVideosPerHandle` above ten walks the creator's video list in a browser (one `video_walk` event, $0.10 per creator handle, the run at 2,048 MB; a walk that gets no list from TikTok is not charged and the embed's 10 newest stand in) and reads the captions from the list itself; the run summary says how many videos the walk listed against TikTok's own count (COMPLETE or TRUNCATED). A date window that reaches past the ten newest needs `maxVideosPerHandle` above ten to walk; at ten or less the row says so.
- **Subtitles, translations and files** — A caption row carries TikTok's own timed cues as `segments`, the raw WebVTT as `vtt` and an SRT built from the cues; `captionLanguages` adds TikTok's machine-translated track per language in `translations` (text and WebVTT) where TikTok has one; `timestamps: true` adds the timed words and the timing model's segments on a model row; `downloadMedia: "video"` writes the video file to the run's key-value store with a link that does not expire (`video_file_url`). TikTok's own media links on the row (`play_url`, `music_url`, `cover_url`) expire within hours: `media_expires_at` says when.
- **From another scraper** — Give `datasetId` the dataset of a TikTok scraper run (the clockworks scrapers, scrape-creators, dz_omar and the like): every row is scanned for a video link (`webVideoUrl`, `url`, `videoUrl`) or a video `id`, and the videos are transcribed with their metadata read live. With Apify's 'Connect actor' integration the id is filled automatically.
- **Many videos** — Paste any number of video links, ids or creators; `maxItems` bounds the charged rows across every input. The run reads videos on several exits at once and transcribes them in parallel; the log line at the start says the most the run can cost at your plan's prices.

### Input configuration

| field | type | default | what it does |
|---|---|---|---|
| `videoUrls` | `array` | `[]` | TikTok videos, one per line, in any form: `https://www.tiktok.com/@hubermanlab/video/7578955632024309022`, a `/photo/` link, a bare 19-digit video id (the `video_id` column of an earlier run), a share or short link (`vm.tiktok.com/…`, `tiktok.com/t/…`: followed to the video it lands on). A creator's handle pasted here (`garyvee`, `@garyvee`) is read as a creator. The same video given several ways is one row. |
| `handles` | `array` | `[]` | TikTok creators, one per line: `garyvee`, `@garyvee`, `https://www.tiktok.com/@garyvee` or a share link to a profile. The Actor lists each creator's newest videos by posting date (a video pinned to the top of the profile is told apart and left out unless `includePinned` is on) and transcribes up to `maxVideosPerHandle` of them. A missing or private creator is a free row. |
| `datasetId` | `string` | `""` | The dataset id of another TikTok scraper's run (the clockworks scrapers, scrape-creators, dz_omar and the like): every row is scanned for a video link (`webVideoUrl`, `url`, `videoUrl`) or a video `id` and transcribed. Empty = none. |
| `maxVideosPerHandle` | `integer` | `10` | How many of each creator's newest videos to transcribe, newest by posting date, pinned videos not counted. Up to 10 come from TikTok's embed; more are read by walking the creator's video list in a browser, with a COMPLETE or TRUNCATED verdict against TikTok's own count in the run summary. A video without words counts (it is a free row); a video outside the date window does not. |
| `includePinned` | `boolean` | `false` | Creators only: also deliver the videos pinned to the top of the profile (up to 3), flagged `is_pinned`, besides the newest `maxVideosPerHandle`. Off = the newest videos by date only; a pinned video is never taken for one of the newest. |
| `dateFrom` | `string` | `""` | Creators only: videos posted on or after this date (`2026-09-01`), or a span back from today (`30 days`, `2 weeks`, `6 months`). The embed serves a creator's 10 newest: a window that reaches past them needs `maxVideosPerHandle` above 10 (the browser walk, which stops at the first video older than this). |
| `dateTo` | `string` | `""` | Creators only: videos posted on or before this date (same forms as `dateFrom`). |
| `aiTranscription` | `fallback` / `always` / `off` | `"fallback"` | Where the words come from when TikTok has no caption track for a video (77 % of ordinary clips): `fallback` sends the video's audio to a speech model and charges the `ai_transcript` event; `always` sends every video to the model (the most consistent words across a creator's videos; TikTok's own track, when there is one, rides along as `caption_text`); `off` delivers TikTok's captions only and makes a video without one a free `no_caption` row. A video the model hears no speech in is a free `no_speech` row in every mode. |
| `captionLanguages` | `array` | `[]` | ISO 639-1 codes (`es`, `fr`, `pt`…), up to 5: TikTok's machine-translated caption track in each language, with its text and WebVTT in `translations` (one more page read per language, charged per track delivered). TikTok offers translations for some languages per video, not all: a language it has no track for is said in `notes`, free. |
| `timestamps` | `boolean` | `false` | On a row the speech model transcribed: add the timed words (`words`), the model's timed segments, `srt` and `vtt`, the speech seconds and the hook's start second, and the spoken language as named by the timing model (OpenAI whisper-1). Charged per started minute of audio on top of the `ai_transcript` event. A caption row carries TikTok's own timed cues, SRT and WebVTT in every mode. |
| `downloadMedia` | `none` / `video` | `"none"` | Store the video file in the run's key-value store, with a link that does not expire on the row (`video_file_url`). Charged per file written. TikTok's own media links on the row expire within hours (`media_expires_at`). |
| `readCountry` | `US` / `GB` / `DE` / `FR` | `"US"` | The country the video page is read from. TikTok shows the comment count of the reader's region (32 from the US and 35 from Germany on one video in the same minute); every row says which country read it (`read_country`). Canada and Brazil are not offered: TikTok refuses visitors from them on the first call. |
| `maxItems` | `integer` | `0` | The most transcripts this run delivers (and charges), captions and model rows together. 0 = no limit. Free rows (no speech, photo posts, not found) do not count. |
| `onlyNew` | `boolean` | `false` | Skip videos this task delivered in an earlier run: run a creator daily and get only the new videos. A video without words is remembered too (its verdict is final); a video that could not be read is tried again next run. Turns the memory on. |
| `stateStoreName` | `string` | `""` | Name of the memory between runs (`onlyNew`, `is_new`, `first_seen_at`). Empty = derived from your videos and creators, so the same task always meets its own memory. |
| `proxyUrl` | `string` |  | Optional. Your own proxy as `scheme://user:pass@host:port`, used instead of the Actor's own residential exits for TikTok's pages (Apify's residential proxy stays the fallback when yours fails). Leave empty to use the maintained exits (recommended). |

### Errors

**A run does not fail because of your input.** When an input cannot be used — a date that is not a date, an id in the wrong form, two settings that contradict each other — the run ends **Succeeded**, charges nothing, and says what to change:

- the status message starts with `INPUT REJECTED`;
- the dataset holds one row, `{"error": true, "code": "…", "message": "…"}`, and no results;
- the key-value store record `ERROR` holds the same object.

From code, check `error` on the first row before reading results.

A search that matches nothing is not an error: the dataset is empty and nothing is charged. A run that ends **Failed** is a fault on our side, never your input; it charges nothing, and we are alerted.

### FAQ

**Is it TikTok's caption or the spoken words?**

Both, labelled. Where TikTok has a caption track the row carries it (`transcript_source` = `caption`, `caption_source` = ASR for TikTok's speech recognition, MT for a machine translation, LC for the creator's own captions). Where TikTok has none the speech model's words are the transcript (`transcript_source` = `model`, `transcript_model` names it). The video's written description is on the row too, as `description`, clearly apart.

**What about a music-only clip, a photo post or a video without a track?**

A free row with the video's metadata: `no_speech` when the speech model hears no words (music-only, silent, or a fragment too short to be speech), `photo_post` for a slideshow of images (no spoken track; with `aiTranscription: always` a slideshow with the creator's own sound goes to the model), `no_caption` for a video without a TikTok track when the speech model is off.

**Which counts are exact?**

Every count is what TikTok's web page shows (`statsV2`): exact up to 10,000 and rounded by TikTok above it — likes 45,200 where the app shows 45,171, plays 99,900 where it shows 99,885, followers 668,000. The comment count also depends on the reader's region (32 from the US and 35 from Germany on one video in the same minute): `readCountry` picks it and `read_country` says it on every row. No logged-out page serves the app's unrounded figures.

**Why are pinned videos not among the newest?**

A creator pins up to three videos to the top of the profile, and TikTok lists them first whatever their date (one of garyvee's is from 2021). Asked for the two newest, three rivals returned the pinned ones. Here the newest N count unpinned videos only; `includePinned` adds the pinned ones, flagged `is_pinned`, in their date position.

**Which languages do the translations come in?**

TikTok's own machine-translated caption tracks, where TikTok offers them for the video: on our test video Spanish and French were offered, German, Portuguese, Italian and Japanese were not. A requested language TikTok has no track for is named in `notes` and costs nothing. The transcript itself is always the track in the video's own language.

**Why no comment texts, hashtag or keyword lists, or a Canadian or Brazilian view?**

The comment texts sit behind a signed route TikTok serves to its own app only; keyword, hashtag and sound lists are outside this Actor's scope (it reads videos and creators); TikTok refuses visitors from Canada and Brazil on the first call. This Actor reads what a visitor from the US, GB, Germany or France sees, with no account and no cookie beyond the page's own, so it does not promise them.

**What is never charged?**

A video without speech, a photo post, a video without a TikTok track when the speech model is off, a removed or withheld video, a creator with no videos, a bad value, a share link that lands nowhere, an audio the CDN refused, a model failure, a video skipped by `onlyNew`, a video the time limit cut before its transcription, the metadata, and a run that failed before its first row. A run that stops early (a timeout, an abort) is charged for the rows it delivered.

**Are the timestamps exact?**

A caption row carries TikTok's own cue times (`segments`, `srt`, `vtt`): the cues of a 36-second clip start at 0.02 s and end at 36.47 s. A model row carries timed words and segments only with `timestamps: true` (OpenAI whisper-1 gives each word's start and end in seconds); without it the model row has the words alone.

**How long can a video be?**

Any length TikTok allows: the whole audio goes to the model in one call (its upload limit is 25 MB; the original-sound mp3 of a 10-minute video is about 10 MB). The first minute is in the AI transcript price; each started minute past the first is a `long_video_minute` event. A caption row costs the same whatever the length.

**What does the transcript cache do?**

A video the speech model transcribed once is kept on our side by its own video id and model: a later run of the same video, by anyone, gets the same words with no new transcription (`transcript_from` = `cache`, `transcribed_at` = when it was read). The metadata on the row is always this run's live read. TikTok's own captions are read live every run (the page carries the fresh track).

**Can an AI agent use it (MCP)?**

Yes. Apify's MCP server (`https://mcp.apify.com`) offers Store Actors as tools to Claude, Cursor and other MCP clients: add this Actor to the server's tool list and the agent sends `videoUrls` or `handles` and gets the rows back. The synchronous API (`run-sync-get-dataset-items`) does the same in one HTTP call.

### Integration

#### JavaScript

```javascript
import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: 'YOUR_TOKEN' });
const run = await client.actor('hyperbach/tiktok-transcript-scraper').call({"videoUrls": ["https://www.tiktok.com/@hubermanlab/video/7578955632024309022"]});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
```

#### Python

```python
from apify_client import ApifyClient
client = ApifyClient('YOUR_TOKEN')
run = client.actor('hyperbach/tiktok-transcript-scraper').call(run_input={'videoUrls': ['https://www.tiktok.com/@hubermanlab/video/7578955632024309022']})
items = client.dataset(run['defaultDatasetId']).list_items().items
```

#### CLI

```bash
apify call hyperbach/tiktok-transcript-scraper --input '{"videoUrls": ["https://www.tiktok.com/@hubermanlab/video/7578955632024309022"]}'
```

#### REST

```bash
curl -X POST "https://api.apify.com/v2/acts/hyperbach~tiktok-transcript-scraper/run-sync-get-dataset-items?token=YOUR_TOKEN" \
  -H 'Content-Type: application/json' -d '{"videoUrls": ["https://www.tiktok.com/@hubermanlab/video/7578955632024309022"]}'
```

### Support

apify@hyperbach.com

*This page is generated from the Actor's schemas and a live sample — it cannot describe a field the Actor does not have.*

# Actor input Schema

## `videoUrls` (type: `array`):

TikTok videos, one per line, in any form: `https://www.tiktok.com/@hubermanlab/video/7578955632024309022`, a `/photo/` link, a bare 19-digit video id (the `video_id` column of an earlier run), a share or short link (`vm.tiktok.com/…`, `tiktok.com/t/…`: followed to the video it lands on). A creator's handle pasted here (`garyvee`, `@garyvee`) is read as a creator. The same video given several ways is one row.

## `handles` (type: `array`):

TikTok creators, one per line: `garyvee`, `@garyvee`, `https://www.tiktok.com/@garyvee` or a share link to a profile. The Actor lists each creator's newest videos by posting date (a video pinned to the top of the profile is told apart and left out unless `includePinned` is on) and transcribes up to `maxVideosPerHandle` of them. A missing or private creator is a free row.

## `datasetId` (type: `string`):

The dataset id of another TikTok scraper's run (the clockworks scrapers, scrape-creators, dz_omar and the like): every row is scanned for a video link (`webVideoUrl`, `url`, `videoUrl`) or a video `id` and transcribed. Empty = none.

## `maxVideosPerHandle` (type: `integer`):

How many of each creator's newest videos to transcribe, newest by posting date, pinned videos not counted. Up to 10 come from TikTok's embed; more are read by walking the creator's video list in a browser, with a COMPLETE or TRUNCATED verdict against TikTok's own count in the run summary. A video without words counts (it is a free row); a video outside the date window does not.

## `includePinned` (type: `boolean`):

Creators only: also deliver the videos pinned to the top of the profile (up to 3), flagged `is_pinned`, besides the newest `maxVideosPerHandle`. Off = the newest videos by date only; a pinned video is never taken for one of the newest.

## `dateFrom` (type: `string`):

Creators only: videos posted on or after this date (`2026-09-01`), or a span back from today (`30 days`, `2 weeks`, `6 months`). The embed serves a creator's 10 newest: a window that reaches past them needs `maxVideosPerHandle` above 10 (the browser walk, which stops at the first video older than this).

## `dateTo` (type: `string`):

Creators only: videos posted on or before this date (same forms as `dateFrom`).

## `aiTranscription` (type: `string`):

Where the words come from when TikTok has no caption track for a video (77 % of ordinary clips): `fallback` sends the video's audio to a speech model and charges the `ai_transcript` event; `always` sends every video to the model (the most consistent words across a creator's videos; TikTok's own track, when there is one, rides along as `caption_text`); `off` delivers TikTok's captions only and makes a video without one a free `no_caption` row. A video the model hears no speech in is a free `no_speech` row in every mode.

## `captionLanguages` (type: `array`):

ISO 639-1 codes (`es`, `fr`, `pt`…), up to 5: TikTok's machine-translated caption track in each language, with its text and WebVTT in `translations` (one more page read per language, charged per track delivered). TikTok offers translations for some languages per video, not all: a language it has no track for is said in `notes`, free.

## `timestamps` (type: `boolean`):

On a row the speech model transcribed: add the timed words (`words`), the model's timed segments, `srt` and `vtt`, the speech seconds and the hook's start second, and the spoken language as named by the timing model (OpenAI whisper-1). Charged per started minute of audio on top of the `ai_transcript` event. A caption row carries TikTok's own timed cues, SRT and WebVTT in every mode.

## `downloadMedia` (type: `string`):

Store the video file in the run's key-value store, with a link that does not expire on the row (`video_file_url`). Charged per file written. TikTok's own media links on the row expire within hours (`media_expires_at`).

## `readCountry` (type: `string`):

The country the video page is read from. TikTok shows the comment count of the reader's region (32 from the US and 35 from Germany on one video in the same minute); every row says which country read it (`read_country`). Canada and Brazil are not offered: TikTok refuses visitors from them on the first call.

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

The most transcripts this run delivers (and charges), captions and model rows together. 0 = no limit. Free rows (no speech, photo posts, not found) do not count.

## `onlyNew` (type: `boolean`):

Skip videos this task delivered in an earlier run: run a creator daily and get only the new videos. A video without words is remembered too (its verdict is final); a video that could not be read is tried again next run. Turns the memory on.

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

Name of the memory between runs (`onlyNew`, `is_new`, `first_seen_at`). Empty = derived from your videos and creators, so the same task always meets its own memory.

## `proxyUrl` (type: `string`):

Optional. Your own proxy as `scheme://user:pass@host:port`, used instead of the Actor's own residential exits for TikTok's pages (Apify's residential proxy stays the fallback when yours fails). Leave empty to use the maintained exits (recommended).

## Actor input object example

```json
{
  "videoUrls": [
    "https://www.tiktok.com/@hubermanlab/video/7578955632024309022"
  ],
  "handles": [],
  "datasetId": "",
  "maxVideosPerHandle": 10,
  "includePinned": false,
  "dateFrom": "",
  "dateTo": "",
  "aiTranscription": "fallback",
  "captionLanguages": [],
  "timestamps": false,
  "downloadMedia": "none",
  "readCountry": "US",
  "maxItems": 0,
  "onlyNew": false,
  "stateStoreName": ""
}
```

# Actor output Schema

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

All scraped records in the default dataset. One row per video: the transcript and its labels, the video's metadata, the options you asked for — or, for a video without a transcript, the same row with `status` and `reason` saying why (free):

## `runSummary` (type: `string`):

How the run went: transcripts charged by source (caption / model), free rows by status, per creator what was listed, taken, pinned and the walk's verdict against TikTok's count, the model's calls and cost, the transcript cache's hits, the media's egress per class, the source counters, and what TikTok does not serve logged out.

# 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 = {
    "videoUrls": [
        "https://www.tiktok.com/@hubermanlab/video/7578955632024309022"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("hyperbach/tiktok-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 = { "videoUrls": ["https://www.tiktok.com/@hubermanlab/video/7578955632024309022"] }

# Run the Actor and wait for it to finish
run = client.actor("hyperbach/tiktok-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 '{
  "videoUrls": [
    "https://www.tiktok.com/@hubermanlab/video/7578955632024309022"
  ]
}' |
apify call hyperbach/tiktok-transcript-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,hyperbach/tiktok-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/rHulE7r0YaHyZdSti/builds/51uynNcCOf8raLwAo/openapi.json
