How to Monitor Competitor TikTok Accounts at Scale

Published on May 29, 2026

The Case for Competitor Monitoring on TikTok

If you run marketing ops for a brand or agency, you already know that TikTok is the channel where positioning shifts fastest. A competitor can drop a viral concept on Tuesday and own the conversation by Friday. Manually checking 10 profiles a week is workable. Tracking 50 profiles, attributing surges, and building a defensible benchmark is not.

This guide shows how to build a competitor monitoring pipeline using TikLiveAPI. We will resolve usernames, snapshot profiles, pull recent posts, compute engagement rate, detect surge posts, build daily diffs, and scale the whole thing to 50 accounts on a cron schedule. The end state is a system that emits Slack alerts when something interesting happens and a weekly digest your strategy team can act on.

The reasoning for building this in-house rather than buying a tool is simple. Off-the-shelf social listening tools price per seat and rarely expose the raw post-level fields you need for gap analysis. With 37 documented endpoints and a pay-as-you-go credit model, you only pay for the requests you make, and credits do not expire. See pricing for current rates.

Step 1: Resolve a Username to a User ID

Almost every TikTok endpoint that returns post-level or follower-level data wants a numeric userid, not a handle. Your competitor list will arrive as @handles, so the first step in any pipeline is converting them to IDs. The /userid/ endpoint takes a username and returns a minimal response containing a single key.

import requests

API_BASE = "https://api.tikliveapi.com"
API_KEY = "YOUR_API_KEY"
HEADERS = {"X-Api-Key": API_KEY}

def resolve_userid(username: str) -> str:
    r = requests.get(
        f"{API_BASE}/userid/",
        params={"username": username},
        headers=HEADERS,
        timeout=15,
    )
    r.raise_for_status()
    return r.json()["id"]

uid = resolve_userid("nike")
print(uid)  # "6731274686050321413"

Cache the result. Usernames change rarely, so a username-to-id map stored locally avoids burning credits on every run. Reserve a fresh lookup for accounts you have not seen before or when an existing call returns a not-found error.

Step 2: Snapshot the Profile Daily

Once you have the ID, pull a profile snapshot once per day per competitor. The /userinfo-by-username/ endpoint returns two top-level objects: user and stats. Be careful about field casing. Inside user most fields are camelCase (uniqueId, avatarThumb, secUid) but a few are snake_case (ins_id, twitter_id, youtube_channel_id). The stats object is uniformly camelCase: followerCount, followingCount, heartCount, videoCount, diggCount.

import datetime as dt

def snapshot_profile(username: str) -> dict:
    r = requests.get(
        f"{API_BASE}/userinfo-by-username/",
        params={"username": username},
        headers=HEADERS,
        timeout=15,
    )
    r.raise_for_status()
    data = r.json()
    user = data["user"]
    stats = data["stats"]
    return {
        "captured_at": dt.datetime.utcnow().isoformat(),
        "username": user["uniqueId"],
        "sec_uid": user["secUid"],
        "private": user.get("privateAccount", False),
        "follower_count": stats["followerCount"],
        "following_count": stats["followingCount"],
        "heart_count": stats["heartCount"],
        "video_count": stats["videoCount"],
    }

Persist each snapshot row keyed by (username, captured_at). The day-over-day delta is what you actually care about; a single snapshot is worthless on its own. Note that mixed casing is a real risk; access the documented field names exactly rather than transforming everything to one case and hoping.

Step 3: Pull Recent Posts

Use /user-posts/ to pull the most recent posts. The endpoint accepts userid, an optional count (default 10, max 35), and an optional cursor for pagination. The top key is videos and each video uses snake_case throughout: aweme_id, video_id, play_count, digg_count, comment_count, share_count, create_time, play (no-watermark URL), and wmplay (watermarked URL).

def fetch_recent_posts(userid: str, count: int = 35) -> list:
    r = requests.get(
        f"{API_BASE}/user-posts/",
        params={"userid": userid, "count": count},
        headers=HEADERS,
        timeout=20,
    )
    r.raise_for_status()
    return r.json().get("videos", [])

def normalize_post(v: dict) -> dict:
    return {
        "aweme_id": v["aweme_id"],
        "create_time": v["create_time"],
        "play_count": v.get("play_count", 0),
        "digg_count": v.get("digg_count", 0),
        "comment_count": v.get("comment_count", 0),
        "share_count": v.get("share_count", 0),
        "is_ad": v.get("is_ad", False),
    }

