When you pay creators to post, the report you owe is easy to describe: how many views each video got, day by day, and whether any of them came down. TikTok shows the numbers on every video's page. Opening forty pages each morning and copying them into a sheet is the part worth automating.
This guide reads a list of video links, looks each one up with ScrapingBot's video endpoint, stores a snapshot per video and run in SQLite, and prints what changed since the last run. Every response and output below is real, collected through ScrapingBot on November 3, 2026, from eight public videos by NASA, TikTok's own account and Khaby Lame.
Before you start
- A ScrapingBot API key. Create a free account for 100 credits: 100 video lookups, a 20-video campaign for five days. No card needed.
- Python 3.9 or newer and
pip install requests. SQLite comes with Python. - The links of the videos to track, one per row in a CSV with a
urlcolumn.
TikTok's own API needs every creator's login
TikTok's video query endpoint returns view_count, like_count, comment_count and share_count, but only for videos of a creator who has logged in to your app and granted it the video.list scope, and its video object has no saves count (checked November 2026). When every creator in a campaign connects their account, that route is free and the better choice. When they don't, or the videos aren't your campaign's at all, the public numbers are what you have. The Research API is limited to approved non-commercial researchers; our comparison covers it.
One video
Every TikTok call is a POST to https://scrapingbot.io/api/v1/tiktok. Video details is the endpoint named /, and it takes the video's link:
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/@nasa/video/7692161454694206733"}}'
NASA's October 2 recap, a month after it went up, trimmed to the fields a tracker needs:
{
"success": true,
"data": {
"id": "7692161454694206733",
"title": "It was a big week in low Earth orbit at NASA! 🧑🚀 Crew-13 reaches the IS…",
"create_time": 1790970924,
"duration": 136,
"region": "US",
"play_count": 353400,
"digg_count": 14500,
"comment_count": 404,
"share_count": 463,
"collect_count": 1156,
"author": { "id": "7664638705177150477", "unique_id": "nasa", "nickname": "NASA", … },
"music_info": { "id": "7692161536332253965", "title": "original sound - NASA", … },
"share_url": "https://www.tiktok.com/@nasa/video/7692161454694206733",
"cover": "https://p16-common-sign.tiktokcdn-us.com/tos-useast5-p-0068-tx/…",
"play": "https://www.tiktok.com/aweme/v1/play/?…",
…
},
"processed_time": 1.859,
"duration": "1.93",
"statusCode": 200,
"creditsUsed": 1
}
| Field | What it is |
|---|---|
id | The video ID, the number in the link. Key your records on it. |
play_count | Views. |
digg_count, comment_count, share_count | Likes, comments and shares. |
collect_count | Saves: how many people added the video to their favorites. |
create_time | When it was posted, as a Unix timestamp (UTC). |
duration | Length in seconds; 0 for a photo post, which also carries an images list. |
author | The creator: unique_id (the handle), nickname, id. |
music_info | The sound: id and title. Track every video using it with the sound guide. |
The response also has the caption (title), cover images and playable media links, which expire. One lookup is 1 credit; ours took 0.6 to 2.2 seconds.
The counts are rounded the way TikTok shows them
TikTok shows "352.6K views", and that is the number you get: 352,600. Across the 48 videos we looked at (the eight below and forty more from four accounts' video lists), every view, like, comment and share count of 10,000 or more was a multiple of 100, and every count over a million a multiple of 100,000. Smaller counts come unrounded (8,419 comments; 4,937 likes). Saves came back exact from the video endpoint (17,695 and 301,479), though the creators' video lists rounded them too.
- Small changes don't show. TikTok's October 31 video read 346,000 views and, seconds later, 346,100. NASA's recap went from 352,600 at 05:40 UTC to 353,400 at 16:47 UTC. A video with 5.7 million views can gain 99,999 and read the same.
- Compare days, not minutes. The script prints
<100or<100,000rather than+0when a rounded count hasn't moved, so a flat line isn't mistaken for no views.
Links in, video IDs out
The endpoint is particular about the link, and forgiving about the rest of it:
- The ID decides. A link with the wrong handle in it (
@nasaon one of TikTok's own videos) returned TikTok's video, and tracking parameters such as?is_from_webapp=1are ignored. - The handle has to be there.
tiktok.com/video/<id>without one failed with a500(refunded), so the script always sendstiktok.com/@handle/video/<id>. - Share links and photo links are refused, free, with a
400:
{
"success": false,
"error": "Invalid video URL. URL must include /video/ (e.g., https://www.tiktok.com/@user/video/1234567890). For user profiles, use the \"User Profile Info\" endpoint with just the username."
}
The app's Copy link gives vm.tiktok.com/… or tiktok.com/t/…. TikTok answers those with a 301 redirect to the full address, so the script makes one HEAD request to read it. Photo posts (/photo/ links) work once the link says /video/: NASA's October 28 rocket-stacking post came back with duration 0, its images, and 129,100 views.
When a video disappears
A video that was deleted, made private or put under review comes back as a 404, at no charge. So does an ID that never existed; we changed the last digit of a real one:
{
"success": false,
"error": "TikTok video was not found.",
"code": 404
}
The answer doesn't say which it was. The script records the run when a video first went missing, prints its last snapshot, and keeps asking on later runs: if the video returns, tracking picks up where it stopped.
The script
Save this as tiktok_tracker.py. It reads campaign.csv (or the file you name), turns every link into the form the endpoint accepts, looks the videos up in parallel up to CONCURRENCY, and stores one snapshot per video in tiktok_campaign.db.
import csv
import re
import sqlite3
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
import requests
API = "https://scrapingbot.io/api/v1/tiktok"
HEADERS = {"x-api-key": "YOUR_API_KEY"}
CONCURRENCY = 1 # your plan's limit: Free 1, Starter 10, Startup 50
COUNTS = {"views": "play_count", "likes": "digg_count", "comments": "comment_count",
"shares": "share_count", "saves": "collect_count"}
class Gone(Exception):
"""TikTok no longer shows the video: deleted, made private or under review."""
def call(url, tries=4):
"""Video details: 1 credit on success. Failed calls, a 404 included, are refunded."""
error = None
for attempt in range(tries):
try:
res = requests.post(API, headers=HEADERS, timeout=60,
json={"endpoint": "/", "params": {"url": url}})
body = res.json()
except (requests.RequestException, ValueError) as err: # no answer, or not JSON
error = err
else:
if body.get("success"):
return body["data"]
if res.status_code == 404:
raise Gone(body.get("error"))
if res.status_code in (400, 401, 402): # retrying will not help
raise RuntimeError(f"{res.status_code} {body.get('error')}")
error = body.get("error")
time.sleep(2 ** attempt) # 408, 429, 5xx or a network error: wait and try again
raise RuntimeError(f"kept failing: {error}")
def video_url(link):
"""(video ID, URL the API accepts) for a video link, a photo link or a share link."""
link = link.strip()
if re.search(r"//(vm|vt)\.tiktok\.com/|tiktok\.com/t/", link): # the app's Copy link
link = requests.head(link, allow_redirects=False, timeout=15).headers.get("Location", link)
m = re.search(r"tiktok\.com/@([\w.-]+)/(?:video|photo)/(\d+)", link)
if not m:
raise ValueError("not a link to one TikTok video")
return m.group(2), f"https://www.tiktok.com/@{m.group(1)}/video/{m.group(2)}"
def fetch(item):
video_id, url = item
try:
return video_id, url, call(url), None
except Gone as err:
return video_id, url, None, f"gone: {err}"
except RuntimeError as err:
return video_id, url, None, str(err)
def step(n):
"""Counts from 10,000 come rounded the way TikTok shows them: 352.6K, 5.7M."""
return 100_000 if n >= 1_000_000 else 100 if n >= 10_000 else 1
if __name__ == "__main__":
with open(sys.argv[1] if len(sys.argv) > 1 else "campaign.csv", newline="", encoding="utf-8-sig") as f:
links = [r["url"] for r in csv.DictReader(f) if r.get("url", "").strip()]
videos = {}
for link in links:
try:
video_id, url = video_url(link)
videos.setdefault(video_id, url) # the same video twice is one call
except (ValueError, requests.RequestException) as err:
print(f"skipped {link}: {err}")
db = sqlite3.connect("tiktok_campaign.db")
db.executescript("""
create table if not exists videos (id text primary key, url text, creator text,
posted text, title text, gone_since text);
create table if not exists snapshots (id text, taken text, views int, likes int,
comments int, shares int, saves int);""")
now = datetime.now(timezone.utc)
taken = now.strftime("%Y-%m-%d %H:%M:%S") # one snapshot per video and run, never replaced
with ThreadPoolExecutor(max_workers=CONCURRENCY) as pool:
results = list(pool.map(fetch, videos.items()))
print(f"{taken} UTC: {len(results)} videos, {sum(r[2] is not None for r in results)} credits")
print(f"{'video':<34}{'posted':>8}{'views':>13}{'change':>16}{'likes':>11}{'saves':>9}{'eng.':>7}")
totals = [0, None]
for video_id, url, data, problem in results:
last = db.execute("select taken, views from snapshots where id = ? order by taken desc limit 1",
(video_id,)).fetchone()
if data is None:
if problem.startswith("gone"):
db.execute("insert or ignore into videos (id, url) values (?, ?)", (video_id, url))
db.execute("update videos set gone_since = coalesce(gone_since, ?) where id = ?",
(taken, video_id))
seen = f", last seen {last[0]} with {last[1]:,} views" if last else ""
print(f"{video_id:<34}{problem}{seen}")
continue
c = {k: data.get(field) or 0 for k, field in COUNTS.items()}
name = f"@{data['author']['unique_id']}/{video_id}"
posted = datetime.fromtimestamp(data["create_time"], timezone.utc)
db.execute("insert or replace into videos values (?, ?, ?, ?, ?, null)",
(video_id, url, data["author"]["unique_id"], posted.strftime("%Y-%m-%d %H:%M"),
(data.get("title") or "")[:200]))
db.execute("insert into snapshots values (?, ?, ?, ?, ?, ?, ?)",
(video_id, taken, *c.values()))
change = "new"
if last:
mins = (now - datetime.strptime(last[0], "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
).total_seconds() / 60
grew = c["views"] - last[1]
shown = f"<{step(c['views']):,}" if grew == 0 and step(c["views"]) > 1 else f"{grew:+,}"
change = shown + (f" in {mins / 60:.0f}h" if mins >= 60 else f" in {mins:.0f}m" if mins >= 1 else "")
eng = (c["likes"] + c["comments"] + c["shares"]) / c["views"] * 100 if c["views"] else 0
totals[0] += c["views"]
if last:
totals[1] = (totals[1] or 0) + c["views"] - last[1]
day = f"{posted:%b} {posted.day}"
print(f"{name:<34}{day:>8}{c['views']:>13,}{change:>16}{c['likes']:>11,}"
f"{c['saves']:>9,}{eng:>6.1f}%")
db.commit()
grew = "" if totals[1] is None else f"{totals[1]:+,}"
print(f"{'total':<34}{'':>8}{totals[0]:>13,}{grew:>16}")
- Set
CONCURRENCYto your plan. Lookups don't depend on each other, so they run side by side up to your plan's concurrency limit: 1 on the free plan, 10 on Starter. - Two tables.
videosholds one row per video (creator, posting time, caption,gone_since);snapshotsone row per video and run with the five counts, stamped to the second. Nothing is overwritten, so every earlier run stays queryable. - Engagement is likes, comments and shares over views, as in the profile stats guide. Saves are stored and shown apart.
- A failure skips one video, not the run. Timeouts (
408), concurrency limits (429), server errors and dropped connections are retried with a growing pause; a bad link is skipped with a message; failed calls are refunded.
Run it
The campaign file, as people paste links: one with tracking parameters, one photo link, and one video listed twice.
url
https://www.tiktok.com/@nasa/video/7702504724049743117
https://www.tiktok.com/@nasa/photo/7701866987609197837
https://www.tiktok.com/@nasa/video/7692161454694206733
https://www.tiktok.com/@tiktok/video/7702947070604332319?is_from_webapp=1&sender_device=pc
https://www.tiktok.com/@tiktok/video/7700680369246358815
https://www.tiktok.com/@tiktok/video/7694797676306615583
https://www.tiktok.com/@khaby.lame/video/7697307919943060767
https://www.tiktok.com/@khaby.lame/video/7678009073421405471
https://www.tiktok.com/@nasa/video/7692161454694206733
python tiktok_tracker.py campaign.csv
2026-11-03 16:47:46 UTC: 8 videos, 8 credits
video posted views change likes saves eng.
@nasa/7702504724049743117 Oct 30 126,200 new 12,700 1,538 10.7%
@nasa/7701866987609197837 Oct 28 129,100 new 13,200 1,370 11.0%
@nasa/7692161454694206733 Oct 2 353,400 new 14,500 1,156 4.3%
@tiktok/7702947070604332319 Oct 31 346,000 new 4,937 443 2.0%
@tiktok/7700680369246358815 Oct 25 774,900 new 9,178 866 1.8%
@tiktok/7694797676306615583 Oct 9 1,500,000 new 19,400 1,791 1.8%
@khaby.lame/7697307919943060767 Oct 16 5,700,000 new 334,100 17,695 6.6%
@khaby.lame/7678009073421405471 Aug 25 125,800,000 new 8,400,000 301,479 6.9%
total 134,729,600
The duplicate cost nothing extra: nine rows, eight lookups, 8 credits. Twenty-two minutes later, the same command again:
2026-11-03 17:09:18 UTC: 8 videos, 8 credits
video posted views change likes saves eng.
@nasa/7702504724049743117 Oct 30 126,600 +400 in 22m 12,700 1,539 10.6%
@nasa/7701866987609197837 Oct 28 129,200 +100 in 22m 13,300 1,370 11.1%
@nasa/7692161454694206733 Oct 2 353,400 <100 in 22m 14,500 1,156 4.3%
@tiktok/7702947070604332319 Oct 31 348,000 +2,000 in 22m 4,961 444 2.0%
@tiktok/7700680369246358815 Oct 25 775,200 +300 in 22m 9,190 866 1.8%
@tiktok/7694797676306615583 Oct 9 1,500,000 <100,000 in 22m 19,400 1,791 1.8%
@khaby.lame/7697307919943060767 Oct 16 5,700,000 <100,000 in 22m 334,200 17,697 6.6%
@khaby.lame/7678009073421405471 Aug 25 125,800,000 <100,000 in 22m 8,400,000 301,493 6.9%
total 134,732,400 +2,800
TikTok's October 31 video gained 2,000 views in those 22 minutes and NASA's newest 400, while the month-old recap and every count over a million stayed inside their rounding step. Saves come exact, so they still moved: Khaby Lame's August video gained 14 saves (301,479 to 301,493) while its views read 125,800,000 both times. Run it daily and the steps stop mattering.
Every morning, and a report for the client
Schedule it once a day with cron (or Task Scheduler on Windows); each run adds a row per video:
0 9 * * * cd ~/campaign && python3 tiktok_tracker.py campaign.csv >> tracker.log 2>&1
The history comes out of SQLite as a CSV with one query, with the sqlite3 command that ships with macOS and most Linux systems:
sqlite3 -header -csv tiktok_campaign.db \
"select v.creator, s.id, s.taken, s.views, s.likes, s.comments, s.shares, s.saves
from snapshots s join videos v on v.id = s.id order by v.creator, s.id, s.taken" > report.csv
creator,id,taken,views,likes,comments,shares,saves
khaby.lame,7678009073421405471,"2026-11-03 16:47:46",125800000,8400000,68200,220000,301479
khaby.lame,7678009073421405471,"2026-11-03 17:09:18",125800000,8400000,68200,220000,301493
khaby.lame,7697307919943060767,"2026-11-03 16:47:46",5700000,334100,8419,33600,17695
khaby.lame,7697307919943060767,"2026-11-03 17:09:18",5700000,334200,8420,33600,17697
…
Cost and speed
- 1 credit per video per run. 50 videos every day for 30 days is 1,500 credits, about 27 cents on Starter ($49.99 for 275,000 credits). The 100 free credits cover 100 lookups.
- Missing videos and failed calls are free. A
404, a400for a bad link and a timeout cost nothing. - Fast enough to run often, though daily is what the rounding rewards: a lookup took 0.6 to 2.2 seconds here, so 50 videos at Starter's 10 at a time should take well under ten seconds.
Where to go from here
- Track a sound instead of a list of videos: every video that uses a sound, or a daily sound tracker in Node.js.
- Vetting the creators before the campaign: follower and like counts with Python. What viewers said: export a video's comments to CSV.
- No code: the free TikTok engagement rate calculator, the sound tracker, and for Instagram the profile stats tool.
- Comparing providers? The best TikTok data APIs in 2026 covers cost and coverage. The video details reference lists every parameter, and the TikTok API page every endpoint.
Common questions
Is there an API for TikTok video views?
TikTok's own video query endpoint returns view_count, but only for the videos of creators who log in and authorize your app (checked November 2026). For any public video, POST {"endpoint": "/", "params": {"url": "<video link>"}} to https://scrapingbot.io/api/v1/tiktok: the answer has play_count (views), digg_count (likes), comment_count, share_count and collect_count (saves), for 1 credit.
Why do the view counts not change between two runs?
Counts of 10,000 and more come rounded the way TikTok shows them: to the nearest 100 (352.6K) and, from a million, to the nearest 100,000 (5.7M). A video can gain up to 99 views, or 99,999 above a million, and show the same number. Compare snapshots a day apart, not minutes apart.
What happens when a video is deleted or made private?
The lookup returns 404 with "TikTok video was not found." and costs nothing. A deleted video, a private one, one under review and an ID that never existed all get the same answer, so the script records when the video went missing and keeps checking it on later runs.
Can I track the short links from the TikTok app?
Not directly: the endpoint answers 400 for links without /video/ in them, such as vm.tiktok.com/… or tiktok.com/t/…. Those links redirect to the full address, so the script makes one HEAD request to read it first. Photo links (/photo/) work once rewritten to /video/.
How much does tracking 50 videos every day cost?
One credit per video per run: 1,500 credits a month, about 27 cents on the Starter plan ($49.99 for 275,000 credits). The 100 free credits cover 100 lookups. Videos that come back 404 are free.