TikTok Video Stats: Views, Likes, Comments, Shares by URL
Pricing
Pay per usage
TikTok Video Stats: Views, Likes, Comments, Shares by URL
Fresh views, likes, comments, shares and saves for a list of TikTok videos, read from the public video page. No login, no browser, no proxy. Deleted and private videos get their own status. Built for repeat measurements.
Pricing
Pay per usage
Rating
0.0
(0)
Developer
Sergey Faraday
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
0
Monthly active users
6 hours ago
Last modified
Categories
Share
Measures known TikTok videos again and again: views, likes, comments, shares and saves, each time with the moment of measurement. Give it a list of video URLs and compare snapshots to see how each video grows.
It reads the public video page with plain HTTP requests. No login, no cookies, no browser and no proxy, so it costs a fraction of a browser-based scraper.
Not collected here: search results, hashtag and sound video lists. TikTok does not serve them openly. You can get them from the companion Unified Scraper Adapter actor (modules tiktok_search, tiktok_sound).
How to use
- Paste video URLs or ids into
videoUrls. For a panel measured over time use numeric ids: resolve short links first, because a dead short link has no numeric id to join with earlier rows (sampling.requestedAsis the only link).requestedAsholds the form you asked in without its query string and without the author handle (https://www.tiktok.com/@_/video/<id>).sampling.requestedAs,data.requestedAs,applied.controlVideo.requestedAsand invalid inputs insources[].excludedkeep origin and path only: the query string and fragment of a share link are never stored, and a handle in the path of your URL is replaced by@_. - Give each run a
snapshotId(for example the date) and run it on a schedule. Always pass it: without it each run gets<actor>.<runId>, so a retry becomes a new snapshot. - Compare snapshots by
sourceRecordId, as in the next section. The actor does not compare by itself: one run cannot read the rows of another.
Comparing two snapshots
| Case | How to tell |
|---|---|
| new | no earlier row for this sourceRecordId |
| unchanged | same provenance.contentChecksum. It covers saves, which are not rounded, so it also catches growth the rounded counters hide |
| changed | different provenance.contentChecksum |
| gone | a deleted, not_public or not_found row where the earlier snapshot had a post. blocked, fetch_error and not_attempted rows say nothing about the video |
| below resolution | a difference smaller than precision.step: this is not zero growth, and a rounded counter can stay the same while the video grows |
State rows take canonicalUrl from the id (https://www.tiktok.com/@_/video/<id>), so the form of the input does not read as a change. A short link stays its own key: before the read it cannot be tied to a video, so a short link against a full URL is another task. A change of the asked proxy country reads as changed through source.marketReason, not in the metrics; data.requestRegion names the country and stays out of the checksum.
Do not assume counters only grow: comments fell between readings in stored data, sometimes by more than the rounding step.
Input
| Field | Default | Meaning |
|---|---|---|
videoUrls | video URLs, short links (vm.tiktok.com) or numeric video ids. 0, any short number and a short link host with no code in the path are invalid inputs: no request, listed in sources[].excluded with reason not_a_tiktok_video_reference, and the run status does not move | |
delayMs | 1500 | pause between two videos |
stopAfterBlocks | 5 | stop after this many blocked videos in a row |
controlVideoUrl | a video you know is live; see "Is not_found reliable?" below | |
snapshotId | <actor>.<runId> | your id for this measurement. One object has one row per snapshot: a measurement replaces a state row of the same video (their recordKeys differ by recordType, so join on sourceRecordId), a later run does not change a finished snapshot, and a value that differs is kept apart. A new id gives a new observation. Compare snapshots by provenance.contentChecksum |
maxItems, maxRunSeconds | limits. maxItems is a hard cap on rows: the ids past it get no row, they are listed in the MANIFEST under sources[].applied.notAttemptedIds and sources[].excluded with reason not_attempted:maxItems (excluded holds the first 200 ids, plus a count). A time cut comes back as not_attempted rows while maxItems allows. The maxRunSeconds budget counts from the run start and a restart does not renew it, the time between workers counts. A resurrected run past its budget stops at once and finishes partial; a new run under the same snapshotId within 24 hours completes the snapshot |
{ "videoUrls": ["https://www.tiktok.com/@tiktok/video/7106594312292453675", "7534061113365859586"], "snapshotId": "2026-11-02" }
Output
One post row per live video:
{"recordType": "post","platform": "tiktok","sourceRecordId": "7534061113365859586","canonicalUrl": "https://www.tiktok.com/@user/video/7534061113365859586","time": { "publishedAt": "2025-08-02T18:45:03.000Z", "observedAt": "2026-11-02T14:34:30.000Z", "observedAtBasis": "response_received" },"metrics": {"views": { "value": 87400, "sourceName": "statsV2.playCount", "unit": "count", "kind": "counter", "history": "live_snapshot", "precision": { "class": "platform_rounded", "step": 100, "rule": "tiktok.page.display.2026-11-02" } },"likes": { "value": 6383, "sourceName": "statsV2.diggCount", "unit": "count", "kind": "counter", "history": "live_snapshot", "precision": { "class": "as_served", "step": 1, "rule": "tiktok.page.display.2026-11-02" } },"comments": { "value": 33, "sourceName": "statsV2.commentCount" },"shares": { "value": 787, "sourceName": "statsV2.shareCount" },"saves": { "value": 381, "sourceName": "statsV2.collectCount" }},"data": { "text": "...", "hashtags": ["..."], "soundId": "...", "videoLocationCreated": "PT", "isAd": false, "authorPseudoId": "tiktok:a_...", "durationSec": 15, "pageStatusCode": 0, "pageServedRegion": "AE", "isPhotoPost": false, "privateItem": false, "secret": false }}
A video that cannot be measured comes back as an object_state row with its own status and, when the page answered, the page's own code and message:
{"recordType": "object_state","platform": "tiktok","sourceRecordId": "7042692929109986561","status": "deleted","statusReason": "TikTok page status 10204 (status_deleted)","data": { "requestedAs": "7042692929109986561", "pageStatusCode": 10204, "pageStatusMsg": "status_deleted", "pageServedRegion": "AE" },"sampling": { "requestedAs": "7042692929109986561" }}
status | Meaning |
|---|---|
deleted | TikTok's page names the video deleted |
not_public | the video exists or existed, but the page does not show it to the public |
not_found | TikTok says there is no such video. This does not prove it ever existed; an id you saw live earlier reads as removed since then |
region_blocked | closed for the region of the collector address |
unavailable | the page answered something we cannot read: an unknown code or message, or another video than the one requested (data.errorCode is ID_MISMATCH) |
blocked | TikTok answered with a block or verification page: the video may well be alive. This also covers a 200 page without the data script or detail block that says captcha, verify you are human, access denied, unusual traffic or security check |
fetch_error | our read failed: HTTP error, network, changed page layout. data.errorCode says which. This says nothing about the video. HTTP 503 without Retry-After counts here. With no response at all, observedAtBasis is request_failed: observedAt is when the read gave up |
not_attempted | the run stopped before this video; observedAt is null |
Where the page codes get their meaning
TikTok documents none of these codes. Seen means we read that code and message on live pages on 2026-11-02. status_deleted and status_self_see were read from one address outside the Apify platform, so it is not established that a run on Apify gets the same messages. Community means a label from third-party open source projects: we never saw that code, and those projects partly disagree.
| Code and message | status | Where the meaning comes from |
|---|---|---|
10204 status_deleted | deleted | seen; the meaning rests on the message itself |
10204 status_self_see | not_public | seen; the message itself and one third-party patch |
10204 item doesn't exist | not_found | seen, also on ids that never existed |
| 10204, any other message | unavailable | counted in counters.unclassified; the source stays partial |
| 10216, 10217, 10221, 10222 | not_public | community, never seen by us |
| 10219, 10231 | region_blocked | community, never seen by us |
| 10000, 10113, 10114 | blocked | community, never seen by us |
| any other code | unavailable | counted in counters.unclassified; the source stays partial |
deleted, not_public, not_found and region_blocked are valid answers, so a run with only these and live videos is ok. A new code never reads as a valid answer until someone has looked at it.
The MANIFEST record in the key-value store reports the run status (ok, partial, blocked, failed), counts of measured, gone, region-blocked, blocked, failed, unclassified and not attempted videos, invalid inputs, cut videos and errors.
How to read the data
observedAtis read after the page response arrived,collectedAtwhen this actor built the row, so "what did we know at time T" usescollectedAt.runStartedAtis the platform start, the same on every row of a run. On a measured rowrunStartedAt <= observedAt <= collectedAt. The page states no date of its own for its counters, sosourceUpdatedAtis null andsourceUpdatedAtBasissays why.- Counters are the rounded numbers TikTok's page shows at
observedAt: below 10,000 as they are, from 10,000 in steps of 100, from 1,000,000 in steps of 100,000, from 1,000,000,000 in steps of 100,000,000. The step is what was measured, not the direction of the rounding. Stored data holds views and likes at the 100,000 step; shares at that step and views at the 100,000,000 step rest on one live sample each; comments have no sample from 1,000,000. Saves are not rounded. The rule was measured on 2026-11-02 and is not documented by TikTok, so it can change. - Each metric carries
precisionwithclass,stepandrule.platform_rounded: the true count is within about one step of the value.as_served: the page shows this number and no rounding is known.rule_mismatch: the value does not fit the rule,stepis null, the value is untouched and theMANIFESTnotes how many.exactis never written, and there are no low and high bounds, because the rounding direction is not established. authorFollowersfollows the same rule,authorFollowersStepgives its step.- Values come from
statsV2first and fromstatsper field only ifstatsV2has no usable value: above 2^31 viewsstats.playCountwraps to a negative number. A positive value instatsbeats a zero instatsV2. A counter in neither hassourceNameabsent:statsV2.x|stats.x. pageServedRegionis what TikTok reports for the address that read the page: evidence for region effects, not a market and not an audience.requestRegionis the country asked of the Apify proxy (proxyConfiguration.apifyProxyCountry), on post and state rows, withrequestRegionMethod; without one it is null withnot_set. It says what was asked,pageServedRegionwhat TikTok reports; neither is a market or an audience.marketis null because counters are global, andsource.marketReasonsays so.- Author names are not stored as fields:
authorPseudoIdis a one-way hash. An original sound is titled by its author, so its title is not stored:data.soundTitleis null,data.soundIsOriginalis true anddata.soundIdstays. The handle appears in the video link, and the post text can hold whatever name its author wrote there. So the hash does not hide who posted. data.hashtagsholds one tag per video, in the spelling that came first: the challenge list first, then the text. 12 of 92 stored rows held one tag in two cases; those stored rows are not rewritten.- A value the page does not provide is null with a reason, never 0. Zero views on a live video are treated as not served. A video with 0 on every counter stays a post as served, views null and the others 0; nothing shows that block was served, so if views is unreadable or 0 on every post, or on 10% of 10 or more, the source is
partialas described under a changed page. - An empty counter block, or one whose only readable value is a zero of views, is
fetch_errorwithSCHEMA_CHANGED, not a post full of nulls. - A changed page. Every real page read so far sent playCount, diggCount, commentCount, shareCount, collectCount and createTime. If one of them has no readable value (absent, null, empty, not a number, or 0 for views) on every post of a run, from one post on, or on 10% or more of the posts when there are 10 or more, the source is
partialwithSCHEMA_CHANGEDnaming the field inerrors[].field, withaffectedandof.counters.unreadableFieldsholds the count per field. The rows stay as they are: null with a reason, andunparseablewith the raw value when the value is there but cannot be read. A real 0 of likes, comments, shares or saves is an answer and raises nothing.
FAQ
Are the numbers exact? No. They are what TikTok's own page shows, and views, likes, comments and shares are rounded as described above. Two readers of this page agree with each other because both read the same rounded numbers, so agreement between them does not show exactness. This page has no exact counts to give.
Is not_found reliable? It is what the page answered. We have seen no live video answer not_found, but other readers report 10204 also for an address that TikTok treats differently, so this is not excluded. Pass controlVideoUrl with a video you know is live: when some video answers not_found, the control is read as well, without a row of its own. If it does not read as live, the source is partial with the error NOT_FOUND_UNCONFIRMED, and sources[].applied.controlVideo in the MANIFEST shows what it answered.
What about photo posts? data.isPhotoPost is set when the page has a photo block, but no photo post has been checked on a live page yet.
What about search and hashtags? Those lists are not served openly. Use modules tiktok_search and tiktok_explore of the Unified Scraper Adapter.
Limits
- If TikTok starts blocking the page, the run stops and says so. No bypass is attempted. A real block, a private account and a region block have not been seen on this actor yet, so those paths rest on the community labels above.
- A migration or restart of the run is not a cut: the next worker goes on with the videos not yet read. A video whose row reached the dataset is counted from that row: it is not read again and gets no second row. A cut takes all the remaining videos at once, so it costs one push and a few writes however long the panel is. An aborted run names the videos left in
sources[].excludedwith reasonnot_attempted:aborting; the abort handler gives the collector up to 20 s to name them, and past the 200 listed insources[].excludedthe rest are insources[].applied.notAttemptedIds. A full URL of a video that already came under a short link is not read again;measuredis the number of videos with a post row. - The counters are the value at the moment of measurement. They cannot be spread over past dates.
- Check that automated collection fits TikTok's terms and your use case.
Cost
About $0.03-0.10 of platform usage per 1,000 videos, depending on your Apify plan, at 256 MB of memory and the default delay. 1,000 videos take about 40 minutes.