Engagement rate is the metric everybody reports and almost nobody defines the same way. One tool divides by followers, another divides by views, a third averages per-post rates across a window, and the three answers for the same creator can differ by a factor of ten. None of them is wrong - they measure different things - but you cannot compare numbers across tools unless you know which formula produced them. This guide builds all three from raw API fields: two calls, the exact keys that feed each denominator, why the by-view version is the one to trust, and a worked example where the same five posts produce three very different percentages.
The two calls behind every formula
Everything you need comes from userinfo-by-username and user-posts. The first turns a handle into the account record and the follower total. The second returns that account's uploads with per-video counters attached. Both take your key in the X-PrimeAPI-Key header against the base URL https://api.primeapi.co/.
The link between them is secUid, the opaque profile identifier that user-posts requires - it will not accept a handle or a numeric ID. Read it once from user.secUid and store it, because handles change and the secUid does not. There is a full explainer on what a TikTok secUid is and how to get one if that identifier is new to you.
# 1. handle -> secUid + followerCount
curl -s "https://api.primeapi.co/userinfo-by-username?username=tiktok" \
-H "X-PrimeAPI-Key: YOUR_API_KEY"
# 2. secUid -> one page of posts with per-video counters
curl -s "https://api.primeapi.co/user-posts?secUid=MS4wLjABAAAA...&count=35&cursor=0" \
-H "X-PrimeAPI-Key: YOUR_API_KEY"
The profile response splits into user and stats. The feed response nests one level deeper: the posts live at data.itemList, with data.cursor and data.hasMore alongside them for paging. Two credits gets you a follower count and up to 35 posts of engagement data, which is enough for a complete rate calculation on one account.
The three formulas
Each row below is a genuinely different question. Pick deliberately, then label whichever number you publish with the formula that produced it.
| Formula | Expression | Fields it needs | Answers |
|---|---|---|---|
| By view (ERV) | (diggCount + commentCount + shareCount) / playCount × 100 | Item stats only | How strongly the people who saw this reacted to it |
| By follower | (diggCount + commentCount + shareCount) / followerCount × 100 | Item stats + profile stats.followerCount | How much reaction a post produced relative to the audience the account owns |
| Average by post | Σ engagements over N posts / (followerCount × N) × 100 | Both, across a page of itemList | What a typical post from this account is worth to a sponsor |
There is also a one-call shortcut that needs no feed at all: heartCount / videoCount / followerCount × 100 from the profile response gives a lifetime like rate. It costs a single credit and it is genuinely useful for filtering a list of thousands of handles down to the few hundred worth measuring properly. Be clear about what it hides. It counts likes only - heartCount has no comment or share equivalent in that block - and it averages over the account's entire history, so one old viral post keeps inflating the figure for years. Treat it as a screening filter, never as the published number. The rest of that profile block is covered in the guide to getting a TikTok follower count via API.
Which response fields feed which number
Every counter below appears in the live responses. Nothing else in either payload is an engagement metric, so if a formula calls for a field that is not on this list, that field does not exist in the data.
| Field | Where it lives | Type | Role in the calculation |
|---|---|---|---|
playCount | itemList[].stats | int | Views. The by-view denominator. Only available per video. |
diggCount | itemList[].stats | int | Likes on the video. The largest term in every numerator. |
commentCount | itemList[].stats | int | Comments. Part of the standard numerator. |
shareCount | itemList[].stats | int | Shares. Part of the standard numerator. |
collectCount | itemList[].stats | int | Saves. Optional fourth term - include it or not, but stay consistent. |
repostCount | itemList[].statsV2 | string | Reposts. Only in statsV2, and needs casting before you add it. |
followerCount | profile stats | int | The by-follower denominator. |
heartCount | profile stats | int | Lifetime likes received. Feeds the one-call shortcut only. |
videoCount | profile stats | int | Public uploads. Divisor for the lifetime shortcut and a sanity check on your sample. |
createTime | itemList[] | int | Epoch seconds. Defines the time window your average covers. |
authorStats | itemList[] | object | Follower snapshot carried on each item, handy when you process feeds without the profile call. |
One caution on the numerator: the classic definition is likes plus comments plus shares. Adding collectCount is defensible, but it lifts the result and makes your numbers incomparable with anyone else's. Either way, store the individual counters alongside the computed rate so you can recalculate later without paying for the data again.
Why by-view beats by-follower
TikTok distributes through recommendation, not subscription. A video from an account with 400 followers can reach two million people, and a video from an account with two million followers can reach 30,000. The follower count is a weak predictor of reach, which makes it a weak denominator.
The practical failure shows up the moment you look at two posts from the same account in the same week. Divide both by the same follower number and one scores 25 per cent while the other scores under one per cent - not because the content quality differed by 30 times, but because the algorithm pushed one and ignored the other. Divide each by its own playCount and both land within a point of each other, which is the honest reading: the audience reacted at roughly the same rate, only the size of the audience changed.
By-view is also comparable across account sizes. A 6 per cent rate means the same thing on a 5,000-follower account and a 5-million-follower one, so a mixed creator list ranks on a single column. By-follower rates trend downward as accounts grow, which favours small accounts and forces you into size brackets to correct a problem you created.
Keep by-follower anyway, for two reasons. Brand briefs and rate cards still specify it, and a large gap between the two numbers is informative in itself: high by-view with low by-follower means the account is reaching people who do not follow it, which is exactly what you want from a discovery campaign.
A worked example
Take a creator with stats.followerCount of 412,000 and run one page of user-posts. Five recent items, with the counters exactly as they arrive in each item's stats block:
| Post | playCount | diggCount | commentCount | shareCount | Engagements | By view | By follower |
|---|---|---|---|---|---|---|---|
| 1 | 1,240,000 | 96,400 | 1,820 | 4,110 | 102,330 | 8.25% | 24.84% |
| 2 | 88,000 | 6,300 | 210 | 305 | 6,815 | 7.74% | 1.65% |
| 3 | 305,000 | 21,900 | 640 | 1,150 | 23,690 | 7.77% | 5.75% |
| 4 | 47,500 | 2,980 | 96 | 130 | 3,206 | 6.75% | 0.78% |
| 5 | 612,000 | 44,800 | 1,050 | 2,260 | 48,110 | 7.86% | 11.68% |
| Total | 2,292,500 | 172,380 | 3,816 | 7,955 | 184,151 | 8.03% | - |
Post 1 shows the arithmetic: 96,400 + 1,820 + 4,110 = 102,330 engagements. Against its own 1,240,000 views that is 8.25 per cent. Against the account's 412,000 followers it is 24.84 per cent.
Now compare the two columns. By view, the five posts run from 6.75 to 8.25 per cent - a spread of a point and a half. By follower, they run from 0.78 to 24.84 per cent - a spread of 32 times, from the same account, in the same period, with the same audience. The by-follower column is measuring the recommendation algorithm's mood, not the content.
The account-level numbers follow from the totals. Weighted by-view rate is 184,151 / 2,292,500 = 8.03 per cent, which is the figure to publish. The unweighted mean of the five per-post rates is 7.67 per cent, slightly lower because the small posts count equally - say which one you used. Average by post is 184,151 / (412,000 × 5) = 8.94 per cent. And if that same account reports heartCount of 18,600,000 across a videoCount of 214, the one-call shortcut returns 21.10 per cent, more than double the recent-post figure, because it is still counting likes from posts that went viral years ago. Three formulas, one account, three defensible answers between 8 and 21 per cent.
Computing it in code
Both snippets do the same thing: resolve the handle, page one feed, sum the counters, return every rate at once. Guard the division - a brand new post can report a playCount of zero, and dividing by it poisons the whole average with an infinity.
Node.js (Axios)
const axios = require("axios");
const api = axios.create({
baseURL: "https://api.primeapi.co/",
headers: { "X-PrimeAPI-Key": "YOUR_API_KEY" }
});
async function engagementRate(username, count = 35) {
const profile = await api.get("userinfo-by-username", { params: { username } });
const user = profile.data.user || {};
const followers = (profile.data.stats || {}).followerCount;
if (!user.secUid || !followers) throw new Error("No profile for " + username);
const feed = await api.get("user-posts", {
params: { secUid: user.secUid, count, cursor: 0 }
});
const items = (feed.data.data || {}).itemList || [];
let engagements = 0;
let plays = 0;
const rows = [];
for (const item of items) {
const s = item.stats || {};
if (!s.playCount) continue;
const e = (s.diggCount || 0) + (s.commentCount || 0) + (s.shareCount || 0);
engagements += e;
plays += s.playCount;
rows.push({
id: item.id,
posted: new Date(item.createTime * 1000).toISOString().slice(0, 10),
views: s.playCount,
byView: +(e / s.playCount * 100).toFixed(2),
byFollower: +(e / followers * 100).toFixed(2)
});
}
return {
handle: user.uniqueId,
followers,
sampled: rows.length,
erByView: +(engagements / plays * 100).toFixed(2),
erByPost: +(engagements / (followers * rows.length) * 100).toFixed(2),
balance: Number(feed.headers["x-primeapi-balance"]),
rows
};
}
engagementRate("tiktok")
.then(r => console.log(r))
.catch(err => console.error(err.response ? err.response.data : err.message));
Python (Requests)
import time
import requests
BASE = "https://api.primeapi.co/"
HEADERS = {"X-PrimeAPI-Key": "YOUR_API_KEY"}
def engagement_rate(username, count=35, days=90):
profile = requests.get(BASE + "userinfo-by-username",
params={"username": username},
headers=HEADERS, timeout=15).json()
user = profile.get("user") or {}
followers = (profile.get("stats") or {}).get("followerCount")
if not user.get("secUid") or not followers:
return None
feed = requests.get(BASE + "user-posts",
params={"secUid": user["secUid"], "count": count, "cursor": 0},
headers=HEADERS, timeout=15).json()
items = (feed.get("data") or {}).get("itemList") or []
cutoff = int(time.time()) - days * 86400
engagements = plays = sampled = 0
for item in items:
if item.get("createTime", 0) < cutoff:
continue
s = item.get("stats") or {}
if not s.get("playCount"):
continue
engagements += s.get("diggCount", 0) + s.get("commentCount", 0) + s.get("shareCount", 0)
plays += s["playCount"]
sampled += 1
if not sampled:
return {"handle": username, "followers": followers, "sampled": 0}
return {
"handle": user.get("uniqueId"),
"followers": followers,
"sampled": sampled,
"er_by_view": round(engagements / plays * 100, 2),
"er_by_post": round(engagements / (followers * sampled) * 100, 2),
"lifetime_like_rate": round(
(profile["stats"]["heartCount"] / max(profile["stats"]["videoCount"], 1))
/ followers * 100, 2),
}
if __name__ == "__main__":
print(engagement_rate("tiktok"))
The Python version adds the time filter the Node one skips: only posts newer than days count, using the item createTime. An account that posted twice this year and 200 times three years ago will otherwise be scored on ancient uploads, and its rate will bear no relation to what a campaign starting tomorrow would get.
Sampling, cost and honest limits
One page is enough for a current rate. If you want the whole catalogue - to chart how the rate moved over two years, or to find which posts beat the account's own baseline - loop on data.cursor until data.hasMore turns false, one credit per page. The paging loop with its guards is written out in the walkthrough on getting all videos from a TikTok user via API.
At one credit per call the budgeting is simple. Screening 1,000 handles with the profile call alone is 1,000 credits. A proper two-call check on all of them is 2,000, well inside the Basic tier at $9.90 for 2,500 credits, while a daily rate for 500 accounts runs 1,000 credits a day and needs the Pro tier at $59.90 for 50,000. The published pricing tiers and the parameter reference in the documentation cover the rest, and the API playground will run both calls against your own key in the browser if you want to see a real stats block before writing any code. The default rate limit is 100 requests per minute, so a 500-account sweep takes at least ten minutes of wall clock regardless of your balance - queue it rather than firing it in a burst.
Four limits are worth stating plainly. Private accounts return no usable feed, so check user.privateAccount and user.secret before you queue the second call. Deleted and region-blocked posts never appear in itemList, which means stats.videoCount from the profile and the number of items you actually collect will not match - use the profile figure as a sanity check, not as a loop condition. Counters are live rather than cached, so a rate computed at noon and again at midnight for a fresh post will differ, and the right fix is to exclude posts younger than about 48 hours, since views accumulate faster than likes in the first hours and depress the rate artificially. And a rate says nothing about sentiment: 40,000 comments on a post can be enthusiasm or a pile-on, and separating them means reading the text, which is what the guide to TikTok comment sentiment analysis with an API is for. Where a single video needs a fresher or fuller counter set than the feed carries, post-detail returns the same stats and statsV2 pair for one post ID.
PrimeApi is an independent service and is not affiliated with or endorsed by TikTok or ByteDance. The counters it returns are the public ones any visitor can see on a profile - there is no impressions or watch-time data behind them. Build on the fields that exist, label the formula you used, and the number will survive scrutiny.
Frequently asked questions
Which TikTok engagement rate formula should I use?
Engagement rate by view, which is (diggCount + commentCount + shareCount) / playCount. It compares a video against the audience that actually saw it, so it stays stable whether a post reached 40,000 people or 4 million. Engagement rate by follower is the number most agency templates still ask for, so calculate both and report the follower version only with the view version next to it.
Which endpoints do I need to calculate engagement rate?
Two. userinfo-by-username turns a handle into the profile record, giving you stats.followerCount for the denominator and user.secUid for the next call. user-posts then returns the upload feed, and each item carries the per-video stats block with playCount, diggCount, commentCount, shareCount and collectCount.
Can I get a TikTok view count from the profile endpoint?
No. The stats object on userinfo-by-username holds followerCount, followingCount, heartCount, videoCount, diggCount and heart. There is no account-level view total anywhere in that response, which is why any by-view calculation has to read playCount per video from the post feed instead.
How many posts should the average cover?
Twenty to thirty-five recent uploads is the usual window, and one user-posts page with count=35 covers it for a single credit. Filter on the item createTime field, an epoch timestamp in seconds, so the sample is a real time window rather than a fixed post count - an account posting daily and an account posting monthly should not be compared over the same 30 items.
Why does the response contain both stats and statsV2?
They hold the same counters twice. stats gives you playCount, diggCount, commentCount, shareCount and collectCount as numbers, while statsV2 repeats those five, adds repostCount, and returns its values as strings. Read stats for arithmetic and dip into statsV2 only when you specifically want the repost counter, casting it first.
How many credits does an engagement rate check cost?
One credit per request. A full check on a single account is one call to userinfo-by-username plus one page of user-posts, so two credits buys you the follower total and up to 35 posts of counters. New accounts start with 50 free credits, and the remaining balance comes back in the X-PrimeAPI-Balance response header on every call.