TikTok Scraper for extracting follower counts, video views, likes, captions, hashtags and contact details from TikTok.com. This repo has a free TikTok web scraping script you can run right now, and a TikTok data API with 6 endpoints returning real structured JSON.
This repo reads public brand and business accounts only. Every capture, example and screenshot on this page targets a brand account (@tiktok, @netflix, @duolingo, @nba). No follower rows are published anywhere in this repo, and there is no tooling here for profiling a private individual. More on that in Scope.
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 are truncated where marked (each block says exactly what was cut); every field shown is verbatim. Full uncut samples are committed in tiktok_scraper_api_data/, so you can diff this page against them. Every code example calls the actual API and is runnable from tiktok_scraper_api_codes/.
pip install requests
export CHOCODATA_API_KEY="your_key" # free: 1,000 requests, one-time, no card
python tiktok_scraper_api_codes/profile.py tiktokThose three lines return this, live from TikTok.com:
{
"uniqueId": "tiktok",
"nickname": "TikTok",
"signature": "One TikTok can make a big impact",
"verified": true,
"created_at": "2015-02-28T17:22:29.000Z",
"stats": { "followerCount": 94900000, "heartCount": 461900000, "videoCount": 1477 },
"statsV2": { "followerCount": "94890816", "heartCount": "461869515" }
}...for any public brand account you point it at, with 13 fields each:
That is the whole point of this repo. The rest of this page is the free path, the three places it stops, and the six endpoints that cover them.
- Scope: public brand accounts only
- Free TikTok Scraper
- Avoid getting blocked when scraping TikTok
- TikTok Scraper API reference
- Track brand accounts and competitors over time
- Measured latency
TikTok is mostly personal data, and that shapes what this repo is willing to be.
What it does: read public brand and business accounts, and public videos published by them. Follower and like counts, video views, captions, hashtags, business category, published contact links.
What it does not do: publish anybody's follower list. The follower endpoint is documented here because it is a real endpoint people need, but the rows it returns are real members of the public: on the page we captured, 0 of 30 were verified, the median account had 121 followers, and 17 of 30 carried a written bio. That is a list of private individuals, so the committed sample is the response envelope with the followers array cut to 0 of 30, and the field reference below is a schema table rather than a data dump. The runnable example writes rows to a local gitignored file instead of your terminal, so a follower list does not end up in a screenshot or a CI log.
This repo is built for TikTok data about brands. Data about people is out of scope by design.
TikTok server-renders a JSON blob into every profile and video page, inside a <script id="__UNIVERSAL_DATA_FOR_REHYDRATION__"> tag. You can read that without a headless browser, JavaScript rendering, or a login. No key, no cost:
python free_scraper/tiktok_free_scraper.py @tiktok
python free_scraper/tiktok_free_scraper.py https://www.tiktok.com/@tiktok/video/7106594312292453675Source: free_scraper/tiktok_free_scraper.py. It parses the rehydration blob, reads the webapp.user-detail scope for profiles and webapp.video-detail for videos, and prints the fields it finds.
It works. Measured on 2026-07-20 from a residential connection: 20 of 20 profile fetches returned HTTP 200 and parsed, including a burst of 12 consecutive requests at roughly 1/s across 11 different brand accounts. Video pages parsed too, with the caption, engagement counts, categories and duration all present. We are reporting what happened rather than a wall we did not hit.
One thing does change the outcome, and it is worth knowing before you blame your parser:
The same script, the same URL, seconds apart. With a browser User-Agent the profile page returns 200 and 292 KB. With the default python-requests User-Agent it returns HTTP 403 with a zero-byte body, 4 times out of 4. The video page serves that same bare client fine, 200 on 4 of 4 at 310 to 381 KB: the 403 is specific to /@handle. One header, and only on one of the two page types.
This is why the script parses first and only reports a failure when the data is genuinely absent. The strings captcha (25 occurrences) and verify (2) appear in the JavaScript bundle of a perfectly healthy TikTok page, so a block detector that greps the whole body for "captcha" reports BLOCKED on a page that parsed fine.
The free path above covers one profile and one video at a time. Three things it does not cover, all measured on 2026-07-20:
1. The follower list returns HTTP 200 and nothing at all. TikTok's own user/list XHR is what the web app calls to page through followers. Called without the signature its JavaScript mints in a real browser, it answers:
HTTP 200 and a zero-byte body. Not a 403, not a 401, not an error body. An empty string that r.json() throws on. If you are wrapping that call in a try/except and logging "no followers", you will log it forever.
2. A creator's video list is not in the page. The webapp.user-post scope was absent from every profile fetch we made, 11 fetches across 8 brand accounts. The profile HTML gives you the account, not its timeline, so you cannot enumerate a brand's videos by fetching its profile.
3. The number you get is rounded, and it does not look rounded. This one is the expensive default, because nothing announces it:
stats.followerCount is the display number, rounded to three significant figures. statsV2.followerCount is exact, in the same response, as a string. On @ryanair that is a 49,552-follower gap. On @nba's like count it is over 31 million. Across these 11 accounts the rounding error peaked at 2.37% on followers (@shopify) and 2.78% on likes (@nba), and it will never throw an error.
| What bites you | Why | What it costs you |
|---|---|---|
| HTTP 200 is not success | The follower XHR answers 200 with a zero-byte body. The page endpoints answer 200 with a full blob whether or not your scope is in it. | The expensive failure is not a crash. It is weeks of empty rows that looked like "this account has no followers". |
stats is rounded, statsV2 is exact |
TikTok ships both in the same object. stats is what the page prints; statsV2 is the real number, typed as a string. |
Analytics that are quietly wrong by tens of thousands, with no error to alert on. |
| The scary strings are always there | captcha appears 25 times in the JS bundle of a healthy page. |
You debug a block that never happened, or you trust a detector that cannot fail. |
| One header flips the profile page | A bare python-requests UA gets 403 on /@handle but 200 on /@handle/video/<id>. |
A scraper that works on videos and silently 403s on profiles, from the same process. |
| Counts differ by egress country | The same account read through us, gb and de within one minute returned three different exact follower counts, us sitting ~11,000 below the other two on all three rounds. We are reporting the measurement, not a cause. |
Switch country mid-series and you record an 11,000-follower step change that never happened. |
| Engagement counts are pre-rounded at source | On videos, playCount and diggCount come back already rounded (564,100 and 98,700), while commentCount, shareCount and collectCount are exact. On the video we sampled, stats and statsV2 carried the same rounded play count on every path we checked. |
Per-video view analytics that are precise to three digits and no further. |
The managed option, and the one this repo is built around. The Chocodata TikTok Scraper API has six endpoints for TikTok data extraction at scale, including the follower list and the exact counts, with a ~99% success rate and no proxy management. Free for the first 1,000 requests.
Below is the TikTok Scraper API reference to get you started. Six endpoints, each with its parameters, a call you can paste, and the JSON that comes back.
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,
)
d = r.json()
print(d["uniqueId"], int(d["statsV2"]["followerCount"]))
# tiktok 94890816After 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.
Accepted by every endpoint below:
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
api_key |
string | yes | - | Your Chocodata API key. |
country |
string (ISO-2) | no | us |
Egress location. It changes the exact follower count you get back (see the table above), so pin it and keep it constant across a time series. |
add_html |
boolean | no | false |
Also return the raw upstream HTML alongside the parsed JSON (debugging). |
Each request costs 5 credits (= 1 request). Responses are billed only on success (2xx).
| Status | error code |
Meaning | Billed | What to do |
|---|---|---|---|---|
400 |
invalid_params |
A required param is missing or the wrong type. Body lists the exact issue and 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; see Rate limits. |
502 |
extraction_failed |
TikTok did not serve this one. Also what you get for a handle or video id that does not exist. | no | Retry. Persistent failure across three tries means it is probably gone. |
There is no 404. A handle that does not exist and a handle TikTok refused to render both come back as 502 extraction_failed, because from outside they look the same: a page with no user in it. We ran example.com through the url endpoints as a control and got 502 there too, rather than a 200 carrying a bogus object under the TikTok schema, so a wrong URL fails loudly instead of quietly.
Two response shapes exist: auth and billing errors nest under error.code (uppercase), while scrape-layer errors are flat with a lowercase error string. Both are shown below.
The scripts in this repo map each documented status onto 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."}}A missing parameter, verbatim:
{
"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. Every endpoint is a synchronous GET: there is no webhook, callback, or async job to poll. The examples use timeout=90 because a request that has to be re-attempted takes longer than the median (see Measured latency).
Sizing: at Pro (50 concurrent) and a ~1.7s median profile call, one worker pool sustains roughly 50 / 1.7 = 29 requests/second. 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)
if not r.ok:
return handle, None
return handle, int(r.json()["statsV2"]["followerCount"])
brands = ["tiktok", "netflix", "duolingo", "redbull", "gymshark"]
with ThreadPoolExecutor(max_workers=10) as pool: # <= your plan's concurrency
for handle, followers in pool.map(one, brands):
print(handle, followers)One public TikTok account: identity, bio, exact follower and like counts, video count, and the verified flag.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
username |
string | one of username/url |
- | The TikTok handle. Use this one. A leading @ is stripped for you. |
url |
string (URL) | one of username/url |
- | Full profile URL. The handle is parsed out of it, so it resolves to exactly the same call. |
curl "https://api.chocodata.com/api/v1/tiktok/profile?api_key=YOUR_KEY&username=tiktok"Real response. The object is complete, all 13 fields verbatim; only the avatar string is truncated where marked, because it is a 300-character signed CDN URL (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://p16-common-sign.tiktokcdn-us.com/tos-useast5-avt-0068-tx/ba67b11de451691...",
"url": "https://www.tiktok.com/@tiktok",
"socialPlatform": "tiktok",
"stats": {
"followerCount": 94900000,
"followingCount": 0,
"heartCount": 461900000,
"videoCount": 1477
},
"statsV2": {
"followerCount": "94890816",
"followingCount": "0",
"heartCount": "461869515",
"videoCount": "1477"
}
}statsV2 is the field most people come for, and reading stats instead is the single most common mistake against this endpoint. They sit side by side in the same object: stats.followerCount is 94900000, statsV2.followerCount is "94890816". The first is TikTok's display rounding, the second is the real count. We measured the gap across 11 brand accounts and it ran from 22 followers (@canva) to 49,552 (@ryanair).
Field notes:
statsV2values are strings,statsvalues are integers.int(d["statsV2"]["followerCount"])before you do arithmetic, or you will concatenate two follower counts and not notice.videoCountis exact in both. It is the one stat TikTok does not round, and it is the cheapest way to detect that a brand published or deleted something: we watched@netflixgo from 8,012 to 8,011 in 45 seconds.secUidis the join key. The follower endpoint resolves it internally, and it is the stable identifier for an account that renamed its handle.followingCountis genuinely0for@tiktok. That is the account, not a parse failure.
Runnable: tiktok_scraper_api_codes/profile.py · Single-endpoint repo: tiktok-profile-scraper
One public TikTok video by its permalink: caption, engagement counts, hashtags, duration, music, and the CDN media URLs.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
url |
string (URL) | one of url/id |
- | Full video permalink. Use this one. |
id |
string (digits) | one of url/id |
- | Bare numeric video id. Pair it with author, since the canonical permalink needs the handle. |
author |
string | no | - | The @handle, used with id to build the permalink. |
curl "https://api.chocodata.com/api/v1/tiktok/video?api_key=YOUR_KEY&url=https://www.tiktok.com/@tiktok/video/7106594312292453675"Real response from a video published by @tiktok on TikTok's own account. images is cut to 1 of 4 and the signed CDN URLs are truncated where marked; every object is complete, all 17 top-level fields verbatim (full sample):
{
"id": "7106594312292453675",
"url": "https://www.tiktok.com/@tiktok/video/7106594312292453675",
"socialPlatform": "tiktok",
"title": "how many frogs did you find? 🐸 check out tiktok's #Minecraft community today! @Gorillo",
"description": "how many frogs did you find? 🐸 check out tiktok's #Minecraft community today! @Gorillo",
"caption": "how many frogs did you find? 🐸 check out tiktok's #Minecraft community today! @Gorillo",
"create_time": 1654632929,
"created_at": "2022-06-07T20:15:29.000Z",
"author": {
"id": "107955",
"uniqueId": "tiktok",
"nickname": "TikTok",
"verified": true,
"signature": "One TikTok can make a big impact",
"avatar": "https://p19-common-sign.tiktokcdn-us.com/tos-useast5-avt-0068-tx/ba67b11...",
"secUid": "MS4wLjABAAAAv7iSuuXDJGDvJkmH_vz1qkDZYo1apxgzaxdBSeIuPiM",
"id_str": "107955",
"url": "https://www.tiktok.com/@tiktok"
},
"stats": {
"plays": 564100,
"likes": 98700,
"comments": 1334,
"shares": 127,
"saves": 58528
},
"statsV2": null,
"images": ["https://p19-common-sign.tiktokcdn-us.com/tos-useast5-p-0068-tx/beee36ba02..."],
"thumbnail": "https://p19-common-sign.tiktokcdn-us.com/tos-useast5-p-0068-tx/beee36ba0...",
"video": {
"duration": 24,
"width": 576,
"height": 1024,
"cover": "https://p19-common-sign.tiktokcdn-us.com/tos-useast5-p-0068-tx/beee36ba0...",
"dynamic_cover": "https://p19-common-sign.tiktokcdn-us.com/tos-useast5-p-0068-tx/447945bfe...",
"play_addr": "https://v16-webapp-prime.us.tiktok.com/video/tos/useast5/tos-useast5-ve-...",
"download_addr": "https://v16-webapp-prime.us.tiktok.com/video/tos/useast5/tos-useast5-pve..."
},
"music": {
"id": "7106594280055130923",
"title": "original sound",
"authorName": "TikTok",
"duration": 24,
"cover": "https://p19-common-sign.tiktokcdn-us.com/tos-useast5-avt-0068-tx/ba67b11...",
"play_url": "https://v16m.tiktokcdn-us.com/811c9fee825451be4dc1d68dbcff102f/6a5ecdb3/..."
},
"hashtags": ["Minecraft"],
"_source": "rehydration"
}caption is the field most people come for: it is the copy the brand actually shipped, and hashtags is parsed out of it for you. title, description and caption are three names for the same string on this surface, so pick one and ignore the others.
Field notes:
playsandlikesare rounded by TikTok, not by us.564100and98700are what the source hands over;comments,sharesandsavesare exact. Unlike the profile endpoint there is no exact alternative here, so a view count is precise to three significant figures and no further.statsV2isnullon this endpoint. The profile endpoint populates it; this one does not. Usestats, and treat the two large numbers in it as rounded.savesis normalised to an integer. TikTok ships that one field as a string ("58535") while its four siblings are integers, which is the kind of thing that survives testing and dies in production.- The counts move, in both directions. This same video's
commentsread 1334 in the block above, 1344 about ten minutes later, and 1275 in the terminal shot above that was captured later still, whilesaveswent 58528 then 58535. Comments get deleted as well as added, so treat any single reading as a snapshot rather than a running total, and expect your own captures to disagree with this page. - CDN URLs expire.
images,thumbnailand thevideoURLs carry signed expiry parameters. Fetch what you need promptly rather than storing the URL for later.
Runnable: tiktok_scraper_api_codes/video.py · Single-endpoint repo: tiktok-video-scraper
The same video, in a wider shape. post returns everything video does, plus TikTok's own content classification, the creation region, and the creator's follower stats inline.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
url |
string (URL) | yes | - | Full video permalink. |
curl "https://api.chocodata.com/api/v1/tiktok/post?api_key=YOUR_KEY&url=https://www.tiktok.com/@tiktok/video/7106594312292453675"Real response, same video as above. images is cut to 1 of 4 and the signed CDN URLs are truncated where marked; every object is complete, all 19 top-level fields verbatim (full sample):
{
"id": "7106594312292453675",
"url": "https://www.tiktok.com/@tiktok/video/7106594312292453675",
"socialPlatform": "tiktok",
"title": "how many frogs did you find? 🐸 check out tiktok's #Minecraft community today! @Gorillo",
"description": "how many frogs did you find? 🐸 check out tiktok's #Minecraft community today! @Gorillo",
"caption": "how many frogs did you find? 🐸 check out tiktok's #Minecraft community today! @Gorillo",
"create_time": 1654632929,
"created_at": "2022-06-07T20:15:29.000Z",
"author": {
"id": "107955",
"uniqueId": "tiktok",
"nickname": "TikTok",
"verified": true,
"signature": "One TikTok can make a big impact",
"avatar": "https://p16-common-sign.tiktokcdn-us.com/tos-useast5-avt-0068-tx/ba67b11...",
"secUid": "MS4wLjABAAAAv7iSuuXDJGDvJkmH_vz1qkDZYo1apxgzaxdBSeIuPiM",
"url": "https://www.tiktok.com/@tiktok",
"followerCount": 94900000,
"followingCount": 0,
"heartCount": 461900000,
"videoCount": 1477
},
"stats": {
"plays": 564100,
"likes": 98700,
"comments": 1334,
"shares": 127,
"saves": 58528
},
"images": ["https://p19-common-sign.tiktokcdn-us.com/tos-useast5-p-0068-tx/beee36ba02..."],
"thumbnail": "https://p19-common-sign.tiktokcdn-us.com/tos-useast5-p-0068-tx/beee36ba0...",
"video": {
"duration": 24,
"width": 576,
"height": 1024,
"cover": "https://p19-common-sign.tiktokcdn-us.com/tos-useast5-p-0068-tx/beee36ba0...",
"dynamic_cover": "https://p19-common-sign.tiktokcdn-us.com/tos-useast5-p-0068-tx/447945bfe...",
"play_addr": "https://v16-webapp-prime.us.tiktok.com/video/tos/useast5/tos-useast5-ve-...",
"download_addr": "https://v16-webapp-prime.us.tiktok.com/video/tos/useast5/tos-useast5-pve..."
},
"music": {
"id": "7106594280055130923",
"title": "original sound",
"author": "TikTok",
"duration": 24,
"is_original": null,
"cover": "https://p16-common-sign.tiktokcdn-us.com/tos-useast5-avt-0068-tx/ba67b11...",
"play_url": "https://v16m.tiktokcdn-us.com/c6ea44f3af86fdf952ba9d0861542f17/6a5ecdb7/..."
},
"hashtags": ["Minecraft"],
"categories": ["Video Games", "Games", "Entertainment"],
"suggested_words": [],
"location": "US",
"_source": "rehydration"
}categories is the field that makes this endpoint worth its own call. ["Video Games", "Games", "Entertainment"] is TikTok's own classification of the content, not ours and not a guess from the caption, which is how you bucket a competitor's output by theme without writing a classifier.
Which of the two to call, measured on the same video:
video |
post |
|
|---|---|---|
| Top-level fields | 17 | 19 |
stats |
identical | identical |
| Content classification | - | categories, location, suggested_words |
| Creator follower stats inline | - | 4 fields on author |
statsV2 |
present (null here) | not returned |
Accepts a bare video id |
yes, with author |
no, url only |
Field notes:
locationis where the video was created,"US"here, and it is a two-letter code rather than a place name.suggested_wordscame back empty on this video. It is TikTok's related-search list and it populates on some videos and not others, so code for[].authorcarries the rounded counts, matchingstatson the profile endpoint. If you need the exact follower number for the creator, callprofileand readstatsV2.music.is_originalisnull. The field is present on this endpoint and absent fromvideo, which returnsmusic.authorNamewhere this one returnsmusic.author.
Runnable: tiktok_scraper_api_codes/post.py · Single-endpoint repo: tiktok-post-scraper
One page of follower records for a public account, plus the exact follower total and a cursor for the next page. This is the surface a direct call cannot reach: TikTok's own user/list XHR answers a plain client with HTTP 200 and a zero-byte body.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
username |
string | one of username/url |
- | The TikTok handle. Use this one. |
url |
string (URL) | one of username/url |
- | Full profile URL; the handle is parsed out of it. |
count |
integer | no | 30 |
Rows to return this call. 1 to 30. The schema accepts up to 200, but we measured 30 returning 30 rows and 31, 40 and 50 all returning 502: TikTok's page size for this list is 30. |
cursor |
string | no | - | The next_cursor from a previous call. |
curl "https://api.chocodata.com/api/v1/tiktok/follower?api_key=YOUR_KEY&username=tiktok&count=30"Real response envelope. The 10 envelope fields are verbatim; the followers array is cut to 0 of 30 rows, because those rows are private individuals and this repo does not publish them (committed sample, same cut):
{
"uniqueId": "tiktok",
"secUid": "MS4wLjABAAAAv7iSuuXDJGDvJkmH_vz1qkDZYo1apxgzaxdBSeIuPiM",
"url": "https://www.tiktok.com/@tiktok",
"socialPlatform": "tiktok",
"follower_count": 94900000,
"followers_count": 30,
"has_more": true,
"next_cursor": "1784576259",
"total": 94890816,
"followers": []
}Each entry in followers carries these 11 fields:
| Field | Type | Description |
|---|---|---|
position |
integer | 1-based index within this page. |
id |
string | Numeric account id. |
uniqueId |
string | The @handle. |
nickname |
string | Display name. |
signature |
string | Bio text. Present on 17 of the 30 rows we sampled. |
verified |
boolean | 0 of 30 were verified on the page we sampled. |
secUid |
string | Stable account identifier. |
avatar |
string | Signed CDN URL, expires. |
url |
string | Profile URL built from the handle. |
followerCount |
integer | Their own follower count. Median 121 across our sample. |
followingCount |
integer | Their own following count. |
total is the field most people come for, and it is the exact one. follower_count is 94900000 rounded; total is 94890816. Same split as stats and statsV2 on the profile endpoint, different field names.
Field notes:
- Pagination works and does not repeat itself. Two consecutive pages of 30 returned 0 overlapping ids. Pass
next_cursorthrough ascursorand keep going whilehas_moreis true. followers_countis the rows in this response (30), not the account's follower total. That istotal.countabove 30 is a hard502, not a truncation to 30. Ask for 31 and you get nothing.- The example script keeps rows out of your terminal.
follower.pyprints the envelope and per-page aggregates, and writes rows to a gitignoredfollowers.jsonl. Follower rows are personal data and they do not belong in your scroll-back.
Runnable: tiktok_scraper_api_codes/follower.py · Single-endpoint repo: tiktok-follower-scraper
The public business block on a TikTok account: TikTok's own business category, the commerce-account flag, the bio and its published link, plus a contact email when the account put one in its bio text.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
username |
string | one of username/url |
- | The TikTok handle. Use this one. |
url |
string (URL) | one of username/url |
- | Full profile URL; the handle is parsed out of it. |
curl "https://api.chocodata.com/api/v1/tiktok/email?api_key=YOUR_KEY&username=tiktok"Real response. The object is complete, all 11 fields verbatim, nothing cut (full sample):
{
"uniqueId": "tiktok",
"url": "https://www.tiktok.com/@tiktok",
"socialPlatform": "tiktok",
"email": null,
"bio": "One TikTok can make a big impact",
"bioLink": "linktr.ee/tiktok",
"category": "Media & Entertainment",
"is_commerce_account": true,
"nickname": "TikTok",
"verified": true,
"email_source": null
}category and is_commerce_account are what this endpoint reliably gives you, and they are the two fields that qualify an account: "Media & Entertainment" is TikTok's own taxonomy label, and is_commerce_account tells you the account is set up to sell. Across 32 public brand and business accounts sampled on 2026-07-20, category populated on 28, bioLink on 26, and is_commerce_account was true on 28.
Field notes:
emailis read out of the bio text, and large brands do not put one there. It wasnullon all 32 accounts we sampled, every one of which routes contact throughbioLinkinstead. The parser also handles the obfuscated forms creators use (name (at) brand dot com), and it only fires on shapes that clearly encode an address, so it returnsnullrather than minting a false address out of ordinary prose.email_sourcetells you which form matched when one does.bioLinkis the real contact route on brand accounts, and it is usually a link aggregator or a shortener (linktr.ee/tiktok,https://gym.sh/shoptiktok), so expect to follow a redirect to get anywhere useful.categoryisnullon some accounts. 4 of our 32 returned no category despite being large brand accounts, so treat it as optional rather than guaranteed.
Runnable: tiktok_scraper_api_codes/contact_email.py
The oEmbed record for a video or a profile: ready-to-paste embed HTML, the thumbnail with its dimensions, and the author. This is the endpoint to use when you are rendering TikTok content in your own product rather than analysing it.
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
url |
string (URL) | yes | - | A video permalink or a profile URL. Both work. |
curl "https://api.chocodata.com/api/v1/tiktok/oembed?api_key=YOUR_KEY&url=https://www.tiktok.com/@tiktok/video/7106594312292453675"Real response. The object is complete, all 17 fields verbatim; the thumbnail_url and html strings are truncated where marked (full sample):
{
"id": "7106594312292453675",
"url": "https://www.tiktok.com/@tiktok/video/7106594312292453675",
"socialPlatform": "tiktok",
"type": "video",
"version": "1.0",
"title": "how many frogs did you find? 🐸 check out tiktok's #Minecraft community today! @Gorillo",
"author_name": "TikTok",
"author_unique_id": "tiktok",
"author_url": "https://www.tiktok.com/@tiktok",
"provider_name": "TikTok",
"provider_url": "https://www.tiktok.com",
"thumbnail_url": "https://p16-common-sign.tiktokcdn.com/tos-useast5-p-0068-tx/beee36ba02ef4493...",
"thumbnail_width": 576,
"thumbnail_height": 1024,
"embed_type": "video",
"html": "<blockquote class=\"tiktok-embed\" cite=\"https://www.tiktok.com/@tiktok/video/7106594312292453675\" data-video-id=\"7106594312292453675\"...",
"stats": null
}html is the field most people come for: 751 characters of <blockquote class="tiktok-embed"> plus the embed.js script tag, which is the officially supported way to render a TikTok video on your own page.
Field notes:
typetells you what you pointed at. A video permalink returns"video"; a profile URL returns"rich". Both are valid inputs, and this is how you tell them apart after the fact.statsisnullhere. oEmbed is a rendering record, not an analytics one. For engagement counts callvideoorpost.thumbnail_widthandthumbnail_heightare real pixel dimensions (576x1024 here), so you can reserve layout space before the image loads.
Runnable: tiktok_scraper_api_codes/oembed.py
Watching your own and your competitors' account performance is the main reason to scrape TikTok, so that use case is in the repo end to end rather than as a snippet. brand_tracker.py polls a set of public brand accounts, stores every observation as a local dataset in SQLite (export it to CSV with one sqlite3 command), and prints what moved since the last run:
python tiktok_scraper_api_codes/brand_tracker.py tiktok netflix duolingoThat is a real second run, 45 seconds after the first. The deltas are small on followers because the interval was under a minute, and they are real: these are exact statsV2 counts, and they move constantly on accounts this size. Note @netflix at -1 videos in that window, which is a video coming down; the video count is exact, so it is the cheapest publish/delete signal you get.
Run it on a daily cron and the same diff lines become the follower trend for your brand and its competitors. One API call per account per run: 1,000 free requests covers roughly 11 months of daily checks on three accounts, one-time.
Pin --country and leave it pinned. The exact counter differs between egress countries, so switching mid-series records a step change that is not growth.
The script skips an account that returns 502 for a run rather than dying, because one bad handle should not kill the batch. That is not hypothetical: one handle in our sweep returned 502 on 4 attempts across an hour. Since there is no 404, a handle that does not exist looks exactly like one TikTok declined to serve, and either way the other accounts in the batch should still get recorded.
Real end-to-end wall-clock, measured from a laptop against the live API on 2026-07-20. This includes the upstream fetch, the anti-bot handling, and the parse:
| Endpoint | Median | Range | n |
|---|---|---|---|
/tiktok/video |
1.44s | 1.27 to 1.77s | 5 |
/tiktok/profile |
1.73s | 1.39 to 3.09s | 5 |
/tiktok/post |
1.73s | 1.15 to 3.28s | 5 |
/tiktok/email |
2.02s | 1.64 to 2.75s | 5 |
/tiktok/oembed |
2.27s | 1.40 to 4.64s | 8 |
/tiktok/follower |
2.39s | 1.50 to 2.83s | 5 |
All 33 of those requests returned 200, from one laptop on one evening. oembed is the one to watch: an earlier run of the same call returned 200 on only 4 of 5 before the 8 of 8 above, and it holds the widest range in the table. Budget for retries, not for the median. Reproduce it with the scripts in this repo.
MIT. See LICENSE.










