TikTok Post Scraper for extracting view counts, likes, comments, captions, hashtags, content categories and creation region from TikTok.com. This repo has a free TikTok post web scraping script you can run right now, and a TikTok post data API that returns 19 structured fields for one video permalink.
This is the post 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_post_scraper_api_data/. Every code example calls the actual API and is runnable from tiktok_post_scraper_api_codes/.
pip install requests
export CHOCODATA_API_KEY="your_key" # free: 1,000 requests, one-time, no card
python tiktok_post_scraper_api_codes/post.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": 15400, "shares": 256200, "saves": 112340 },
"categories": ["Professional Personal Development", "Education", "Culture & Education & Technology"],
"location": "US"
}That is 7 of the 19 fields, with the nested objects cut down. Point it at any list of public post URLs and you get a row each, classified by TikTok's own categories:
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 Post Scraper
- Avoid getting blocked when scraping TikTok posts
- TikTok Post Scraper API reference
- Bucket a competitor's posts by TikTok's own content categories
- Measured latency
- License
TikTok server-renders the post's state into a JSON blob inside a <script id="__UNIVERSAL_DATA_FOR_REHYDRATION__"> tag, so you can extract structured post data without a headless browser, JavaScript rendering or a login. No key, no cost:
python free_scraper/tiktok_post_free_scraper.py https://www.tiktok.com/@duolingo/video/7459895174467177774
python free_scraper/tiktok_post_free_scraper.py 7459895174467177774 --author duolingoSource: free_scraper/tiktok_post_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 reads the content categories, the location and suggested_words out of that same struct, which is what makes it a post scraper rather than a plain video one.
It works, and the User-Agent does not matter on a post page. Measured on 2026-07-20 from a residential connection: the same post page returned HTTP 200 and parsed with a browser User-Agent (380,498 bytes) and again with the default python-requests User-Agent (379,578 bytes), and the categories and location fields were present both times.
Two things are worth knowing before you blame your parser. First, on the logged-out page the creator's identity (author) and the creator's stats (authorStats) are separate objects, so the follower count is not on author where you might look for it; the API folds it into author for you. Second, 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 post page, so a detector that greps the whole body for it reports a block on a page that parsed fine.
Getting blocked is not the interesting failure mode on a post page, because a single residential fetch parses fine. The numbers are, and so is knowing which endpoint carries what. All of the following is measured against TikTok on 2026-07-20, across the two public brand-account posts committed in posts_sampled.json.
A post response contains no exact number anywhere. plays, likes, comments and shares are TikTok's display values, rounded to a 100 boundary once they pass 10,000: on the @duolingo post, plays is 18,800,000 and likes is 1,400,000, read back from 1.4M. Unlike the video endpoint, post does not even return a statsV2 placeholder, and the creator's author.followerCount is the rounded 17,100,000 rather than the exact figure. For an exact follower or view count you pair this endpoint with the profile endpoint.
categories is the reason to call post. It is TikTok's own classification of the content, not ours and not a guess from the caption: ["Professional Personal Development", "Education", "Culture & Education & Technology"] on the @duolingo post and ["Video Games", "Games", "Entertainment"] on the @tiktok one. That is how you bucket a competitor's output by theme without writing a classifier, and the plain video endpoint does not return it.
| What bites you | Why | What it costs you |
|---|---|---|
| Every count is a rounded display value | plays, likes, comments and shares land on a 100 boundary above 10,000, post carries no statsV2, and author.followerCount is rounded too. |
Engagement maths that is good to three significant figures with no exact field anywhere in the payload to reconcile against. |
saves is the one counter that keeps its digits |
TikTok ships collectCount as a string while its four siblings are integers, and it stayed exact above 10,000 on both posts sampled (112,340 and 58,528). |
The counter you can actually diff day to day is the one most people skip over. |
A bare id is accepted but unreliable |
id alone builds a /v/<id>.html share URL. On the video tested that URL returned 502 while the full permalink returned 200. |
A batch keyed on bare ids silently 502s. Pass the full url. |
music field names differ from the video endpoint |
post returns music.author and music.is_original; the video endpoint returns music.authorName. is_original was null on both posts sampled. |
Code ported from the video endpoint reads None for the artist. |
suggested_words is often empty |
It came back [] on both posts sampled. It is TikTok's related-search list and populates on some posts and not others. |
A pipeline that assumes it is always present drops rows or throws. |
| CDN URLs are signed and expire | images, thumbnail and every video.* URL carry x-expires / expire parameters, about 48 hours out on the post sampled. |
Store the URL for later and it 403s. Fetch what you need promptly. |
The managed option, and the one this repo is built around: the Chocodata TikTok Post Scraper API. One GET per post URL, 19 named fields of TikTok post data extraction at scale, a ~99% success rate, and no proxy management. The item struct is resolved from whichever scope TikTok used on that render, the content categories and location are pulled through, 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 Post 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/post?api_key=YOUR_KEY&url=https://www.tiktok.com/@duolingo/video/7459895174467177774"import requests
r = requests.get(
"https://api.chocodata.com/api/v1/tiktok/post",
params={"api_key": "YOUR_KEY",
"url": "https://www.tiktok.com/@duolingo/video/7459895174467177774"},
timeout=90,
)
p = r.json()
print(p["author"]["uniqueId"], p["categories"], p["location"])
# duolingo ['Professional Personal Development', 'Education', 'Culture & Education & Technology'] USAfter running the command, your terminal should look something like this:
That run reports comments as 15,400 where a later free-scraper read shows 15,500. Both are real readings of the same post minutes apart: the rounded counters oscillate between buckets, so treat any single reading as a snapshot.
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/post?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 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 lands here too. | no | Retry once after a few seconds, then treat it as gone. |
There is no 404. A video 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/post?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:
{"error": "invalid_params", "issues": [{"code": "custom", "message": "url or id is required", "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:
The limit is concurrency: how many requests you may have in flight at once.
| Plan | Concurrent requests |
|---|---|
| Free | 10 |
| Vibe | 30 |
| Pro | 50 |
| Custom | 100 to 500+ |
Exceed it 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 2.68s and a re-attempted one takes longer.
Fan out with a thread pool sized to your plan's concurrency:
from concurrent.futures import ThreadPoolExecutor
import requests
def one(url):
r = requests.get("https://api.chocodata.com/api/v1/tiktok/post",
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: # <= your plan's concurrency
posts = [p for p in pool.map(one, urls) if p]The full record for one public TikTok post: engagement counts, the caption and its hashtags, the creator with their follower stats inline, the audio track, dimensions, the signed media URLs, and TikTok's own content classification plus the creation region.
| 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. It builds a /v/<id>.html share URL, which returned 502 on the video tested, so prefer url. |
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/post?api_key=YOUR_KEY&url=https://www.tiktok.com/@duolingo/video/7459895174467177774"Real response. images is cut to 1 of 3 and the signed CDN URLs are truncated where marked; every object is complete and all 19 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-0...",
"secUid": "MS4wLjABAAAAxzJGbseZk0TDBgT88nlMt8s4rjFp-Um-B7X545vT-MaVZiZEWVyKmaVwqxntcGgn",
"url": "https://www.tiktok.com/@duolingo",
"followerCount": 17100000,
"followingCount": 310,
"heartCount": 482200000,
"videoCount": 1129
},
"stats": {
"plays": 18800000,
"likes": 1400000,
"comments": 15400,
"shares": 256200,
"saves": 112340
},
"images": [
"https://p16-common-sign.tiktokcdn-us.com/tos-useast5-p-006..."
],
"thumbnail": "https://p16-common-sign.tiktokcdn-us.com/tos-useast5-p-006...",
"video": {
"duration": 25,
"width": 576,
"height": 1024,
"cover": "https://p16-common-sign.tiktokcdn-us.com/tos-useast5-p-006...",
"dynamic_cover": "https://p16-common-sign.tiktokcdn-us.com/tos-useast5-p-006...",
"play_addr": "https://v16-webapp-prime.us.tiktok.com/video/tos/useast5/t...",
"download_addr": null
},
"music": {
"id": "7459895172415916843",
"title": "original sound",
"author": "Duolingo",
"duration": 25,
"is_original": null,
"cover": "https://p16-common-sign.tiktokcdn-us.com/tos-useast5-avt-0...",
"play_url": "https://v16m.tiktokcdn-us.com/1df55ff8a29dff47ecdd52b7e192..."
},
"hashtags": ["duolingo", "rednote"],
"categories": ["Professional Personal Development", "Education", "Culture & Education & Technology"],
"suggested_words": [],
"location": "US",
"_source": "rehydration"
}categories is the field that makes this endpoint worth its own call. It is TikTok's classification of the post, so you can group a creator's output by theme without building a classifier. location is the creation region as a two-letter code ("US" here), not a place name.
Field notes, measured across the two posts in posts_sampled.json:
title,descriptionandcaptionare the same string, identical on both posts. Pick one and ignore the other two.- The counts are TikTok's rounded display values, and there is no exact field in a
postpayload.plays,likes,commentsandsharesround to a 100 boundary above 10,000;saveskept every digit (112,340,58,528). For an exact creator follower count, call the profile endpoint and read itsstatsV2. authorcarries the creator's follower stats inline (followerCount,followingCount,heartCount,videoCount), which the plain video endpoint does not. These are the rounded numbers.music.authoris the artist andmusic.is_originalwasnullon both posts. The video endpoint names the same artist fieldmusic.authorName, so do not port the key across.suggested_wordscame back[]on both posts. Code for the empty list.download_addrwasnullon the sample above whileplay_addrwas populated. Both are signed stream URLs that expire._sourcewas"rehydration"on both, which tells you the record came from the page's embedded state.
Runnable: tiktok_post_scraper_api_codes/post.py
The job this endpoint is built for: take a list of a competitor's post URLs and group them by theme, using TikTok's own categories rather than a classifier you have to write and maintain.
export CHOCODATA_API_KEY="your_key"
python tiktok_post_scraper_api_codes/classify_posts.py my_urls.txtIt reads one post 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 the Free plan's concurrency, a 502 is retried once, and the rows come out grouped by primary category:
The CSV carries author, post_id, created_at, primary_category, all_categories, location, plays, plays_precision, likes, saves, hashtags, url. The plays_precision column flags whether the play count is a rounded display value, so a downstream analyst sees which numbers carry full precision without rereading this page.
Source: tiktok_post_scraper_api_codes/classify_posts.py
8 consecutive calls to /tiktok/post from a laptop against the live API on 2026-07-20. All 8 returned 200.
| Metric | Value |
|---|---|
| calls made | 8 |
200 responses |
8 of 8 |
| median | 1.40s |
| fastest | 1.03s |
| slowest | 2.68s |
8 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 classifier retries a 502 once before giving up. Reproduce it with the scripts in this repo.
MIT. See LICENSE.