For daily polling, 35 posts per account is usually enough to catch everything new since the last run. If you suspect a competitor is posting more than 35 times a day (unlikely but possible for big media brands), paginate using the cursor returned in the response.

Step 4: Compute Engagement Rate Per Post

Engagement rate normalizes raw metrics so you can compare a 500-follower account against a 5-million-follower account. The standard formula is (likes + comments + shares) / followers, but you need sane caps. New posts have not had time to accumulate engagement, so anything younger than 24 hours should be tagged as "fresh" rather than included in the median. Accounts under 1000 followers should be excluded from rate calculations because the denominator is too noisy.

def engagement_rate(post: dict, follower_count: int) -> float | None:
    if follower_count < 1000:
        return None
    age_hours = (dt.datetime.utcnow().timestamp() - post["create_time"]) / 3600
    if age_hours < 24:
        return None  # too fresh to score
    interactions = (
        post["digg_count"]
        + post["comment_count"]
        + post["share_count"]
    )
    rate = interactions / follower_count
    # cap at 100% to absorb viral outliers in downstream stats
    return min(rate, 1.0)

Whether to include play_count in the numerator is a religious debate. Most brand teams stick to the three-action formula above because plays are heavily inflated by the For You algorithm and do not represent intent. You can also compute a separate "view-through rate" of likes / play_count as a content quality signal.

Step 5: Detect Surge Posts

A surge post is one that significantly outperforms the account's baseline. The practical definition: a post whose engagement rate is at least 3 times the account's median engagement rate over the trailing 30 posts. Median, not mean, because viral outliers from previous weeks would distort the mean and hide future surges.

import statistics

def detect_surges(posts: list, follower_count: int, multiple: float = 3.0):
    rates = [
        (p, engagement_rate(p, follower_count))
        for p in posts
    ]
    scored = [(p, r) for p, r in rates if r is not None]
    if len(scored) < 10:
        return []
    median_rate = statistics.median(r for _, r in scored)
    threshold = median_rate * multiple
    return [
        {"post": p, "rate": r, "median": median_rate}
        for p, r in scored
        if r >= threshold
    ]

Surges are the highest-value signal in the pipeline. When a competitor posts something that does 3x their median, your strategy team should see the video URL, the concept, and the hook within hours. That is the difference between reactive content planning and being permanently a week behind.

Step 6: Build a Daily Diff

The daily diff is the comparison between today's snapshot and yesterday's snapshot for each competitor. Three things matter: new posts (posts whose aweme_id is in today's set but not yesterday's), deleted posts (the opposite), and follower delta. Deleted posts are unusually interesting because they reveal what a competitor is willing to walk back.

def daily_diff(today_snap, yesterday_snap, today_posts, yesterday_posts):
    today_ids = {p["aweme_id"] for p in today_posts}
    yesterday_ids = {p["aweme_id"] for p in yesterday_posts}
    new_post_ids = today_ids - yesterday_ids
    deleted_post_ids = yesterday_ids - today_ids
    new_posts = [p for p in today_posts if p["aweme_id"] in new_post_ids]
    follower_delta = (
        today_snap["follower_count"] - yesterday_snap["follower_count"]
    )
    return {
        "username": today_snap["username"],
        "new_posts": new_posts,
        "deleted_post_ids": list(deleted_post_ids),
        "follower_delta": follower_delta,
        "follower_growth_pct": (
            follower_delta / yesterday_snap["follower_count"]
            if yesterday_snap["follower_count"] else 0
        ),
    }

A follower drop of more than 1% in a day is worth flagging. Big drops correlate with platform-side actions (shadowban, takedown) or PR events. Either way you want to know.

Step 7: Scale to 50 Competitors

To run this for 50 accounts on a schedule, you need three things: a database to store snapshots and posts, a cron job to drive the run, and rate-limit awareness. TikLiveAPI's standard rate limit is 200 requests per minute, which is comfortably more than a 50-account daily poll needs (2 calls per account = 100 requests). For larger fleets, request a higher limit via contact.

import sqlite3, time, json

def init_db(path="competitors.db"):
    cn = sqlite3.connect(path)
    cn.executescript("""
    CREATE TABLE IF NOT EXISTS snapshots(
        username TEXT, captured_at TEXT,
        follower_count INT, video_count INT, heart_count INT,
        PRIMARY KEY(username, captured_at)
    );
    CREATE TABLE IF NOT EXISTS posts(
        aweme_id TEXT PRIMARY KEY, username TEXT,
        create_time INT, play_count INT, digg_count INT,
        comment_count INT, share_count INT, raw TEXT
    );
    CREATE TABLE IF NOT EXISTS userid_cache(
        username TEXT PRIMARY KEY, userid TEXT
    );
    """)
    return cn

