TikTok Video Scraper for extracting view counts, likes, comments, shares, saves, captions, hashtags and music from TikTok.com. This repo has a free TikTok video web scraping script you can run right now, and a TikTok video data API that returns 17 structured fields for one video permalink.
This is the video endpoint on its own. The TikTok Scraper repo covers the rest of the surface.
Last updated: 2026-07-20. Working against TikTok.com as of July 2026, and re-verified whenever TikTok changes their markup.
Every JSON block on this page was captured from the live API on 2026-07-20. Long arrays are trimmed and long strings truncated where marked, and each block says exactly what was cut; the fields shown are verbatim. Full uncut samples are committed in tiktok_video_scraper_api_data/. Every code example calls the actual API and is runnable from tiktok_video_scraper_api_codes/.
pip install requests
export CHOCODATA_API_KEY="your_key" # free: 1,000 requests, one-time, no card
python tiktok_video_scraper_api_codes/video.pyThose three lines return this, live from TikTok.com:
{
"id": "7459895174467177774",
"author": { "uniqueId": "duolingo", "verified": true },
"caption": "just some good phrases to keep in your back pocket π«°π #duolingo #rednote",
"created_at": "2025-01-14T22:03:27.000Z",
"stats": { "plays": 18800000, "likes": 1400000, "comments": 15100, "shares": 256200, "saves": 112340 },
"hashtags": ["duolingo", "rednote"],
"video": { "duration": 25, "width": 576, "height": 1024 }
}That is 7 of the 17 fields, with the nested objects cut down. Point it at any list of public video URLs and you get a row each:
That is the whole point of this repo. The rest of this page is the reference: the parameters, the full response, and the things worth knowing about those numbers before you build on them.
- Free TikTok Video Scraper
- Avoid getting blocked when scraping TikTok videos
- TikTok Video Scraper API reference
- Enrich a list of TikTok video URLs with views, likes and saves
- Measured latency
- License
TikTok server-renders the video page's state into a JSON blob inside a <script id="__UNIVERSAL_DATA_FOR_REHYDRATION__"> tag, so you can extract structured video data without a headless browser, JavaScript rendering or a login. No key, no cost:
python free_scraper/tiktok_video_free_scraper.py https://www.tiktok.com/@duolingo/video/7459895174467177774
python free_scraper/tiktok_video_free_scraper.py 7459895174467177774 --author duolingoSource: free_scraper/tiktok_video_free_scraper.py. It locates the rehydration blob, JSON-parses it, and reads the item struct out of the webapp.video-detail scope, falling back to webapp.reflow.video.detail.
It works, and the User-Agent does not matter here. Measured on 2026-07-20 from a residential connection across 6 brand videos, 2 rounds each: 12 of 12 fetches with a browser User-Agent returned HTTP 200 and parsed, and 12 of 12 with the default python-requests User-Agent did too. Response sizes ran 309,310 to 388,017 bytes, every one a different byte count, so those are 24 independent fetches rather than a cached page served back.
That is worth stating plainly because the profile page behaves differently: a bare python-requests client gets a 403 on /@handle while the same client gets a 200 on /@handle/video/<id>. On video pages you do not need to dress up the request.
The script parses first and only reports a failure when the item struct is genuinely absent. The string captcha appears in the JavaScript bundle of a perfectly healthy TikTok video page, so a detector that greps the whole body for it reports a block on a page that parsed fine.
What actually goes wrong on TikTok video pages is a different problem, and it is the subject of the next section.
Getting blocked is not the interesting failure mode on a video page. The numbers are. All of the following are measured against TikTok on 2026-07-20, across 14 videos from public brand accounts.
Four of the five engagement counters lose precision, and the loss starts exactly at 10,000.
plays, likes, comments and shares land on a 100 boundary once the value passes 10,000: 33 of 33 values at or above 10,000 did, and 0 of the 23 values below 10,000 did. So 1,691 comments is the real number and 1,400,000 likes is 1.4M read back as an integer. Nothing in the response marks the difference.
saves is the exception. It is the one counter that keeps every digit at every size: 17,524, 29,859, 30,163, 94,761 and 112,340 were the five values above 10,000 in the sample, and none of them landed on a boundary. It also arrives typed differently at source, which is the tell: TikTok ships collectCount as a string while its four siblings are integers.
There is no exact alternative to fall back to. On a profile, TikTok publishes rounded counts in stats and exact ones in statsV2. On a video page it publishes a statsV2 block whose values are identical to stats, just string-typed. We read that straight off the page for two of the videos in this sample and the numbers matched field for field, so the endpoint returns statsV2: null here rather than a copy that would imply precision it does not carry.
The rounded counters also move when nothing has happened. The same video called 10 times in 30 seconds returned comments as 15,100 on eight calls and 15,400 on two, moving down as well as up, while plays, likes, shares and saves returned the same value on all 10. Diff two consecutive reads and you will record a 300-comment swing that did not occur.
| What bites you | Why | What it costs you |
|---|---|---|
| The counters round above 10,000 | plays, likes, comments and shares land on a 100 boundary once past 10,000, on 33 of 33 values sampled. |
Growth maths on a video with 1.4M likes is good to a few significant figures, and nothing raises an error to tell you. |
saves is typed differently from its siblings |
TikTok ships collectCount as a string while the other four are integers. |
Cast the block uniformly and you either throw or silently stringify a number you then sort on. |
| A rounded counter oscillates between buckets | Two distinct comments values across 10 calls in 30 seconds, moving in both directions. |
A polling job that diffs consecutive reads invents engagement that never happened. |
statsV2 is null on this endpoint |
The profile surface populates it; the video page's own copy duplicates the rounded numbers, so there is nothing exact to pass through. | Code ported from the profile endpoint reads None on every video. |
| Every image URL in a response shares one expiry | thumbnail, images[], video.cover, video.dynamic_cover and author.avatar all carried x-expires of 2026-07-22T20:00:00Z across all 6 videos sampled that day, about 48 hours out and on the hour. |
Cache the URLs from a batch job and they do not age out one by one. They stop together. |
| The stream URL is not the image URL | video.play_addr signs with expire + signature + policy rather than x-expires. Fetched from a plain client, the thumbnail returned 200 and the JPEG bytes while play_addr returned 403, with 48 hours still on its clock. |
A pipeline that treats every URL in the response as equally fetchable half-fails, and the half that fails is the video. |
hashtags is not the caption's casing |
The array carries TikTok's canonical tag titles. 15 of 36 tokens differed in case from the #tag in the caption, in both directions (#MTB came back mtb, #languagelearning came back LanguageLearning). |
Case-sensitive matching against your own tag list drops rows without complaining. |
The managed option, and the one this repo is built around: the Chocodata TikTok Video Scraper API. One GET per video URL, 17 named fields of TikTok video data extraction at scale, a ~99% success rate, and no proxy management. The item struct is resolved from whichever location TikTok used on that render, collectCount is normalised to an integer, hashtags are collected from the structured tag list, and the cover frames are assembled into one images array. Free for the first 1,000 requests.
Below is the TikTok Video Scraper API reference to get you started: authentication, the error bodies, the rate limits, and the endpoint itself.
curl "https://api.chocodata.com/api/v1/tiktok/video?api_key=YOUR_KEY&url=https://www.tiktok.com/@duolingo/video/7459895174467177774"import requests
r = requests.get(
"https://api.chocodata.com/api/v1/tiktok/video",
params={"api_key": "YOUR_KEY",
"url": "https://www.tiktok.com/@duolingo/video/7459895174467177774"},
timeout=90,
)
v = r.json()
print(v["author"]["uniqueId"], v["stats"]["plays"], v["stats"]["saves"])
# duolingo 18800000 112340After running the command, your terminal should look something like this:
That run reports comments as 15,400 where the JSON block further down reads 15,100. Both are real readings of the same video minutes apart, and that oscillation is the behaviour described above rather than 300 new comments.
Pass your key as the api_key query parameter. There is no header form and no OAuth step.
https://api.chocodata.com/api/v1/tiktok/video?api_key=YOUR_KEY&url=...
A free key is 1,000 requests, one time, with no card. Get one at chocodata.com.
Nothing below is billed: you are only charged on a 2xx.
| Status | error code |
Meaning | Billed | What to do |
|---|---|---|---|---|
400 |
invalid_params |
Neither url nor a usable id was supplied. The body names the issue and its path. |
no | Fix the query string. |
401 |
INVALID_API_KEY |
Key missing, unrecognised, or revoked. | no | Check api_key. Get one at chocodata.com. |
402 |
INSUFFICIENT_CREDITS |
Balance exhausted. | no | Top up or upgrade. |
429 |
RATE_LIMITED |
Over your plan's concurrency. | no | Back off and retry. |
502 |
extraction_failed |
TikTok did not serve this one. A deleted, private or nonexistent video id lands here too. | no | Retry once after a few seconds, then treat it as gone. |
There is no 404. A video id that never existed and one TikTok declined to render both return 502, because from outside they look the same. We ran https://example.com through the url parameter as a control and it returned 502 as well, rather than a 200 carrying some other page's contents under the TikTok schema, so a wrong URL fails loudly instead of quietly.
A bad key, verbatim:
curl "https://api.chocodata.com/api/v1/tiktok/video?api_key=totally_invalid_key_123&url=https://www.tiktok.com/@duolingo/video/7459895174467177774"{"error":{"code":"INVALID_API_KEY","message":"Api key not recognised."}}A call with no video identifier, verbatim. Note that the message names both accepted forms, which is the fastest way to discover that a bare id needs an author beside it:
{"error":"invalid_params","issues":[{"code":"custom","message":"provide url, or id (ideally with author)","path":[]}]}Two response shapes exist: auth and billing errors nest under error.code (uppercase), while scrape-layer errors are flat with a lowercase error string.
The scripts in this repo turn each documented status into an actionable message, so a typo'd key does not hand you a stack trace:
Build the retry in. A 502 here is retryable and uncharged. enrich_url_list.py retries once after 8 seconds before giving up on a URL, and that is the pattern to copy.
Two limits apply and they are enforced independently.
| Limit | Value |
|---|---|
| Requests per key | 120 per 60 seconds (sliding window) |
| Concurrent requests, Free | 10 |
| Concurrent requests, Vibe | 30 |
| Concurrent requests, Pro | 50 |
| Concurrent requests, Custom | 100 to 500+ |
Exceed either and you get 429, not a queue. Every call is a synchronous GET: there is no webhook, callback or async job to poll. The examples use timeout=90 because the slowest call in the latency run below took 3.81s and you want headroom for a retried one.
Fan out with a thread pool sized to stay inside both limits at once:
from concurrent.futures import ThreadPoolExecutor
import requests
def one(url):
r = requests.get("https://api.chocodata.com/api/v1/tiktok/video",
params={"api_key": KEY, "url": url}, timeout=90)
return r.json() if r.status_code == 200 else None
with ThreadPoolExecutor(max_workers=8) as pool:
videos = [v for v in pool.map(one, urls) if v]The full record for one public TikTok video: engagement counts, the caption and its hashtags, the creator, the audio track, dimensions, duration, and the signed cover and stream URLs.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
url |
string (URL) | one of url/id |
- | Full video permalink. Use this one. Any query string on it is stripped. |
id |
string (digits) | one of url/id |
- | Bare numeric video id. Pass author with it: an id on its own returned 502 on the video we tested it against, because the canonical permalink needs the handle. |
author |
string | no | - | The @handle, used with id to build the permalink. A leading @ is stripped for you. |
country |
string (ISO-2) | no | us |
Egress location. Pin it and keep it constant across a time series. |
api_key |
string | yes | - | Your key. Query parameter, not a header. |
curl "https://api.chocodata.com/api/v1/tiktok/video?api_key=YOUR_KEY&url=https://www.tiktok.com/@duolingo/video/7459895174467177774"Real response. images is cut to 1 of 4 and the signed CDN URLs are truncated where marked; every object is complete and all 17 top-level fields are verbatim (full sample):
{
"id": "7459895174467177774",
"url": "https://www.tiktok.com/@duolingo/video/7459895174467177774",
"socialPlatform": "tiktok",
"title": "just some good phrases to keep in your back pocket π«°π #duolingo #rednote",
"description": "just some good phrases to keep in your back pocket π«°π #duolingo #rednote",
"caption": "just some good phrases to keep in your back pocket π«°π #duolingo #rednote",
"create_time": 1736892207,
"created_at": "2025-01-14T22:03:27.000Z",
"author": {
"id": "6917704832925746181",
"uniqueId": "duolingo",
"nickname": "Duolingo",
"verified": true,
"signature": "hot owl summer π₯π¦",
"avatar": "https://p16-common-sign.tiktokcdn-us.com/tos-useast5-avt-0068-tx...",
"secUid": "MS4wLjABAAAAxzJGbseZk0TDBgT88nlMt8s4rjFp-Um-B7X545vT-MaVZiZEWVyKmaVwqxntcGgn",
"id_str": "6917704832925746181",
"url": "https://www.tiktok.com/@duolingo"
},
"stats": {
"plays": 18800000,
"likes": 1400000,
"comments": 15100,
"shares": 256200,
"saves": 112340
},
"statsV2": null,
"images": [
"https://p16-common-sign.tiktokcdn-us.com/tos-useast5-p-0068-tx/o..."
],
"thumbnail": "https://p16-common-sign.tiktokcdn-us.com/tos-useast5-p-0068-tx/o...",
"video": {
"duration": 25,
"width": 576,
"height": 1024,
"cover": "https://p16-common-sign.tiktokcdn-us.com/tos-useast5-p-0068-tx/o...",
"dynamic_cover": "https://p19-common-sign.tiktokcdn-us.com/tos-useast5-p-0068-tx/o...",
"play_addr": "https://v16-webapp-prime.us.tiktok.com/video/tos/useast5/tos-use...",
"download_addr": null
},
"music": {
"id": "7459895172415916843",
"title": "original sound",
"authorName": "Duolingo",
"duration": 25,
"cover": "https://p16-common-sign.tiktokcdn-us.com/tos-useast5-avt-0068-tx...",
"play_url": "https://v16m.tiktokcdn-us.com/a494b6197a3c0517d9cd11bfb7bff769/6..."
},
"hashtags": [
"duolingo",
"rednote"
],
"_source": "rehydration"
}stats.saves is the field worth building on, and almost nobody uses it. It is the only counter in the block that keeps every digit at any magnitude, which makes it the one number you can diff across days and trust: a save is also a stronger intent signal than a like, because the viewer is keeping the video rather than reacting to it.
Field notes, each measured across the 14 videos in videos_sampled.json:
title,descriptionandcaptionare the same string. Identical on all 14 videos. Pick one and ignore the other two.hashtagsis parsed from the structured tag list, not from the caption text, so it can differ in case from the#taga reader sees. Lowercase both sides before matching. It came back empty on 2 of 14 videos, which is a video with no tags rather than a parse failure.video.download_addrwasnullon 4 of 14, including the sample above, whileplay_addrwas populated on all 14. Both are signed stream URLs and both returned403to a plain client in testing.music.play_urlwas populated on 13 of 14.music.titleis often the literal string"original sound", in which caseauthorNameis the creator, not a musician.imagesheld 3 or 4 entries on every video sampled. They are cover frames for the video, not a photo carousel.video.widthandvideo.heightwere 576x1024 on all 14, so treat them as a rendition size rather than the upload's true resolution.create_timeis a Unix timestamp andcreated_atis the same instant as ISO 8601. Durations ranged 5s to 101s across the sample._sourcewas"rehydration"on all 14, which tells you the record came from the page's embedded state.
A second committed sample, video_by_id.json, is a different video fetched with id plus author instead of url, so both parameter forms are demonstrated. Called both ways against the same video, the two forms returned the same 17 keys and identical id, url, caption, create_time and stats.
Runnable: tiktok_video_scraper_api_codes/video.py
The job most people arrive with: a campaign report, a competitor sweep or a spreadsheet of links, and a need for one row per video with the numbers filled in.
export CHOCODATA_API_KEY="your_key"
python tiktok_video_scraper_api_codes/enrich_url_list.py my_urls.txtIt reads one URL per line from the file you name, falls back to urls.txt beside the script, and otherwise runs a built-in sample list. Four workers keep it inside both the concurrency cap and the 120 requests/60s limit, a 502 is retried once after 8 seconds, and the rows come out sorted by play count:
The CSV carries video_id, author, created_at, duration_s, plays, likes, comments, shares, saves, saves_exact, hashtags, caption, url. The saves_exact column is computed by precise(), which flags any counter at or above 10,000 that lands on a 100 boundary, so a downstream analyst can see which numbers carry full precision without rereading this page.
Source: tiktok_video_scraper_api_codes/enrich_url_list.py
23 calls to /tiktok/video from a laptop against the live API on 2026-07-20, in three runs: 8 consecutive calls on one video, 5 across different videos, and 10 more on one video. All 23 returned 200.
| Metric | Value |
|---|---|
| calls made | 23 |
200 responses |
23 of 23 |
| median | 1.36s |
| fastest | 1.00s |
| slowest | 3.81s |
23 calls from one machine on one evening is a small sample, which is why the examples set timeout=90 rather than sizing to the median, and why the enrichment script retries a 502 once before giving up. Reproduce it with the scripts in this repo.
MIT. See LICENSE.







