Guide · Python

Track TikTok video views for a list of URLs with Python

Put a campaign's video links in a CSV. Each run stores views, likes, comments, shares and saves in SQLite, prints what changed and flags the videos that disappeared.

ScrapingBot 9 min read
Endpoint
/ (video details)
Cost
1 credit per video
Language
Python 3.9+
Needs
requests
On this page

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 url column.

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
}
FieldWhat it is
idThe video ID, the number in the link. Key your records on it.
play_countViews.
digg_count, comment_count, share_countLikes, comments and shares.
collect_countSaves: how many people added the video to their favorites.
create_timeWhen it was posted, as a Unix timestamp (UTC).
durationLength in seconds; 0 for a photo post, which also carries an images list.
authorThe creator: unique_id (the handle), nickname, id.
music_infoThe 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 <100 or <100,000 rather than +0 when a rounded count hasn't moved, so a flat line isn't mistaken for no views.

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 (@nasa on one of TikTok's own videos) returned TikTok's video, and tracking parameters such as ?is_from_webapp=1 are ignored.
  • The handle has to be there. tiktok.com/video/<id> without one failed with a 500 (refunded), so the script always sends tiktok.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 CONCURRENCY to 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. videos holds one row per video (creator, posting time, caption, gone_since); snapshots one 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, a 400 for 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

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.

Start scraping in the next five minutes.

100 free credits, no credit card. One API key works for websites, TikTok, Instagram, Google, Amazon and ChatGPT.