def run_daily(usernames: list):
    cn = init_db()
    for handle in usernames:
        try:
            row = cn.execute(
                "SELECT userid FROM userid_cache WHERE username=?",
                (handle,)
            ).fetchone()
            uid = row[0] if row else resolve_userid(handle)
            if not row:
                cn.execute(
                    "INSERT INTO userid_cache VALUES(?, ?)",
                    (handle, uid)
                )
            snap = snapshot_profile(handle)
            posts = fetch_recent_posts(uid, count=35)
            cn.execute(
                "INSERT OR REPLACE INTO snapshots VALUES(?,?,?,?,?)",
                (snap["username"], snap["captured_at"],
                 snap["follower_count"], snap["video_count"],
                 snap["heart_count"])
            )
            for v in posts:
                p = normalize_post(v)
                cn.execute(
                    "INSERT OR REPLACE INTO posts VALUES(?,?,?,?,?,?,?,?)",
                    (p["aweme_id"], handle, p["create_time"],
                     p["play_count"], p["digg_count"],
                     p["comment_count"], p["share_count"],
                     json.dumps(v))
                )
            cn.commit()
            time.sleep(0.4)  # gentle pacing
        except Exception as e:
            print(f"[{handle}] failed: {e}")
    cn.close()

Drop this into a cron entry that runs once a day at a consistent UTC time (consistency matters more than the specific hour, because day-over-day deltas are only meaningful when sampled at the same point in the day). For Postgres at scale, swap SQLite for psycopg and add an index on (username, captured_at).

Output Format Suggestions

Raw rows in a database are not actionable. You need three downstream surfaces, each tuned to a different consumption pattern.

Slack alerts

Wire surge detections and large follower drops to a Slack webhook. Keep the message short: competitor name, metric that triggered, the post URL (use the play field from /post-detail/ if you need the no-watermark URL), and a one-line "why this matters" inference. One message per event. No batching, because the value of a surge alert collapses if you see it 8 hours late.

Weekly digest

Aggregate seven days of diffs into a Monday morning email. Top 5 surge posts across the entire competitor set, top 3 follower-growth accounts, and a list of any accounts that went silent (no new posts in 7 days). The digest is for strategy, not reaction.

Dashboard

A Metabase or Superset board on top of your SQLite or Postgres tables, with one chart per metric: follower trend per competitor, posts-per-week per competitor, median engagement rate per competitor, and a leaderboard of top surge posts. You can test endpoint responses live in the playground before wiring anything to a dashboard.

FAQ

How many credits does monitoring 50 competitors per day consume?

Two calls per competitor (profile snapshot + recent posts) means 100 credits per day, or 3000 per month. Username-to-ID lookups are one-time per account. At $9.90 for the base credit pack, 50-account monitoring is well under $10 per month for most teams.

Can I monitor private accounts?

No. The TikLiveAPI surface returns only publicly available data. The privateAccount field on the user object will be true for those accounts, and post-level endpoints will return empty arrays. Filter them out of your competitor list during onboarding.

Why does the same response mix camelCase and snake_case?

Inside userinfo-by-username, the user object mixes camelCase fields like uniqueId and secUid with snake_case fields like ins_id and youtube_channel_id. The stats object is uniformly camelCase. The /user-posts/ response is uniformly snake_case at the video level but the top-level hasMore field is camelCase. Treat each documented field name as literal and do not transform the keys.

What happens when a competitor deletes a video?

The video will not appear in subsequent /user-posts/ responses. Your daily diff will surface its aweme_id in the deleted set. If you want to preserve a copy of the original video before deletion, hit /download-video/ with the original TikTok URL during the first poll where you see it; the response includes a no-watermark direct download URL.

Can I extend this to monitor a hashtag instead of an account?

Yes. Replace Step 3 with a call to /challenge-posts/ (takes a challenge_id; use /challenge-info-name/ first to resolve a hashtag name to an ID). The video shape is identical to /user-posts/, so the engagement rate and surge detection logic from Steps 4 and 5 work without modification. The denominator changes from follower count to a rolling median of plays in the challenge, but the structure is the same.

Monitor your credit consumption from your profile as you scale. Build the pipeline incrementally; start with 5 competitors, validate that surge detection is firing on signal rather than noise, then push to 50.

Build with the TikTok API

Ready to put what you read into code? Try our endpoints live or grab the full reference.

Open Playground Read Documentation