TikTok Profile Scraper for extracting follower counts, likes, video counts, bios and the verified flag from TikTok.com profiles. This repo has a free TikTok profile scraping script you can run right now, and a TikTok profile data API that returns one account as 13 parsed JSON fields in under a kilobyte.
Last updated: 2026-07-20. Working against TikTok.com as of July 2026.
pip install requests
export CHOCODATA_API_KEY="your_key" # free: 1,000 requests, one-time, no card
python tiktok_profile_scraper_api_codes/profile.py tiktokThose three lines return this, live from TikTok.com (the identity fields and the complete stats object, 7 of the 13 fields; the full object is below):
{
"uniqueId": "tiktok",
"nickname": "TikTok",
"signature": "One TikTok can make a big impact",
"verified": true,
"id": "107955",
"created_at": "2015-02-28T17:22:29.000Z",
"stats": {
"followerCount": 94900000,
"followingCount": 0,
"heartCount": 461900000,
"videoCount": 1477
}
}...multiplied across a list of accounts, with 13 fields each:
That is the whole point of this repo. The rest of this page is the free script, what actually goes wrong on this target, and the full API reference.
What this repo scrapes. Every handle captured here is a company operating its own TikTok account: @tiktok, @netflix, @nba, @duolingo, @redbull, @chipotle, @sephora, @nfl, @gymshark, @crocs. No private individual's profile is sampled, rendered into an image, or committed to this repo. The endpoint reads whatever public account you point it at, so that choice is yours to make.
This repo covers the profile surface on its own. The other TikTok surfaces are documented in the TikTok Scraper.
- Free TikTok Profile Scraper
- What breaks when you scrape TikTok profiles at scale
- TikTok Profile Scraper API reference
- Track follower counts for a list of TikTok accounts
- Measured latency
- License
TikTok renders the profile as a JavaScript app, but it ships the state that app hydrates from inside the HTML, as a JSON <script id="__UNIVERSAL_DATA_FOR_REHYDRATION__">. Read that script and you have the handle, nickname, bio, verified flag and the follower, like and video counts without running a browser, logging in, or rendering any JavaScript:
python free_scraper/tiktok_profile_free_scraper.py tiktok netflix nba duolingoSource: free_scraper/tiktok_profile_free_scraper.py. It fetches /@<handle>, pulls the webapp.user-detail scope out of that blob, and emits uniqueId, nickname, signature, verified, id, create_time, followerCount, followingCount, heartCount, videoCount.
It works, and it keeps working under load. On 2026-07-20 we made 70 requests from one residential IP: 10 sequential and 4 seconds apart, then 20 across 8 threads, then 40 across 12 threads. All 70 came back HTTP 200 carrying a populated user node.
So the fetch is not the hard part on this target. What you get for it is 290,945 to 361,731 characters of HTML per profile, wrapped around a ~257,000-character JSON blob you have to locate, parse and navigate to reach four numbers. The next section is about what that costs once you are doing it to a list rather than to one handle.
Nothing here is a bot wall. Every failure below arrives as HTTP 200 with a full-sized page, which is precisely what makes them expensive: they do not show up in your error rate. Every row is committed in benchmarks/measurements.json, and benchmarks/measure.py reproduces the table on your own IP.
| What we measured (2026-07-20, one residential IP) | Value |
|---|---|
Requests to /@<handle>, sequential and across 8 and 12 threads |
70 |
| Carried a populated user node | 70 |
| HTML per profile | 290,945 to 361,731 characters |
| The rehydration blob inside it | ~257,000 characters of JSON |
| A handle nobody has registered | HTTP 200, 358,092 characters, no user node |
| Same profile through the API | 810 to 889 bytes of parsed JSON |
The two failures worth designing for both look like success:
| What bites you | Why | What it costs you |
|---|---|---|
| A handle that does not exist returns a full page | We asked for a 23-character handle nobody has registered. TikTok answered HTTP 200 with 358,092 characters, a valid rehydration blob, and a webapp.user-detail scope carrying statusCode 10221 and no user node. A second fetch of the same URL returned 357,972 characters, so page size varies per request the way a real profile's does, and it lands squarely inside the range real profiles occupy. |
Status codes and byte counts cannot tell you the account is missing. Gate on the presence of the user node itself, and treat a typo'd handle and a real one as indistinguishable until you have parsed. |
| The handle is not the brand | Asking for @nasa on 2026-07-20 returned a real profile object under a nickname and bio matching a well-known organisation. The row is committed in tiktok_followers.csv: verified false, account created 4.0 hours before the capture timestamp on the same row, 1 follower and 0 videos. |
An enrichment row comes back populated, plausible and attached to an account nobody has heard of. verified and create_time are the two fields that catch it, and both scripts in this repo check them. |
| The counts are two different numbers | stats.followerCount reads 94,900,000 while statsV2.followerCount reads 94,890,999 on the same response. The first is rounded for display, the second is exact. |
Chart the rounded one and every account under a million looks stable while it moves, and every account over ten million snaps between round numbers. Use statsV2 for anything you plan to diff. |
| The exact counts move between calls | Two files committed in this repo were captured minutes apart in the same session and disagree: profile.json records 94,890,999 and tiktok_followers.csv records 94,890,990. |
These are live counters, not attributes. Store them with the timestamp you read them at, or two rows from different minutes will look like a data quality bug. |
| You are transferring a third of a megabyte to read four numbers | Each profile page is 290,945 to 361,731 characters. The fields you wanted come back from the API as 810 to 889 bytes. | On one handle it does not matter. On 50,000 it is 50,000 fetches of a third-of-a-megabyte document and 50,000 parses of a quarter-million-character blob, before anything is written anywhere. |
| The blob is an internal app state, not an interface | The path to the data is __UNIVERSAL_DATA_FOR_REHYDRATION__ then __DEFAULT_SCOPE__ then webapp.user-detail then userInfo.user. TikTok changes it when the app changes, without notice or a version. |
You maintain a parser against a moving target, and the failure mode is a silent None rather than an exception. Alert on the shape, not on the status code. |
One thing worth reading rather than paraphrasing before you point either method at a list: TikTok's robots.txt (www.tiktok.com/robots.txt, 1,288 bytes, retrieved 2026-07-20). It is more specific than the usual boilerplate and it is not uniform. The file opens with a single block naming 25 crawlers, most of them AI and LLM crawlers, and gives all of them Disallow: /. The User-agent: * block that follows carries 12 Allow rules and 16 Disallow rules: /embed/@, /search/user?q=, */directory/ and /search? are among the disallowed paths, while the profile path /@<handle> appears in neither list. Read the current file yourself, since the answer to "may I fetch this" depends on which path and which client, and the file changes.
The managed option, and the one this repo is built around. The Chocodata TikTok Profile Scraper API returns one public TikTok account as 13 parsed JSON fields, with a ~99% success rate and no page parsing to maintain. Free for the first 1,000 requests.
Below is the TikTok Profile Scraper API reference to get you started: authentication, the global parameter, error handling, concurrency, and the endpoint.
curl "https://api.chocodata.com/api/v1/tiktok/profile?api_key=YOUR_KEY&username=tiktok"import requests
r = requests.get(
"https://api.chocodata.com/api/v1/tiktok/profile",
params={"api_key": "YOUR_KEY", "username": "tiktok"},
timeout=90,
)
p = r.json()
print(p["nickname"], "|", p["stats"]["followerCount"], "followers |", p["verified"])
# TikTok | 94900000 followers | TrueAfter running the command, your terminal should look something like this:
Get a key at chocodata.com (1,000 requests, one-time, no card).
Pass api_key as a query parameter on every request. That is the whole auth model. No OAuth, no TikTok login, no session cookie: you never hand us or TikTok an account.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
api_key |
string | yes | - | Your Chocodata API key. The only parameter every endpoint shares. |
Each request costs 5 credits (= 1 request). Responses are billed only on success (2xx).
Real captured error responses, committed in tiktok_profile_scraper_api_data/errors.json. Nothing below is billed: you are only charged on a 2xx.
| Status | error code |
Meaning | Billed | What to do |
|---|---|---|---|---|
400 |
invalid_params |
Neither username nor url was supplied. |
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 at chocodata.com. |
429 |
RATE_LIMITED |
Over your plan's concurrency. | no | Back off and retry; see Rate limits. |
502 |
extraction_failed |
No profile came back for that handle. | no | Check the handle, then retry after ~8 seconds. |
Two response shapes exist: auth and billing errors nest under error.code (uppercase), while scrape-layer errors are flat with a lowercase error string.
Read 502 on this endpoint as two things at once. A handle nobody registered and a profile that was genuinely unreachable produce the same status, because upstream they produce the same 200-with-no-user-node page (see the table above). If one handle 502s repeatedly, check the spelling before you build a longer backoff.
It refuses politely rather than answering with something wrong. Handing the endpoint a url on another domain entirely, https://example.com, returns 502 rather than a profile object built out of whatever that page contained.
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:
A bad key, verbatim:
curl "https://api.chocodata.com/api/v1/tiktok/profile?api_key=totally_invalid_key_123&username=tiktok"{"error":{"code":"INVALID_API_KEY","message":"Api key not recognised."}}Neither parameter supplied, verbatim. It names the exact requirement:
{"error":"invalid_params","issues":[{"code":"custom","message":"username or url is required","path":[]}]}There is no per-minute request cap. 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. The endpoint is a synchronous GET: there is no webhook, callback, or async job to poll. The examples use timeout=90 so a slow upstream re-attempt does not surface as a client timeout.
Sizing: at Pro (50 concurrent) and the measured 1.09s median, one worker pool sustains roughly 50 / 1.09 = 45 requests/second, so 100,000 accounts is about 37 minutes of saturated pulling. Fan out with a thread pool:
from concurrent.futures import ThreadPoolExecutor
import requests
def one(handle):
r = requests.get("https://api.chocodata.com/api/v1/tiktok/profile",
params={"api_key": KEY, "username": handle}, timeout=90)
return handle, (r.json() if r.ok else None)
handles = ["tiktok", "netflix", "nba", "duolingo"]
with ThreadPoolExecutor(max_workers=10) as pool: # <= your plan's concurrency
for handle, p in pool.map(one, handles):
print(handle, p["stats"]["followerCount"] if p else "unavailable")One public TikTok account: handle, display name, bio, verified flag, account id, creation date, avatar, and the follower, following, like and video counts in both a rounded and an exact form.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
username |
string | one of username/url |
- | The @handle. A leading @ is stripped, so tiktok and @tiktok both work. Use this one. |
url |
string (URL) | one of username/url |
- | A full profile URL such as https://www.tiktok.com/@tiktok. The handle is parsed out of it and the same 13 fields come back. |
Both parameters work and either satisfies the requirement. Prefer username: it is the schema's primary parameter, it is shorter, and it removes the chance of a URL variant resolving somewhere you did not intend. Reach for url when what you already have in hand is a profile link.
Asked both ways back to back, the two responses carried the same 13 keys and the same identity values. Two fields differed, and they differ between any two calls whichever parameter you use: the signed avatar URL, and the live counters in statsV2.
curl "https://api.chocodata.com/api/v1/tiktok/profile?api_key=YOUR_KEY&username=tiktok"Real response, nothing cut. All 13 fields exactly as returned (full sample):
{
"uniqueId": "tiktok",
"nickname": "TikTok",
"signature": "One TikTok can make a big impact",
"verified": true,
"secUid": "MS4wLjABAAAAv7iSuuXDJGDvJkmH_vz1qkDZYo1apxgzaxdBSeIuPiM",
"id": "107955",
"create_time": 1425144149,
"created_at": "2015-02-28T17:22:29.000Z",
"avatar": "https://p19-common-sign.tiktokcdn-us.com/tos-useast5-avt-0068-tx/ba67b11de451691939223e9d978e613a~tplv-tiktokx-cropcenter:1080:1080.jpeg?dr=9640&refresh_token=34490f35&x-expires=1784750400&x-signature=9pxwLrIfNAjKje5XAn%2BQMVf4%2BKE%3D&t=4d5b0474&ps=13740610&shp=a5d48078&shcp=81f88b70&idc=useast8",
"url": "https://www.tiktok.com/@tiktok",
"socialPlatform": "tiktok",
"stats": {
"followerCount": 94900000,
"followingCount": 0,
"heartCount": 461900000,
"videoCount": 1477
},
"statsV2": {
"followerCount": "94890999",
"followingCount": "0",
"heartCount": "461869949",
"videoCount": "1477"
}
}statsV2 is the block most people actually came for, and it is the one that is easy to miss sitting underneath its rounded twin. Both objects carry the same four keys; stats gives you integers rounded for display, statsV2 gives you the exact figures as strings. verified and create_time are the pair that decides whether a row is trustworthy at all, which the table above shows in practice.
Five things to know before you build on this. The counts come from the ten accounts in profiles_sampled.json:
statsV2values are strings,statsvalues are integers."94890990"against94900000. Cast before you do arithmetic, and do not mix the two in one column.- No field came back null on any of the 10 accounts sampled. Every one carried all 13 fields populated,
signatureincluded. Bios are free text as the account wrote them, emoji and line breaks included, so they are display strings and not something to parse. followingCountis often 0 and that is real. It read 0 on @tiktok. Large brand accounts frequently follow nobody, so a zero here is not a missing value.- The
avatarURL is signed and expires. It carriesx-expiresandx-signaturequery parameters. Fetch the image when you read the row rather than storing the URL and expecting it to resolve next month. idandsecUidare the stable identity, the handle is not. A handle can change owner or spelling;idis the numeric account id andsecUidis TikTok's opaque one. Key your own tables onidand treat the handle as a label.
Runnable: tiktok_profile_scraper_api_codes/profile.py
Turning a list of handles into a timestamped table of audience sizes is the main reason people pull single profiles, so that use case is in the repo end to end rather than as a snippet. track_followers.py resolves every handle concurrently, appends one row per account with the capture time attached, and flags the two failure modes from the table above: an account whose returned handle is not the one you asked for, and an unverified account.
python tiktok_profile_scraper_api_codes/track_followers.py tiktok netflix nba duolingo redbull nasaThat is a real run: six handles resolved at 5 concurrent in 4.3 seconds. Five are the brand accounts you would expect. The sixth resolved to an account created that morning with no videos and no verification, and the script says so instead of writing the row silently. Feed it a file instead when the list gets long:
python tiktok_profile_scraper_api_codes/track_followers.py --file handles.txt --out t.csv --workers 8The CSV carries captured_utc, input, uniqueId, handle_matches_input, nickname, verified, account_created, followers, followers_exact, likes_exact, videos, following and status, so a failed row stays in the file with a reason attached instead of vanishing. Re-run it on a schedule and the file becomes a time series. Keep --workers at or below your plan's concurrency.
Do this arithmetic before you start: one request per account per capture. Tracking 200 accounts daily is 200 requests a day, so the one-time free allowance covers five days of it. At a weekly cadence the same list and the same allowance last five weeks.
Real end-to-end wall clock, measured from a laptop against the live API on 2026-07-20 by benchmarks/measure.py. This includes the upstream fetch and the parse:
| Endpoint | Median | Range | n |
|---|---|---|---|
/tiktok/profile |
1.09s | 0.64 to 1.6s | 8 |
Read the range, not just the median. All eight calls returned 200 and the spread is narrow, which is what a target that serves its own page tends to look like: the time goes on the round trip and the parse rather than on re-attempts. Small sample, one machine, one day: run benchmarks/measure.py and use your own numbers.
MIT. See LICENSE.






