A comment thread is the only place on TikTok where an audience writes in its own words. Play counts tell you a video travelled; the comments tell you whether people found it funny, argued about it, asked where to buy the thing or came to complain. Turning that into a number you can report is a four-step pipeline: collect, normalise, score, aggregate. This guide covers all four, with collection built on post-comments and post-comment-replies, and the scoring step kept deliberately vendor-neutral - swap in whatever classifier, lexicon or hosted model you already trust.
What the API gives you, and what it does not
PrimeApi returns comment records, not sentiment labels. There is no sentiment field in the payload, and be sceptical of any TikTok data API that claims one: a label decided by someone else will be wrong for half the situations you point it at. "This is unhinged" is praise under a comedy video and a complaint under a customer-service one. The API is your collection layer; the judgement stays yours.
What you do get is a flat comment object with everything the scoring and weighting steps need:
| Field | Why it matters for sentiment |
|---|---|
text | The comment body, emoji included. The input to your classifier. |
cid | The comment ID. Your primary key, and what you upsert on between runs. |
aweme_id | The post ID the comment belongs to, so many videos share one table. |
comment_language | Detected language code, for example "en". The field that makes a multilingual thread tractable. |
digg_count | Likes on the comment itself. The natural weight when a loud minority is drowning out a quiet majority. |
create_time | Unix timestamp in seconds. Lets you bucket sentiment over time and spot the moment a thread turned. |
reply_comment_total | How many replies hang off the comment. High values mark the arguments, where the strong opinions are. |
is_author_digged, author_pin | The creator liked or pinned the comment. A fair reason to exclude it from an "organic audience" cut. |
is_high_purchase_intent | TikTok's own commercial-intent flag. Not sentiment, but a useful second axis. |
text_extra | Markup for mentions and hashtags inside the text - the handle for stripping them before scoring. |
status, fold_status, no_show | Visibility and moderation flags. Folded and hidden comments still arrive in the payload. |
label_list | Labels TikTok has attached to the comment, or null. |
The envelope carries total, cursor, has_more as an integer flag, has_filtered_comments, status_code, status_msg and a log_pb block with the request ID. Two are load-bearing: total is your denominator, and has_filtered_comments is TikTok saying the thread you see is not the whole thread.
Step 1: Collect the thread
The base URL is https://api.primeapi.co/ and authentication is one header, X-PrimeAPI-Key. post-comments needs postId and takes optional count and cursor paging parameters. PrimeApi is independent and is not affiliated with or endorsed by TikTok or ByteDance, so there is no developer account or app review in the way.
curl
curl -s "https://api.primeapi.co/post-comments?postId=7330315169584778539&count=50&cursor=0" \
-H "X-PrimeAPI-Key: YOUR_API_KEY"
Node.js (Axios)
const axios = require("axios");
const HEADERS = { "X-PrimeAPI-Key": "YOUR_API_KEY" };
async function collectComments(postId, maxPages = 20, count = 50) {
let cursor = 0, pages = 0;
const out = [];
while (pages < maxPages) {
const res = await axios.get("https://api.primeapi.co/post-comments", {
params: { postId, count, cursor },
headers: HEADERS
});
const data = res.data;
const rows = data.comments || [];
if (!rows.length) break;
rows.forEach(c => out.push({
cid: c.cid,
text: c.text,
lang: c.comment_language,
diggs: c.digg_count,
ts: c.create_time,
replies: c.reply_comment_total
}));
pages++;
if (!data.has_more) break;
if (data.cursor === cursor) break; // cursor stalled
cursor = data.cursor;
}
return out;
}
collectComments("7330315169584778539")
.then(rows => console.log("collected:", rows.length))
.catch(err => console.error(err.response ? err.response.data : err.message));
Python (Requests)
import requests
BASE = "https://api.primeapi.co"
HEADERS = {"X-PrimeAPI-Key": "YOUR_API_KEY"}
def comment_page(post_id, cursor=0, count=50):
r = requests.get(f"{BASE}/post-comments",
params={"postId": post_id, "count": count, "cursor": cursor},
headers=HEADERS, timeout=20)
r.raise_for_status()
return r.json()
def collect(post_id, max_pages=20, count=50):
cursor, pages, rows = 0, 0, []
while pages < max_pages:
data = comment_page(post_id, cursor, count)
page = data.get("comments") or []
if not page:
break
rows.extend(page)
pages += 1
if not data.get("has_more"):
break
nxt = data.get("cursor", 0)
if nxt == cursor:
break
cursor = nxt
return rows, data.get("total", 0)
rows, total = collect("7330315169584778539")
print(f"{len(rows)} of {total} comments collected")
Two habits save you later. Always coerce comments to an empty list before iterating - on a deleted video, or one with comments switched off, it comes back null rather than as an empty array, and a raw loop dies on the first one in a batch. And keep total next to your row count, since that ratio turns a sentiment percentage into a defensible one. Paging, the null case and the full field list are covered in the guide to scraping TikTok comments via API.
Decide early whether replies belong in your corpus. Top-level comments react to the video; replies react to other comments, and mixing them shifts your numbers negative, because arguments live in sub-threads. If you want them, expand only comments where reply_comment_total clears a threshold and call post-comment-replies with the parent's cid as commentId alongside the original postId. Reply records carry the same text, digg_count, create_time and comment_language fields, with reply_id holding the parent ID so the two levels stay apart. The walkthrough on comment replies has the reconstruction detail.
Step 2: Normalise the text
Raw TikTok comments are not the clean prose most sentiment models were trained on. They are short, full of emoji, stuffed with mentions, and often not in the language you expected. Normalisation is where most of the accuracy is won or lost.
Emoji are data, not noise
The instinct to strip non-ASCII characters is exactly wrong here. A large share of TikTok comments carry their meaning in emoji, and a fair number contain nothing else. Score them before you touch the text: keep a small table of the emoji that matter in your domain, extract them into their own feature, then clean the remaining text. If your classifier already handles emoji, feed them through untouched.
Strip the plumbing, keep the words
Mentions and hashtags inflate the text without adding sentiment, and text_extra tells you the markup is there. Repeated characters ("soooo good") should be collapsed but not removed, since the repetition is intensity. Trim the result and drop whatever is empty afterwards.
import re
MENTION = re.compile(r"@[\w.]+")
HASHTAG = re.compile(r"#\w+")
REPEATS = re.compile(r"(.)\1{2,}")
EMOJI = re.compile("[\U0001F300-\U0001FAFF☀-➿]")
def normalise(comment):
raw = comment.get("text") or ""
emoji = EMOJI.findall(raw)
body = EMOJI.sub(" ", raw)
body = MENTION.sub(" ", body)
body = HASHTAG.sub(" ", body)
body = REPEATS.sub(r"\1\1", body)
body = re.sub(r"\s+", " ", body).strip()
return {
"cid": comment["cid"],
"lang": comment.get("comment_language") or "unknown",
"diggs": comment.get("digg_count", 0),
"ts": comment.get("create_time", 0),
"emoji": emoji,
"text": body,
"raw": raw,
}
Keep the original string. Storage is cheap and re-collecting is not - the day you change classifiers, re-score from your own database rather than spend credits reading the same threads again.
Split by language before you score
comment_language is the field that makes this manageable. Group the corpus by it, then either route each group to a model that speaks that language or restrict the report to the languages you can score honestly. What you must not do is run an English model over the whole pile and call the output the thread's sentiment. Either way, publish the coverage: "sentiment computed on 71% of comments (en, es)" is a real finding, an unqualified percentage over a multilingual thread is not.
Step 3: Score, without picking a vendor
The scoring step is deliberately the least opinionated part of this pipeline. A lexicon scorer is fast, free and transparent, and a reasonable start for English threads. A trained classifier does better with sarcasm and slang. A hosted language model handles several languages in one pass and costs per call. All three plug into the same interface: take a normalised record, return a score and a confidence.
Two rules matter more than the choice. Keep the scorer behind one function so you can swap it without touching collection or aggregation. And store the score next to the cid with a model version, so a re-score adds a column rather than silently rewriting history and making last month's chart unreproducible.
Also keep neutral as a real class. TikTok comments are full of "first", "who's here in 2026" and tagged friends, none of them positive or negative. Forcing them into a binary is the fastest way to a chart that moves for no reason.
Step 4: Aggregate into something reportable
A plain average is the weakest useful metric because it treats a comment nobody saw the same as one with 40,000 likes. Weighting by digg_count is closer to what the audience endorsed. Report both - when they diverge sharply, that gap is the story: the crowd is upvoting a criticism most commenters did not write.
from collections import Counter
from datetime import datetime, timezone
def aggregate(scored, total_reported):
counts = Counter(s["label"] for s in scored)
n = len(scored) or 1
plain = sum(s["score"] for s in scored) / n
wsum = sum(s["score"] * (1 + s["diggs"]) for s in scored)
wden = sum(1 + s["diggs"] for s in scored) or 1
by_hour = {}
for s in scored:
hour = datetime.fromtimestamp(s["ts"], timezone.utc).strftime("%Y-%m-%d %H")
bucket = by_hour.setdefault(hour, [0, 0])
bucket[0] += s["score"]
bucket[1] += 1
return {
"sampled": len(scored),
"reported_total": total_reported,
"coverage": round(len(scored) / total_reported, 3) if total_reported else None,
"mean_score": round(plain, 3),
"like_weighted_score": round(wsum / wden, 3),
"mix": dict(counts),
"hourly": {h: round(v[0] / v[1], 3) for h, v in by_hour.items()},
}
Bucket by create_time as well. Sentiment on a video is not a constant - the first hour is regular followers, and the tone often shifts once the video reaches a wider audience. An hourly curve shows that; a single number hides it. If you already track reach and interaction rates, the sentiment curve sits beside them, and the method for that side is in the guide to calculating TikTok engagement rate with an API.
Caveats that decide whether the number is trustworthy
Bot spam and promo. Every popular thread carries a layer of copy-paste comments, follow-for-follow bait and dropshipping links. Near-duplicate detection on text removes most of it, comments that are almost entirely mentions or hashtags remove another slice, and clusters of identical messages inside a tight create_time window catch the rest. TikTok's status, fold_status and no_show flags filter what it already suppressed.
Ranking bias. The thread comes back in TikTok's order, not chronological order, and that order favours comments with engagement. Read only the first page and you are measuring the most-liked comments, not the audience. Page deeper, or state plainly that you sampled the top N.
Sample size and coverage. Compare your rows against total every time. On heavily commented videos the reachable set is a large sample rather than an archive, and has_filtered_comments warns that some are held back however deep you page. post-detail, whose stats.commentCount is the public figure, is a cheap sanity check when the two disagree.
Sarcasm and in-group language. No general-purpose model reads "this is criminal" as a compliment. If your reports drive decisions, hand-label a few hundred comments from your own niche and measure your scorer against them first.
Credits, limits and errors
Every request costs 1 credit and the remaining balance comes back in the X-PrimeAPI-Balance header. At 50 comments per page, a thousand videos sampled 100 comments deep is 2,000 credits before any reply expansion. Data is fetched in real time with no caching and responses average around a second. The default rate limit is 100 requests per minute, easy to hit once you fan out across many videos - add a short sleep between pages. Failures arrive as a JSON message: "Please sign up to primeapi.co" for a missing key, "PrimeAPI-Key is not available" for a bad one, a per-minute message when you cross the limit, and "Your balance has been exhausted..." when credits run out. Branch on that body rather than on the status code alone. The documentation lists the same behaviour in reference form, and you can try both comment endpoints against your own key in the playground.
New accounts include 50 free credits, enough to page through a couple of threads and calibrate a scorer. Beyond that the pricing tiers start at $9.90 for 2,500 credits and run to $279.00 for 500,000, so a monthly report over a few hundred videos stays in the low tiers. If you have no key yet, registration takes a minute.
Putting it together
The pipeline that survives contact with real data looks like this: page post-comments until has_more is 0 or your page cap trips, upsert on cid, expand only the sub-threads whose reply_comment_total justifies the credit, normalise with emoji preserved and mentions removed, group by comment_language, score behind a swappable function with the model version recorded, then aggregate both plainly and weighted by digg_count, with coverage against total printed next to every figure. Keep the raw text so re-scoring is free, keep neutral as a class so the chart does not twitch, and treat low coverage as something you cannot yet report. Done that way, comment sentiment stops being a vibe and becomes a metric you can defend.
Frequently asked questions
Does the API return a sentiment score for each comment?
No. PrimeApi returns the raw comment record - text, digg_count, create_time, comment_language and the rest - and you run the classifier yourself. That is deliberate: sentiment is domain-specific, and a fixed label baked into the payload would be wrong for half the use cases. The API is the collection layer, your model or lexicon is the scoring layer.
How should I handle emoji in comment text?
Do not strip them. Emoji carry most of the signal in short TikTok comments, and a comment whose text is nothing but emoji is common enough that discarding it throws away real data. Map the emoji you care about to scores in your own table, run that pass before any punctuation cleaning, and keep the raw text in storage so you can re-score later without re-collecting.
What do I do with comments in other languages?
Every record carries comment_language, a detected language code such as "en". Group by it before scoring, then either route each group to a model that handles that language or restrict your report to the languages you can score honestly. Report the coverage percentage alongside the result - a sentiment figure computed on 40% of a thread should say so.
How many comments do I need before the number means anything?
There is no universal threshold, but compare your collected row count against the total field in the response envelope and publish that ratio. A few hundred comments on a video with a few hundred comments is a census; two hundred out of fifty thousand is a sample of whatever TikTok ranked highest, which skews positive. Treat small or unrepresentative threads as unreportable rather than noisy.
How can I filter out bot spam and promo comments?
Cheap heuristics work well: drop near-duplicate text values across the thread, drop comments whose text is almost entirely mentions or hashtags according to text_extra, and be wary of bursts of identical messages inside a narrow create_time window. TikTok's own moderation flags - status, fold_status and no_show - filter another slice, and has_filtered_comments on the envelope tells you TikTok is already holding some back.
What does a sentiment run cost in credits?
One credit per request, whatever the page size, so a thread read 50 comments at a time costs 1 credit per 50 comments plus a credit for each reply page you expand. Scoring itself costs nothing on the PrimeApi side. New accounts get 50 free credits, the default rate limit is 100 requests per minute, and your remaining balance comes back in the X-PrimeAPI-Balance response header.