A TikTok scraping API turns public TikTok pages into clean JSON: video details with download links, creator profiles, a creator's full video feed, the videos behind a sound, comments and search results. This guide shows how to scrape TikTok with one HTTP endpoint, from the first request to a Python or Node.js script that pages through an entire feed without missing or double-counting a video.
Everything here was checked against live responses on October 3, 2026. The examples use NASA's public account and real response fields, trimmed with … where they run long.
Key takeaways
- Every call is a
POSTtohttps://scrapingbot.io/api/v1/tiktokwith{"endpoint": "…", "params": {…}}. Each request costs 1 credit, and only successful requests are charged. - Eleven endpoints cover videos (
/), profiles (/user/info), feeds (/user/posts), followers (/user/followers,/user/following), sounds (/music/info,/music/posts), comments (/comment/list,/comment/reply) and search (/feed/search,/user/search). - To paginate, send
cursorback unchanged, stop only whenhasMoreis 0 or false, and de-duplicate onvideo_id. Page sizes vary, so a short page is not the end. - Media links are signed and expire within days. Store IDs and download files soon after you fetch them.
- Parallelize across creators, not within one feed, and size your worker pool to your plan's concurrency limit.
Three ways to get TikTok data
Before writing code, pick the route that matches the data you need. There are three realistic options.
| Route | What you get | What it costs you | Use it when |
|---|---|---|---|
| TikTok's official APIs | The Research API for approved researchers; the Display API for accounts that log in to your app | An application, review and usage terms scoped to those programs | You qualify for a program, or you only need your own users' data |
| Your own scraper | Whatever you can parse from the web app | Proxies, request signing, fingerprinting and constant maintenance as TikTok's internals change | Scraping is your core product and you have people to maintain it |
| A scraping API | Public videos, profiles, feeds, sounds, comments and search as JSON | A per-request price; no proxies or browsers to run | You want the data and not the infrastructure |
The rest of this guide covers the third route with the ScrapingBot TikTok API. If you are weighing providers in general, the comparison of web scraping APIs covers the trade-offs, and why web scraping breaks explains what a DIY scraper is up against.
TikTok API quick start
Sign up to get an API key and 100 free credits; no card is needed. Then send your first request. This one fetches a creator's profile:
curl -X POST https://scrapingbot.io/api/v1/tiktok \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"endpoint": "/user/info", "params": {"unique_id": "nasa"}}'
- Endpoint
POST /api/v1/tiktok- Auth
x-api-keyheader, Bearer token orapi_keyquery parameter- Cost
- 1 credit per successful request
- Timeout
- 30 seconds per request
A successful call returns success, data (the endpoint's payload), processed_time, duration, statusCode and creditsUsed. An error returns success: false, an error message and a code equal to the HTTP status:
{"success": false, "error": "unique_id is required.", "code": 400}
The same request from Python and Node.js. Keep the key on your server; never call the API from a browser, where anyone can read the key.
import requests
res = requests.post(
"https://scrapingbot.io/api/v1/tiktok",
headers={"x-api-key": "YOUR_API_KEY"},
json={"endpoint": "/user/info", "params": {"unique_id": "nasa"}},
timeout=60,
)
body = res.json()
print(body["data"]["stats"]["followerCount"])
const res = await fetch("https://scrapingbot.io/api/v1/tiktok", {
method: "POST",
headers: { "x-api-key": "YOUR_API_KEY", "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: "/user/info", params: { unique_id: "nasa" } }),
});
const body = await res.json();
console.log(body.data.stats.followerCount);
If you already have a working cURL command, the cURL converter turns it into Python, Node.js or other languages, and the JSON formatter makes long responses readable.
The 11 TikTok endpoints at a glance
| endpoint | Returns | Key params | Pagination |
|---|---|---|---|
/ | One video: stats, author, sound, media links | url, hd | Single result |
/user/info | Profile and counters | unique_id | Single result |
/user/posts | A creator's videos, newest first | unique_id, count, cursor | cursor + hasMore |
/music/info | One sound's details | music_id or url | Single result |
/music/posts | Videos that use a sound | music_id, count (1–35), cursor | cursor + hasMore |
/comment/list | Comments on a video | url, count (1–50), cursor | cursor + hasMore |
/feed/search | Videos matching a keyword | keywords, count (1–50), region | cursor + searchId + hasMore |
/user/search | Accounts matching a keyword | keywords, count (1–50) | Single page |
/user/followers | Accounts that follow a user | user_id (numeric), count (1–50) | Single page |
/user/following | Accounts a user follows | user_id (numeric), count (1–50) | Single page |
/comment/reply | Replies to one comment | comment_id, video_id or url, count (1–50), cursor | cursor + hasMore |
The TikTok section of the API docs lists every parameter with its default. The sections below cover what each endpoint returns and the details that trip people up.
Get TikTok video details and download links
Send a video URL to the / endpoint. The URL must contain /video/; profile URLs are rejected with a message pointing you to /user/info. Add "hd": "1" to ask for an HD link as well.
curl -X POST https://scrapingbot.io/api/v1/tiktok \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"endpoint": "/", "params": {"url": "https://www.tiktok.com/@aetherrr.flp/video/7682907275505224982", "hd": "1"}}'
{
"success": true,
"data": {
"id": "7682907275505224982",
"title": "luna eterna #lofihouse #electronicmusic #club #fyp #2000smusic",
"duration": 31,
"create_time": 1788816252,
"region": "IE",
"play_count": 354300,
"digg_count": 54000,
"comment_count": 740,
"share_count": 2877,
"collect_count": 20134,
"play": "https://www.tiktok.com/aweme/v1/play/?…",
"wmplay": "https://v16m.tiktokcdn-us.com/…",
"hdplay": "https://www.tiktok.com/aweme/v1/play/?…",
"size": 1164559,
"wm_size": 1254152,
"hd_size": 982846,
"cover": "https://p19-common-sign.tiktokcdn-us.com/…",
"author": { "id": "7002179649582711813", "unique_id": "aetherrr.flp", "nickname": "aether", "avatar": "https://…" },
"music_info": { "id": "7682907287056337667", "title": "original sound - aetherrr.flp", "author": "aether", "original": true, "duration": 31 },
"cha_list": [ { "cha_name": "lofihouse", … }, … ],
…
},
"processed_time": 0.6326,
"duration": "0.79",
"statusCode": 200,
"creditsUsed": 1
}
The media fields are what downloaders need:
playis the file without a watermark,wmplaythe watermarked one, andhdplaythe HD link.size,wm_sizeandhd_sizeare their sizes in bytes.cover,origin_coverandai_dynamic_coverare thumbnail images.music_info.idis the sound ID you pass to the sound endpoints below.
The links are signed. In our test, the CDN and cover URLs carried an x-expires timestamp about two days out, so download the file soon after you fetch it, and store the video ID rather than the URL. When you need the file again, call the endpoint again for fresh links. This function fetches a video and streams the file to disk:
import requests
def download_tiktok(video_url, path, api_key="YOUR_API_KEY"):
res = requests.post(
"https://scrapingbot.io/api/v1/tiktok",
headers={"x-api-key": api_key},
json={"endpoint": "/", "params": {"url": video_url, "hd": "1"}},
timeout=60,
).json()
if not res.get("success"):
raise RuntimeError(res.get("error"))
video = res["data"]
media_url = video.get("hdplay") or video["play"] # both are watermark-free
with requests.get(media_url, stream=True, timeout=120) as file:
file.raise_for_status()
with open(path, "wb") as out:
for chunk in file.iter_content(chunk_size=1 << 20):
out.write(chunk)
return video
Three IDs, one video
This endpoint calls the video ID id; the list endpoints call the same value video_id. There is also an aweme_id field, which holds an internal media identifier (in our test, a string like v24044gl0000…), not the video ID. Key your data on id or video_id, never on aweme_id.
Scrape a TikTok profile
/user/info takes a username as unique_id. An @handle or a full profile URL such as https://www.tiktok.com/@nasa works too; the API trims it to the username.
{
"success": true,
"data": {
"user": {
"id": "7664638705177150477",
"uniqueId": "nasa",
"nickname": "NASA",
"signature": "Making the seemingly impossible, possible.✨",
"verified": true,
"privateAccount": false,
"secUid": "MS4wLjABAAAAU9BRVzC8oCaegVnia8IbqWhPb_…",
"avatarLarger": "https://p19-common-sign.tiktokcdn-us.com/…",
…
},
"stats": {
"followerCount": 1900000,
"followingCount": 23,
"heartCount": 10200000,
"videoCount": 52
}
},
"processed_time": 0.3979,
"duration": "0.56",
"statusCode": 200,
"creditsUsed": 1
}
Two things to notice. First, profile fields are camelCase (uniqueId, followerCount) while video fields are snake_case (unique_id, play_count), so map them deliberately. Second, videoCount tells you roughly how many pages a full feed crawl will take before you start one: 52 videos at about 10 per page is around 6 requests.
Scrape a creator's full video feed
/user/posts returns a creator's videos newest first, one page per request, with a cursor and a hasMore flag. This is where most TikTok scrapers go wrong, so here is the procedure that holds up:
- Request the first page. Send
/user/postswith theunique_idand acountof 10. Leavecursorout. - Store each video by
video_id. Write every video to your store keyed onvideo_id, and skip IDs you already have. - Check
hasMore. If it is 0 or false, you have the whole feed. Ignore how many videos the page held. - Send the cursor back unchanged. Copy
data.cursorinto the next request'scursorand fetch the next page. - Retry failures instead of stopping. Retry 408, 429 and 5xx responses with a growing delay. They are refunded, and a failed page is never the end of the feed.
Here is what two real pages of NASA's feed looked like:
// page 1: {"unique_id": "nasa", "count": 10}
{
"success": true,
"data": {
"videos": [
{
"video_id": "7692161454694206733",
"title": "It was a big week in low Earth orbit at NASA! …",
"create_time": 1790970924,
"duration": 136,
"play_count": 35000,
"digg_count": 2987,
"comment_count": 100,
"share_count": 52,
"collect_count": 234,
"is_top": 0,
"share_url": "https://www.tiktok.com/@nasa/video/7692161454694206733",
"play": "https://www.tiktok.com/aweme/v1/play/?…",
"author": { "id": "7664638705177150477", "unique_id": "nasa", "nickname": "NASA", … },
…
},
… 9 more
],
"cursor": "1789337087000",
"hasMore": true
},
"creditsUsed": 1
}
// page 2: {"unique_id": "nasa", "count": 10, "cursor": "1789337087000"}
// → 10 more videos, "cursor": "1787689386000", "hasMore": true
Treat the cursor as opaque. On /user/posts it was a string that looks like a timestamp; on /music/posts and /comment/list it was a number; and hasMore came back as true on some endpoints and 1 on others. Copy the cursor back exactly as you received it, and test hasMore for truthiness rather than comparing it to one value.
Keep count at 10 for profile feeds
The docs allow a count of 1 to 35 on /user/posts, but in our tests on October 3, 2026, TikTok rejected 30 and 35 on this endpoint with a 400 and invalid count parameter, while the default of 10 worked. Failed requests are not charged, but a crawl that sends a large count simply stops. /music/posts accepted counts up to 35 in the same period.
Two more details matter for complete data:
- Pinned videos. A creator can pin videos to the top of their profile. They come first on page 1 regardless of age, with
is_topset to 1. Don't assume the first video is the newest, and don't stop an incremental crawl on a pinned video you have already seen. - Duplicates. The same video can appear on two consecutive pages. A set or a primary key on
video_idhandles it.
Complete Python script
This script pages through a creator's feed (or every video that uses a sound) and writes one JSON object per line. It retries timeouts, 429 and 5xx responses with exponential backoff, stops only on hasMore, de-duplicates on video_id, refuses to loop if the cursor stops moving, and caps the number of pages so a bug can't spend your whole balance.
"""Save every video from a TikTok creator (or a sound) as JSON Lines.
pip install requests
export SCRAPINGBOT_API_KEY=YOUR_API_KEY
python tiktok_feed.py user nasa > nasa.jsonl
python tiktok_feed.py sound 7682907287056337667 > sound.jsonl
"""
import json
import os
import sys
import time
import requests
API_URL = "https://scrapingbot.io/api/v1/tiktok"
API_KEY = os.environ.get("SCRAPINGBOT_API_KEY", "YOUR_API_KEY")
RETRY_STATUSES = {408, 429, 500, 502, 503, 504}
MAX_PAGES = 1000 # hard stop, so a bug can never drain your balance
def call(endpoint, params, attempts=5):
"""One API call. Failed calls are refunded, so retrying them is safe."""
error = None
for attempt in range(attempts):
try:
res = requests.post(
API_URL,
headers={"x-api-key": API_KEY},
json={"endpoint": endpoint, "params": params},
timeout=60,
)
body = res.json()
except (requests.RequestException, ValueError) as exc:
error = str(exc) # network error, timeout or a non-JSON answer
else:
if res.status_code == 200 and body.get("success"):
return body["data"]
error = f"HTTP {res.status_code}: {body.get('error')}"
if res.status_code not in RETRY_STATUSES:
raise RuntimeError(error) # 400, 401, 402, 404: fix the request instead
time.sleep(2 ** attempt) # 1, 2, 4, 8, 16 seconds
raise RuntimeError(f"giving up after {attempts} attempts: {error}")
def iter_videos(endpoint, params):
"""Yield each video once, following the cursor until hasMore is 0."""
seen = set()
cursor = None
for page in range(1, MAX_PAGES + 1):
page_params = dict(params) if cursor is None else {**params, "cursor": cursor}
data = call(endpoint, page_params)
videos = data.get("videos") or []
new = 0
for video in videos:
if video["video_id"] in seen:
continue # the same video can show up on two pages
seen.add(video["video_id"])
new += 1
yield video
print(f"page {page}: {len(videos)} videos, {new} new, {len(seen)} total", file=sys.stderr)
if not data.get("hasMore"): # 0 or false is the only end signal
return
next_cursor = data.get("cursor")
if next_cursor in (None, "", cursor):
raise RuntimeError(f"cursor did not advance after page {page}")
cursor = next_cursor
raise RuntimeError(f"stopped after MAX_PAGES={MAX_PAGES}")
if __name__ == "__main__":
kind = sys.argv[1] if len(sys.argv) > 1 else "user"
value = sys.argv[2] if len(sys.argv) > 2 else "nasa"
if kind == "sound":
endpoint, params = "/music/posts", {"music_id": value, "count": 35}
else:
endpoint, params = "/user/posts", {"unique_id": value, "count": 10}
for video in iter_videos(endpoint, params):
print(json.dumps(video, ensure_ascii=False))
Complete Node.js script
The same logic for Node.js 18 or later, using the built-in fetch and an async generator. Save it as tiktok_feed.mjs; no dependencies are needed.
// Save every video from a TikTok creator (or a sound) as JSON Lines. Node 18+.
//
// export SCRAPINGBOT_API_KEY=YOUR_API_KEY
// node tiktok_feed.mjs user nasa > nasa.jsonl
// node tiktok_feed.mjs sound 7682907287056337667 > sound.jsonl
const API_URL = "https://scrapingbot.io/api/v1/tiktok";
const API_KEY = process.env.SCRAPINGBOT_API_KEY ?? "YOUR_API_KEY";
const RETRY_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
const MAX_PAGES = 1000; // hard stop, so a bug can never drain your balance
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// One API call. Failed calls are refunded, so retrying them is safe.
async function call(endpoint, params, attempts = 5) {
let error;
for (let attempt = 0; attempt < attempts; attempt++) {
let res, body;
try {
res = await fetch(API_URL, {
method: "POST",
headers: { "x-api-key": API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ endpoint, params }),
signal: AbortSignal.timeout(60_000),
});
body = await res.json();
} catch (err) {
error = err.message; // network error, timeout or a non-JSON answer
}
if (body) {
if (res.ok && body.success) return body.data;
error = `HTTP ${res.status}: ${body.error}`;
if (!RETRY_STATUSES.has(res.status)) throw new Error(error); // fix the request instead
}
await sleep(2 ** attempt * 1000); // 1, 2, 4, 8, 16 seconds
}
throw new Error(`giving up after ${attempts} attempts: ${error}`);
}
// Yield each video once, following the cursor until hasMore is 0.
async function* iterVideos(endpoint, params) {
const seen = new Set();
let cursor;
for (let page = 1; page <= MAX_PAGES; page++) {
const data = await call(endpoint, cursor === undefined ? params : { ...params, cursor });
const videos = data.videos ?? [];
let fresh = 0;
for (const video of videos) {
if (seen.has(video.video_id)) continue; // the same video can show up on two pages
seen.add(video.video_id);
fresh++;
yield video;
}
console.error(`page ${page}: ${videos.length} videos, ${fresh} new, ${seen.size} total`);
if (!data.hasMore) return; // 0 or false is the only end signal
if (data.cursor == null || data.cursor === "" || data.cursor === cursor) {
throw new Error(`cursor did not advance after page ${page}`);
}
cursor = data.cursor;
}
throw new Error(`stopped after MAX_PAGES=${MAX_PAGES}`);
}
const [kind = "user", value = "nasa"] = process.argv.slice(2);
const [endpoint, params] =
kind === "sound"
? ["/music/posts", { music_id: value, count: 35 }]
: ["/user/posts", { unique_id: value, count: 10 }];
for await (const video of iterVideos(endpoint, params)) {
process.stdout.write(JSON.stringify(video) + "\n");
}
Both scripts print progress to stderr, like page 2: 10 videos, 10 new, 20 total, so you can redirect stdout to a file and still watch the crawl.
Daily monitoring: fetch only what's new
Once you have a creator's history, you rarely need to walk the whole feed again. Because the feed is newest first, you can stop at the first video you already know, skipping pinned ones. For most creators that is one request a day. Using iter_videos from the Python script:
def new_videos(username, known_ids):
fresh = []
for video in iter_videos("/user/posts", {"unique_id": username, "count": 10}):
if video["video_id"] in known_ids:
if video.get("is_top"):
continue # pinned videos sit on top whatever their age
break # everything after this point is already known
fresh.append(video)
return fresh
Because iter_videos is a generator, breaking out of the loop stops the crawl, and no further pages are requested. View and like counts on older videos still change, so if you track engagement over time, re-fetch the first page or two on a schedule and store a snapshot per day.
Scrape TikTok sounds and the videos that use them
Sounds are how trends spread on TikTok. /music/info returns a sound's details, including how many videos use it, and /music/posts lists those videos. Both take a music_id, which you can read from any video's music_info.id, or the sound page URL as url.
curl -X POST https://scrapingbot.io/api/v1/tiktok \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"endpoint": "/music/posts", "params": {"music_id": "7682907287056337667", "count": 35}}'
/music/info returns id, title, author, album, duration, original (whether it is a creator's original audio), video_count, an audio play URL and a cover. /music/posts returns videos in the same shape as a creator's feed, with cursor and hasMore. Paginate it exactly like /user/posts; the scripts above do this with python tiktok_feed.py sound 7682907287056337667.
A practical trend tracker polls /music/info for a list of sounds once a day, records video_count, and alerts when a sound's growth rate jumps. That is 1 credit per sound per day, and you only walk /music/posts for the sounds that are taking off.
Scrape TikTok comments
/comment/list takes the video URL and returns comments with their authors, up to 50 per page. The response also includes total, the video's comment count, which tells you up front how many pages a full crawl will take.
{
"success": true,
"data": {
"comments": [
{
"id": "7692163746915566368",
"text": "We want Artemis 4 so bad!!",
"create_time": 1790971455,
"digg_count": 27,
"reply_total": 2,
"video_id": "7692161454694206733",
"user": { "unique_id": "christianvale007", "nickname": "Christian valentini", … }
},
…
],
"cursor": 20,
"hasMore": true,
"total": 100
},
"creditsUsed": 1
}
The pagination rules are the same: send cursor back and stop when hasMore is falsy. To reuse the scripts, change the endpoint to /comment/list, the params to {"url": video_url, "count": 50}, and the item list and key from videos/video_id to comments/id. reply_total tells you how many replies a comment has; the replies themselves are not part of this endpoint. To fetch them, send the comment's id as comment_id to /comment/reply, together with the video's video_id.
Search TikTok videos and users
/feed/search finds videos by keyword or hashtag. Narrow it with region, a two-letter country code such as US. Results are full video objects, so everything in the field reference below applies.
// {"endpoint": "/feed/search", "params": {"keywords": "sourdough starter", "count": 20, "region": "US"}}
{
"success": true,
"data": {
"videos": [
{ "video_id": "7571140261762354462", "title": "Let me know if this is helpful or if you…", "play_count": 17600000, "author": { "unique_id": "laurynchayce", … }, … },
{ "video_id": "7599425357430656286", "title": "New to sourdough? This is what your starter…", "play_count": 762600, … },
… 22 more
],
"cursor": 20,
"hasMore": true,
"searchId": "202610030535055E7848D53B85BA02165F"
},
"creditsUsed": 1
}
We asked for 20 videos and got 24, another reminder that page sizes are TikTok's decision. Search pagination needs one extra field: send the first page's searchId back as search_id together with the cursor, so later pages come from the same result set. Using call from the Python script:
def search_videos(keywords, max_pages=5, region="US"):
params = {"keywords": keywords, "count": 20, "region": region}
seen, results = set(), []
for _ in range(max_pages):
data = call("/feed/search", params)
for video in data.get("videos") or []:
if video["video_id"] not in seen:
seen.add(video["video_id"])
results.append(video)
if not data.get("hasMore"):
break
params = {**params, "cursor": data["cursor"], "search_id": data["searchId"]}
return results
Search is ranked by relevance, so the useful results are usually on the first few pages. Cap max_pages rather than crawling a search to the end. publish_time and sort_type take TikTok's numeric filter codes; leave them out unless you have tested the codes you need.
/user/search finds accounts. It returns a single page as user_list, each entry with a user and a stats object, and ignores any cursor you send. Raise count to 50 for more accounts. It is a quick way to build a list of creators in a niche, which you can then feed into /user/info and /user/posts.
TikTok field reference
The fields below are the ones most projects store. They appear on video objects from /, /user/posts, /music/posts and /feed/search. Responses contain many more fields; keep the raw JSON if you might need them later.
| Field | Type | Meaning |
|---|---|---|
video_id / id | string | The video's stable ID (id on the / endpoint). Use it as your primary key. |
title | string | The caption, hashtags included |
create_time | integer | Upload time in Unix seconds (UTC) |
duration | integer | Length in seconds |
play_count | integer | Views |
digg_count | integer | Likes |
comment_count | integer | Comments |
share_count | integer | Shares |
collect_count | integer | Saves (favorites) |
play / wmplay / hdplay | URL | No-watermark, watermarked and HD video files. Signed; they expire. |
size / wm_size / hd_size | integer | File sizes in bytes |
cover / origin_cover | URL | Thumbnail images. Signed; they expire. |
is_top | 0 or 1 | 1 when the video is pinned to the profile (feed endpoints) |
region | string | Two-letter country code of the upload |
share_url | URL | The public video URL (feed endpoints) |
author.id, author.unique_id, author.nickname | string | Numeric user ID, username and display name |
music_info.id, .title, .author, .original | mixed | The sound used, and whether it is original audio |
aweme_id | string | Internal media identifier. Not the video ID; don't key on it. |
On profiles (/user/info), the useful fields are user.id, user.uniqueId, user.nickname, user.signature (bio), user.verified, user.privateAccount, user.secUid and stats.followerCount, followingCount, heartCount (total likes) and videoCount. On comments, store id, text, create_time, digg_count, reply_total and user.unique_id.
Rate limits, concurrency and retries
ScrapingBot limits how many requests you have in flight at the same time, not how many you send per minute. A slot is taken when a request starts and freed when it finishes. Going over the limit returns 429 immediately and costs nothing.
| Plan | Price / month | Credits / month | Concurrent requests | TikTok cost per 1,000 requests |
|---|---|---|---|---|
| Free | $0 | 100 on sign-up | 1 | n/a |
| Starter | $49.99 | 275,000 | 10 | $0.18 |
| Startup | $99.99 | 1,100,000 | 50 | $0.09 |
| Pro | $249.99 | 3,300,000 | 100 | $0.08 |
| Business | $599.99 | 9,000,000 | 200 | $0.07 |
| Business+ | $999.99 | 15,500,000 | 200 | $0.06 |
How to use that limit well:
- Parallelize across creators, not within a feed. Each page needs the previous page's cursor, so one feed is inherently sequential. Run one feed per worker and as many workers as your plan allows.
- Size the pool to your plan. 10 workers on Starter, 50 on Startup. More workers only produce free
429s. - Retry the right errors. Retry
408,429and5xxwith exponential backoff; they are refunded, so a retry never pays twice. Don't retry400,401,402or404without changing the request. - Set a client timeout above 30 seconds. The API times out TikTok requests at 30 seconds and answers
408; a 60-second client timeout lets you see that answer instead of cutting the connection.
A pool that crawls many creators at once, using iter_videos from the script:
from concurrent.futures import ThreadPoolExecutor
CONCURRENCY = 10 # your plan's limit
def crawl(username):
return username, list(iter_videos("/user/posts", {"unique_id": username, "count": 10}))
creators = ["nasa", "natgeo", "bbcnews"]
with ThreadPoolExecutor(max_workers=CONCURRENCY) as pool:
for username, videos in pool.map(crawl, creators):
print(username, len(videos), "videos")
The concurrency docs and the error reference list every status code and whether it is charged.
What scraping TikTok costs
Every TikTok request costs 1 credit, whether it returns one profile or 35 videos. Only successful 200 responses are charged; errors, timeouts and requests rejected before they run are free. So the cost of a job is simply the number of successful requests:
| Job | Requests | Credits |
|---|---|---|
| Profile stats for 1,000 creators | 1,000 × /user/info | 1,000 |
| Full feed of a creator with 500 videos | about 50 pages at ~10 videos | about 50 |
| Every video using a sound with 2,000 videos | about 60 to 200 pages, depending on page size | 60 to 200 |
| All comments on a video with 5,000 comments | 100 to 250 pages at 20 to 50 per page | 100 to 250 |
| Daily check for new videos on 500 creators, for 30 days | 500 × 30, one page each | 15,000 |
The daily-monitoring job above fits easily in the Starter plan's 275,000 credits, with room for a full backfill of every creator first. The 100 free credits are enough to run every example in this guide several times. See the credits and charging docs for the full rules.
Legal and ethical notes
This is practical guidance, not legal advice.
- Public data only. The API reads what any logged-out visitor can see. Private accounts return their basic profile with
privateAccount: true, and their videos are not available. - Know the terms. Read TikTok's Terms of Service and understand how they apply to your use. Using a third-party API doesn't change what you are allowed to do with the data.
- Treat people's data as personal data. Usernames, bios and comments identify people. If you store them, privacy laws such as GDPR and CCPA can apply: collect what you need, keep it only as long as you need it, and secure it.
- Respect copyright. Videos and sounds belong to their creators. Analyzing metadata is very different from re-uploading someone's video. Download files only for uses you have rights to.
- Be a good citizen. Don't build tools for harassment, stalking or spam, and don't try to reach content creators have restricted.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
400 "Invalid video URL" | A profile or short link sent to the / endpoint | Use a URL that contains /video/, or /user/info for profiles |
400 "invalid count parameter" | A large count on /user/posts | Use the default of 10 |
400 "Unsupported direct TikTok endpoint" | A typo, or an endpoint that doesn't exist (such as stories) | Use one of the eleven endpoints above |
404 on /user/info | The account doesn't exist, was renamed or was banned | Check the username; not charged |
429 | More requests in flight than your plan allows | Shrink the worker pool and back off; not charged |
| Crawl ends early | Stopping on a short or empty page | Stop only on hasMore |
| Duplicate rows | The same video on two pages | Primary key on video_id |
| Download link returns 403 | The signed URL expired | Fetch the video again for fresh links |
TikTok scraping FAQ
Is it legal to scrape TikTok?
Collecting publicly available TikTok data is common, and it carries less risk than anything that needs a login, but the answer depends on your jurisdiction, what you collect and what you do with it. Stick to public data, read TikTok's Terms of Service, treat usernames and comments as personal data under laws such as GDPR and CCPA, and don't republish videos you have no rights to. If the stakes are high, ask a lawyer about your specific use.
Does TikTok have an official API for this?
TikTok has developer products, such as the Research API for approved researchers and the Display API for accounts that log in to your app. They require an application and approval, and they are scoped to those uses. For public profiles, feeds, sounds, comments and search on any account, most teams use a scraping API instead.
How do I get every video from a TikTok account?
Call /user/posts with the unique_id, then send the returned cursor back on the next request. Stop only when hasMore is 0 or false, and de-duplicate on video_id. With the default page size of 10, a 500-video account takes about 50 requests, which is 50 credits.
Why do I get fewer videos than the count I asked for?
TikTok decides the page size. Pages often hold around 10 items, sometimes more and sometimes fewer, and a page can even be empty while more pages remain. A short page is not the end of the feed. Only hasMore set to 0 or false means you are done.
Can I download TikTok videos without a watermark?
Yes. The video endpoint returns play (no watermark), wmplay (watermarked) and, when you send hd as 1, hdplay. The links are signed and expire within a few days, so download the file soon after you fetch them, and only download content you have the right to use.
Can I scrape private TikTok accounts?
No. Only public data is available. A private profile still returns its basic profile with privateAccount set to true, but its videos are not available.
How much does it cost to scrape TikTok?
Every TikTok request costs 1 credit, whatever it returns, and only successful requests are charged. New accounts get 100 free credits. On the Starter plan, $49.99 buys 275,000 credits a month, which is about $0.18 per 1,000 requests.
Next steps
Start with one profile and one page of its feed to see the data shapes, then run the full-feed script on a small account before you point it at thousands of creators. The TikTok API page has more response examples, and the pagination docs repeat the rules in reference form. If you work with AI agents, the MCP server exposes the same TikTok data as tools.
Collecting Instagram data too? The Instagram scraping API guide covers profiles, posts, reels and comments, including its three different pagination styles. And if you are scraping websites directly, the guide to proxy types explains when datacenter, residential and stealth proxies are worth paying for.