TikTok's comment count is not a count of rows you can page through. It counts replies too, and the pagination cursor hands the same rows back more than once. Paginating Scavio's /api/v1/tiktok/video/comments to exhaustion across nine videos returned 3,458 rows that deduplicated to 2,855 distinct comments, against the 5,360 the API's own total field reported. That is 53.3 percent. The missing half is mostly replies, which live behind /api/v1/tiktok/video/comments/replies and have to be fetched one call per parent comment.
If you have ever shipped a comment pipeline, watched it finish cleanly, and then had someone open the video and point at a comment you do not have, this is why. Nothing errored. The scrape was working exactly as written.
What the count actually counts
Nine videos, chosen across five niches with reported comment counts between 311 and 894, each paginated with count: 50 until has_more came back 0. 75 requests in total.
| video | reported total | rows returned | unique | duplicates | replies claimed |
|---|---|---|---|---|---|
| home workout | 368 | 332 | 247 | 25.6% | 22 |
| hiking gear | 456 | 408 | 312 | 23.5% | 48 |
| car detailing | 769 | 604 | 506 | 16.2% | 164 |
| study tips | 311 | 225 | 188 | 16.4% | 87 |
| meal prep | 573 | 411 | 309 | 24.8% | 99 |
| skincare | 742 | 429 | 384 | 10.5% | 252 |
| car detailing | 431 | 236 | 217 | 8.1% | 200 |
| home workout | 816 | 402 | 379 | 5.7% | 406 |
| study tips | 894 | 411 | 313 | 23.8% | 274 |
| all nine | 5,360 | 3,458 | 2,855 | 17.4% | 1,552 |
Two numbers matter here. A pipeline that counts responses believes it collected 3,458 of 5,360, or 64.5 percent. A pipeline that deduplicates first knows it has 2,855, or 53.3 percent, and that it wrote 603 duplicate rows to the database along the way.
The spread across videos is wide and it is not random. The two videos where top-level comments were under half the total, at 46.4 and 35.0 percent, are the two with the heaviest reply activity. Conversation depth, not video size, decides how much of the tree a single-endpoint scrape misses.
The cursor is an offset, not a pointer
The duplicates are not a bug in any particular client. Here is the page-by-page trace of the 769-comment video, showing rows returned against rows not already seen:
| page | cursor sent | rows | new | has_more |
|---|---|---|---|---|
| 0 | 0 | 49 | 49 | 1 |
| 1 | 50 | 49 | 49 | 1 |
| 2 | 100 | 48 | 48 | 1 |
| 3 | 150 | 49 | 48 | 1 |
| 4 | 200 | 50 | 50 | 1 |
| 5 | 250 | 50 | 28 | 1 |
| 6 | 300 | 50 | 42 | 1 |
| 7 | 350 | 49 | 47 | 1 |
| 8 | 400 | 48 | 48 | 1 |
| 9 | 450 | 48 | 33 | 1 |
| 10 | 500 | 50 | 26 | 1 |
| 11 | 550 | 49 | 28 | 1 |
| 12 | 600 | 15 | 10 | 0 |
The cursor advances by exactly 50 every page regardless of how many rows actually came back, which is the tell: it is a positional offset into a list, not a pointer to the last row you saw. The list is ranked and it reorders between your requests as people like and reply to things. Ask for position 500 a few seconds later and some rows have moved above it, so you re-read them, and some have moved below it, so you never see them at all.
The practical consequences are small and specific. Deduplicate on cid, not on text and not on author. Never treat sum(len(page.comments)) as progress. And do not retry a page hoping to close the gap, because a retry is a fresh read of a list that has moved again.
Replies are a second endpoint
A top-level comment carries reply_comment_total, and if that number is above zero the replies are not in the payload. There is no expand flag. You make another call.
{
"cid": "7609107020574769921",
"text": "these study tricks are gold! i need to try that brown noise thing.",
"aweme_id": "7605687868131953927",
"create_time": 1771633304,
"digg_count": 452,
"reply_comment_total": 3,
"reply_id": "0",
"reply_to_reply_id": "0",
"status": 1,
"user": {
"uid": "7584959179316560908",
"sec_uid": "MS4wLjABAAAA7raJ35KDo5VJPuMvvVSABsW_bRLMSm7tE2QcCNLn_Rkyz3tTwlFJUz0sHAFns38H",
"unique_id": "johan94412",
"nickname": "johxn"
}
}Only a minority of comments have replies at all, so this is cheaper than it sounds. On the skincare video, 72 of 384 unique top-level comments carried at least one reply, or 18.8 percent. But those 72 accounted for 252 replies, which is why skipping them costs a third of the tree.
reply_comment_total is an estimate and should be treated as one. Across 90 reply threads pulled with count: 50, the field claimed 1,026 replies where the endpoint returned 974. Sixty-nine threads matched exactly, 19 came back short, and two came back long, including one that promised 48 and delivered 50. Three threads had more than 50 replies and returned has_more: 1, so a thread can itself need pagination.
Use the field to decide whether a comment is worth a call. Use the response's own total and has_more to decide when you are finished with that thread.
One video, resolved completely
The 311-comment study-tips video, taken all the way through both endpoints:
- 5 pagination calls on
/video/commentsreturned 225 rows, deduplicating to 188 unique top-level comments - 23 of those carried replies, claiming 87 between them
- 23 calls on
/video/comments/replies, one per thread and none of them needing a second page, returned 81 unique replies - Total collected: 269 of 311, or 86.5 percent
28 requests. Two endpoints. And still not everything.
The last 13.5 percent is the honest part of this. Those rows are moderated, deleted, filtered by the creator's comment settings, or hidden by keyword filters, and no endpoint exposes them because TikTok does not serve them to a logged-out viewer either. The response even carries a has_filtered_comments flag, which was 0 on all 75 of these calls, so filtering was not the cause here. If your acceptance criterion is "matches the number on the video," you will fail it forever. The reachable ceiling is roughly 85 to 90 percent, and the useful question is whether you are at 86 percent or at 53.
The pull, in order
import requests
BASE = "https://api.scavio.dev/api/v1/tiktok"
HEAD = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
def post(path, body):
r = requests.post(f"{BASE}{path}", headers=HEAD, json=body, timeout=90)
r.raise_for_status()
return r.json()["data"]
def full_tree(video_id):
top, cursor = {}, "0"
while True:
page = post("/video/comments", {
"video_id": video_id, "cursor": cursor, "count": 50,
})
for c in page.get("comments") or []:
top.setdefault(c["cid"], c) # dedupe here, not later
if page.get("has_more") != 1:
break
cursor = str(page["cursor"])
replies = {}
for c in top.values():
if not c.get("reply_comment_total"):
continue # no call for childless comments
rcur = "0"
while True:
page = post("/video/comments/replies", {
"video_id": video_id, "comment_id": c["cid"],
"cursor": rcur, "count": 50,
})
for r in page.get("comments") or []:
replies.setdefault(r["cid"], r)
if page.get("has_more") != 1:
break
rcur = str(page["cursor"])
return list(top.values()), list(replies.values())Four things in there are load-bearing. setdefault keyed on cid absorbs the overlapping pages. The has_more check drives the loop instead of a page counter. The reply_comment_total guard is what keeps the reply pass affordable, since it skips 80 percent of comments. And the inner loop follows has_more too, because a popular comment can carry more than one page of replies.
If you are wiring this into a Python pipeline more broadly, our guide to scraping TikTok with Python covers the surrounding pieces, and why TikTok profile scrapers break on the secUid covers the identifier layer that trips the same pipelines a week later.
What you now own
You have just read the shape of the work. Resolving a video's comment tree means owning a deduplication key across overlapping pages, a reply pass that fans out per parent comment, per-thread pagination for the popular ones, and a coverage metric honest enough to tell you when a run went wrong. That is before TikTok changes the payload, which it does, and before you have decided what to do with a comment that reappears in a later run with a different like count.
Scavio absorbs the part underneath. Token minting, device parameters, proxy rotation and payload normalisation happen on our side, and when TikTok changes something it is our on-call rather than your Saturday. The pagination and dedupe logic above is still yours, because it is your data model, but the transport is not.
Pricing is one credit per request at $0.01 per credit, no monthly commitment. The complete 311-comment tree above cost 28 credits, or 28 cents. The whole nine-video study in this post, including the searches that selected the videos, cost 196 credits, which is $1.96.
Start with 50 free credits, no card — enough to resolve a full comment tree on a mid-size video and check the 86 percent number yourself.
Endpoint reference lives in the TikTok API docs, and if you are still choosing a provider, the TikTok API alternatives comparison lists what each one does and does not return.
Questions people actually ask
Is there a way to see all TikTok comments? No, and it is worth planning around rather than debugging. Paginating to exhaustion and then resolving every reply thread got the study-tips video to 269 of the 311 comments its own API reported, which is 86.5 percent. The rest is moderated, deleted, filtered or author-hidden and is not addressable from any endpoint, paid or free.
Why does the comment count not match what I scraped? Because the count includes replies and your scrape probably did not. The total on a video counts every comment in the tree, while the comments endpoint returns only top-level rows. Across the nine videos, the top-level layer was 53.3 percent of the reported count and replies accounted for another 29.0 percent.
How do I get TikTok comment replies? From a separate endpoint, one call per parent comment. /api/v1/tiktok/video/comments/replies takes video_id plus comment_id and returns that thread. No flag on the comments endpoint expands replies inline, so budget one call for every comment where reply_comment_total is above zero.
Is there a free tool for this? Browser extensions and open-source scrapers exist and they are fine on small videos. They break on the same two things any paid API has to handle: the cursor returns overlapping pages, so counting rows overstates what you have, and replies need a second request per parent. Neither problem is solved by paying; both are solved by knowing they exist.
Does TikTok provide an official comments API? Through the Research API, yes, but it is gated to approved academic and research applicants in specific regions and is not available for general commercial use. That gap is the entire reason the unofficial ecosystem exists.
Why am I getting duplicate rows? The cursor is a positional offset into a list that reorders between requests. Ask for offset 250 twice and you get overlapping but not identical sets. 17.4 percent of the rows returned across 75 calls were duplicates. Deduplicate on cid.
Is reply_comment_total accurate? Close, but an estimate. Across 90 threads it claimed 1,026 replies where one page returned 974, and individual threads missed in both directions. Use it as a yes-or-no signal for whether to spend a call, then trust the reply response's own total and has_more.
What does a full tree cost? One credit per request at $0.01. The 311-comment video took 5 pagination calls and 23 reply calls, so 28 cents. Cost tracks reply threads rather than comment count, so a video with 800 quiet comments is cheaper than one with 300 argumentative ones.
Numbers in this post were measured on 23 August 2026 against live endpoints. TikTok's ranking changes between requests, so a rerun will produce nearby but not identical figures; the duplicate rate in particular depends on how fast the comment list is moving while you page it.