Skip to content

ChocoData-com/tiktok-scraper

Repository files navigation

TikTok Scraper

TikTok Scraper

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 tiktok

Those 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:

Retrieved TikTok brand account data

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.


Contents


Scope: public brand accounts only

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.


Free TikTok Scraper

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/7106594312292453675

Source: 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:

Free TikTok scraper with a browser User-Agent and without one

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.

Avoid getting blocked when scraping TikTok

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:

The same follower list called direct and through the API

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:

TikTok stats versus statsV2 across 11 brand accounts

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.

Using the Chocodata TikTok Scraper API

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.


TikTok Scraper API reference

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.

Quickstart

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 94890816

After running the command, your terminal should look something like this:

Running the TikTok Scraper API profile endpoint

Get a key at chocodata.com (1,000 requests, one-time, no card).

Authentication

Pass api_key as a query parameter on every request. That is the whole auth model.

Global parameters

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).

Errors

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:

TikTok Scraper API error handling

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": []
    }
  ]
}

Rate limits and concurrency

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)

1. Profile: follower counts, likes, bios and verified status

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:

  1. statsV2 values are strings, stats values are integers. int(d["statsV2"]["followerCount"]) before you do arithmetic, or you will concatenate two follower counts and not notice.
  2. videoCount is 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 @netflix go from 8,012 to 8,011 in 45 seconds.
  3. secUid is the join key. The follower endpoint resolves it internally, and it is the stable identifier for an account that renamed its handle.
  4. followingCount is genuinely 0 for @tiktok. That is the account, not a parse failure.

Runnable: tiktok_scraper_api_codes/profile.py · Single-endpoint repo: tiktok-profile-scraper


2. Video: views, likes, captions, hashtags and media URLs

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.

TikTok video endpoint output

Field notes:

  1. plays and likes are rounded by TikTok, not by us. 564100 and 98700 are what the source hands over; comments, shares and saves are exact. Unlike the profile endpoint there is no exact alternative here, so a view count is precise to three significant figures and no further.
  2. statsV2 is null on this endpoint. The profile endpoint populates it; this one does not. Use stats, and treat the two large numbers in it as rounded.
  3. saves is 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.
  4. The counts move, in both directions. This same video's comments read 1334 in the block above, 1344 about ten minutes later, and 1275 in the terminal shot above that was captured later still, while saves went 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.
  5. CDN URLs expire. images, thumbnail and the video URLs 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


3. Post: video data plus content categories and region

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

TikTok post endpoint output

Field notes:

  1. location is where the video was created, "US" here, and it is a two-letter code rather than a place name.
  2. suggested_words came back empty on this video. It is TikTok's related-search list and it populates on some videos and not others, so code for [].
  3. author carries the rounded counts, matching stats on the profile endpoint. If you need the exact follower number for the creator, call profile and read statsV2.
  4. music.is_original is null. The field is present on this endpoint and absent from video, which returns music.authorName where this one returns music.author.

Runnable: tiktok_scraper_api_codes/post.py · Single-endpoint repo: tiktok-post-scraper


4. Follower: follower records with cursor pagination

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.

TikTok follower endpoint output

Field notes:

  1. Pagination works and does not repeat itself. Two consecutive pages of 30 returned 0 overlapping ids. Pass next_cursor through as cursor and keep going while has_more is true.
  2. followers_count is the rows in this response (30), not the account's follower total. That is total.
  3. count above 30 is a hard 502, not a truncation to 30. Ask for 31 and you get nothing.
  4. The example script keeps rows out of your terminal. follower.py prints the envelope and per-page aggregates, and writes rows to a gitignored followers.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


5. Email: contact details, bio links and business category

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.

TikTok contact block output

Field notes:

  1. email is read out of the bio text, and large brands do not put one there. It was null on all 32 accounts we sampled, every one of which routes contact through bioLink instead. 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 returns null rather than minting a false address out of ordinary prose. email_source tells you which form matched when one does.
  2. bioLink is 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.
  3. category is null on 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


6. oEmbed: embed HTML, thumbnails and author

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:

  1. type tells 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.
  2. stats is null here. oEmbed is a rendering record, not an analytics one. For engagement counts call video or post.
  3. thumbnail_width and thumbnail_height are real pixel dimensions (576x1024 here), so you can reserve layout space before the image loads.

Runnable: tiktok_scraper_api_codes/oembed.py


Track brand accounts and competitors over time

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 duolingo

TikTok brand tracker showing real follower and like deltas

That 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.


Measured latency

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.


License

MIT. See LICENSE.