Blog/Tutorials/

TikTok Influencer Fake Views Audit: Build a Vetting Agent

Build a TikTok influencer fake views audit agent: pull a creator's recent posts, score each with a fake-view detector, aggregate in Python, then write a memo.

Cover for a tutorial on building a TikTok creator audit agent that screens recent posts for fake-view risk signals

My first audit of the NBA’s TikTok account came back “review”: reach per follower was below the tier minimum. Then I looked at the 15 posts it had scored. All of them were under two days old, still collecting views. I added an age filter, and the next memo quoted a tier minimum of 0.01, the benchmark for a smaller account than one with 27 million followers. The first post in that batch was a collab whose author had 3.1 million followers, and the code had taken the tier from it. Nothing was fraudulent. The audit was measuring the wrong posts.

This tutorial builds a TikTok influencer fake views audit you can run before a paid deal. You give it a creator handle. It pulls the creator’s recent posts, skips the ones that would skew the result, runs SandBase’s tiktok/analytics/detect-fake-views endpoint on each, aggregates everything in Python, and has openai/gpt-6-luna write a short vetting memo from the computed stats only. Output: a CSV with one row per post and a memo with a screening band. It’s for brand and agency teams who vet creators, and for developers building that screening step. I tested it on three official brand accounts: Duolingo, the NBA and Ryanair.

Key takeaway

  • On 45 posts from three verified brand accounts (2026-10-04), the detector marked 0 posts suspicious and gave “Minimal” confidence every time, with median fake_score 16.26, 19.65 and 19.74.
  • estimated_fake_views looks like a lookup, not a count. It equaled views times 5%, 10% or 20%, chosen by fake_score: 5% for scores up to 9.98, 10% from 12.21 to 19.99, 20% from 20.09 up.
  • Which posts you audit changed the result more than anything else I tried. Skipping pinned posts, posts younger than 3 days and collabs by other authors removed 6 to 65 list items per account before scoring.
  • The LLM step cost $0.00022 to $0.00034 per memo. A numbers check found no invented numbers, but reading the memos still turned up two problems: a flag on 9 of 15 posts called account-level, and a “proceed with the deal” that goes further than the band.

What the agent does

StepEndpointWhat the code keeps
Recent poststiktok/app-v3/user-post-videosvideo id, post date, views, likes, comments, shares; pinned, too new and collab posts skipped
Score each posttiktok/analytics/detect-fake-viewsfake_score, fake-view percentage and estimate, is_suspicious, confidence, flags, follower tier and its benchmarks
AggregatePythonmedians, counts, reach vs the tier benchmarks, recurring flags, screening band
Memoopenai/gpt-6-luna via /v1/chat/completions4 to 6 bullets and a next step, then a numbers check

The detector’s report is long. The single-video guide walks through every block of it. This agent reads only the parts that survive aggregation across posts. For the profile and post endpoints themselves, see the TikTok creator research API guide.

What this result is, and isn’t

detect-fake-views is a third-party heuristic. The reference describes it as traffic analysis based on “TikTok’s Traffic Pool theory”. It isn’t TikTok’s own data, and a high score isn’t proof that anyone bought views. I have no labeled set of known-fake videos, so this article measures how the detector behaves, not how accurate it is. That’s also why the demo uses official brand accounts only. Don’t publish a named creator as “fake” based on this output.

The data is public and read-only, and you need a SandBase API key. SandBase isn’t an official TikTok partner. There’s no access to private accounts, DMs, a creator’s own analytics or account actions.

You needUse
A quick screen of any public creator before outreachThis agent (public data, third-party heuristic)
The creator’s real audience, reach and traffic sourcesAsk the creator for TikTok Studio analytics or campaign data shared through TikTok’s own creator marketplace tools
Data about your own account or adsTikTok’s official tools for account owners

Tested on 2026-10-04 (UTC)

Inputs: the public handles duolingo, nba and ryanair, and video ids those accounts published. Every data call is POST https://api.sandbase.ai/v1/api/<vendor>/<path> with Authorization: Bearer $SANDBASE_API_KEY and a JSON body of that endpoint’s fields only. This is the Model API POST route from the endpoint reference, not the catalog’s GET route; the reference’s generated curl example adds a model field to the body, which the path already identifies. The code reads only the documented outputs[0].data and raises when a run isn’t completed. Field names below are observed in these calls, not documented guarantees.

SandBase endpoint reference for tiktok/app-v3/user-post-videos showing POST /v1/api/tiktok/app-v3/user-post-videos with count, max_cursor, region, sec_user_id, sort_type and unique_id

Caption: The user-post-videos reference lists count (default 20), max_cursor for paging and unique_id, the three fields the agent sends (captured 2026-10-04).

curl -s https://api.sandbase.ai/v1/api/tiktok/app-v3/user-post-videos \
  -H "Authorization: Bearer $SANDBASE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"unique_id": "duolingo", "count": 20}'

Run dfacf0c8-3704-48a8-a8bd-57869a9c2441 returned 10 posts, not 20, plus has_more: 1 and a max_cursor for the next page. The second post, with every other key omitted:

{"aweme_id": "7692128874154429709", "create_time": 1790963327, "is_top": 0,
 "author": {"unique_id": "duolingo"},
 "statistics": {"play_count": 354521, "digg_count": 44803, "comment_count": 1396,
                "share_count": 12012, "collect_count": 8457}}

The first post in that list had is_top: 1: a pinned video from June 2025. author.unique_id is what exposes collabs, since a collab in a creator’s feed carries the other author’s handle.

SandBase endpoint reference for tiktok/analytics/detect-fake-views showing POST /v1/api/tiktok/analytics/detect-fake-views with item_id required and content_category optional

Caption: The detect-fake-views reference shows item_id as the only required field and content_category as optional, and describes the method as based on TikTok’s Traffic Pool theory (captured 2026-10-04).

The detector call for a Duolingo video from 2026-09-25, {"item_id": "7689488735649402126"} (run 28568b7b-0fb3-4970-955a-cd68594413e7), returned this fake_view_analysis object in full. It’s a separate call from the batch runs below, so its numbers differ slightly from theirs:

{"component_scores": {"consistency_score": 77.78, "content_authenticity_score": 34.0,
  "creator_credibility_score": 0, "distribution_score": 65.0, "engagement_score": 0.0,
  "fan_growth_score": 45, "follower_correlation_score": 20, "racing_mechanism_score": 0},
 "confidence_level": "Minimal", "estimated_fake_views": 666010, "fake_score": 25.64,
 "fake_view_percentage": 20.0, "is_suspicious": false,
 "main_detection_reason": "Statistical View Anomalies"}

From detailed_analysis.follower_correlation, with its other keys omitted:

{"follower_tier": "xxlarge",
 "tier_benchmarks": {"like_follower_ratio": {"avg": 0.002, "good": 0.01, "min": 0.0005},
                     "view_ratio": {"avg": 0.02, "good": 0.1, "min": 0.005}}}

The consistency block explained its 77.78 with seven issues across nine days, such as “Day 3: 474265 views but 0 comments”. A video with 9,676 comments in total having zero on five of those days looks more like a gap in the daily series than a pattern in the audience. That’s the kind of reason you should read before acting on a score.

The complete program

One file, 200 lines, standard library plus requests. Run it as python3 creator_audit.py duolingo. Options: --posts (default 15), --min-age-days (default 3), --category and --model. This is the exact file I ran for the results below.

#!/usr/bin/env python3
"""TikTok creator audit agent: recent posts -> detect-fake-views per post -> stats in code -> LLM memo.

Usage: python3 creator_audit.py duolingo [--posts 15] [--category default] [--model openai/gpt-6-luna]
Writes audit_<handle>.csv (one row per post) and memo_<handle>.md (stats, memo, numbers check).
The result is a screening signal from a third-party heuristic, not evidence of fraud.
"""
import argparse
import csv
import json
import os
import re
import statistics as st
import time
from collections import Counter
from concurrent.futures import ThreadPoolExecutor

import requests

BASE = "https://api.sandbase.ai/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['SANDBASE_API_KEY']}"}
PRICE = {"openai/gpt-6-luna": (0.1, 0.5)}  # USD per 1M input / output tokens on SandBase, 2026-10-04


def call(model: str, params: dict, retries: int = 2) -> dict:
    """POST /v1/api/<model> and return outputs[0].data; raise if the run did not complete."""
    for attempt in range(retries + 1):
        try:
            resp = requests.post(f"{BASE}/api/{model}", headers=HEADERS, json=params, timeout=120)
        except requests.RequestException:  # dropped connections happen; retry them like a 5xx
            if attempt == retries:
                raise
            time.sleep(3 * (attempt + 1))
            continue
        body = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
        outputs = body.get("outputs") or []
        if resp.status_code == 200 and body.get("status") == "completed" and outputs:
            return outputs[0].get("data") or {}
        if resp.status_code < 500 or attempt == retries:
            raise RuntimeError(f"{model}: HTTP {resp.status_code} {body.get('status')} {body.get('error')}")
        time.sleep(3 * (attempt + 1))


def recent_posts(handle: str, n: int, min_age_days: float, max_pages: int = 8) -> tuple[list[dict], dict]:
    """Newest posts by this creator, at least min_age_days old (fresh posts are still collecting views)."""
    posts, cursor, cutoff, skipped = [], 0, time.time() - min_age_days * 86400, Counter()
    for page in range(1, max_pages + 1):
        data = call("tiktok/app-v3/user-post-videos", {"unique_id": handle, "count": 20, "max_cursor": cursor})
        for a in data.get("aweme_list") or []:
            s = a.get("statistics") or {}
            author = ((a.get("author") or {}).get("unique_id") or "").lower()
            reason = ("pinned" if a.get("is_top") else "collab by another author" if author != handle.lower()
                      else "too new" if a.get("create_time", 0) > cutoff else "" if s.get("play_count") else "no views")
            if reason:
                skipped[reason] += 1
                continue
            posts.append({"video": a["aweme_id"], "posted": time.strftime("%Y-%m-%d", time.gmtime(a.get("create_time", 0))),
                          "list_views": s["play_count"], "likes": s.get("digg_count", 0),
                          "comments": s.get("comment_count", 0), "shares": s.get("share_count", 0)})
        if len(posts) >= n or not data.get("has_more"):
            break
        cursor = data.get("max_cursor") or 0
    return posts[:n], {"list_pages": page, **skipped}


def detect(post: dict, category: str | None) -> dict:
    params = {"item_id": post["video"], **({"content_category": category} if category else {})}
    d = call("tiktok/analytics/detect-fake-views", params)
    fv, da = d.get("fake_view_analysis") or {}, d.get("detailed_analysis") or {}
    thin = [name for name, block in da.items() if isinstance(block, dict)
            and any("Insufficient" in i for i in block.get("issues") or [])]
    return {**post, "fake_score": fv.get("fake_score"), "fake_pct": fv.get("fake_view_percentage"),
            "est_fake_views": fv.get("estimated_fake_views"), "suspicious": fv.get("is_suspicious"),
            "confidence": fv.get("confidence_level"), "reason": fv.get("main_detection_reason"),
            "views": (d.get("video_metrics") or {}).get("total_views") or post["list_views"],
            "insufficient": " ".join(thin),
            "_flags": d.get("suspicious_features") or [], "_creator": d.get("creator_metrics") or {},
            "_tier": (da.get("follower_correlation") or {}), "_bench": (da.get("engagement") or {}).get("benchmark_category")}


def med(xs: list) -> float:
    xs = [x for x in xs if x is not None]
    return round(st.median(xs), 4) if xs else None


def vs_tier(x: float, b: dict | None) -> str:
    """Place a ratio against the endpoint's own tier benchmarks (min / avg / good) in code, not in the LLM."""
    if not b or x is None:
        return "no benchmark"
    return ("below tier min" if x < b["min"] else "min to avg" if x < b["avg"]
            else "avg to good" if x < b["good"] else "above good")


def aggregate(handle: str, rows: list[dict]) -> dict:
    followers = max(r["_creator"].get("follower_count") or 0 for r in rows)
    tier = rows[0]["_tier"]
    bench = tier.get("tier_benchmarks") or {}
    scores = [r["fake_score"] for r in rows if r["fake_score"] is not None]
    views = [r["views"] for r in rows]
    flags = Counter(f for r in rows for f in set(r["_flags"]))
    return {
        "handle": handle, "posts": len(rows), "followers": followers, "verified": rows[0]["_creator"].get("verified"),
        "follower_tier": tier.get("follower_tier"), "benchmark_category": rows[0]["_bench"],
        "fake_score": {"median": med(scores), "min": min(scores), "max": max(scores)},
        "suspicious_posts": sum(bool(r["suspicious"]) for r in rows),
        "confidence_levels": dict(Counter(r["confidence"] for r in rows)),
        "fake_pct_median": med([r["fake_pct"] for r in rows]),
        "fake_pct_levels": {str(k): v for k, v in sorted(Counter(r["fake_pct"] for r in rows).items())},
        "est_fake_views_total": sum(r["est_fake_views"] or 0 for r in rows), "views_total": sum(views),
        "views_median": med(views), "views_max_to_median": round(max(views) / st.median(views), 2),
        "view_follower_median": med([v / followers for v in views]),
        "like_follower_median": med([r["likes"] / followers for r in rows]),
        "tier_view_ratio": bench.get("view_ratio"), "tier_like_follower_ratio": bench.get("like_follower_ratio"),
        "reach_vs_tier": vs_tier(med([v / followers for v in views]), bench.get("view_ratio")),
        "likes_vs_tier": vs_tier(med([r["likes"] / followers for r in rows]), bench.get("like_follower_ratio")),
        "like_view_median": med([r["likes"] / r["list_views"] for r in rows]),
        "comment_like_median": med([r["comments"] / max(r["likes"], 1) for r in rows]),
        "share_view_median": med([r["shares"] / r["list_views"] for r in rows]),
        "insufficient_data_by_component": dict(Counter(c for r in rows for c in r["insufficient"].split())),
        "recurring_flags": [{"flag": f, "posts": c} for f, c in flags.most_common(4)],
    }


def band(s: dict) -> str:
    """Screening band from fixed starting thresholds (ours, not the vendor's; no ground truth behind them)."""
    if s["suspicious_posts"] / s["posts"] >= 0.3:
        return "escalate"
    if s["suspicious_posts"] or s["reach_vs_tier"] == "below tier min":
        return "review"
    return "no screening concerns"


MEMO_PROMPT = """You write a short vetting memo for a brand team about a TikTok creator before a paid deal.
Use ONLY the JSON stats you are given. Copy numbers exactly as written; do not compute new numbers.
Write plain English, not JSON key names. Ratios are per view or per follower (0.0275 = 2.75%).
Compare with the follower tier only through the reach_vs_tier and likes_vs_tier labels.
The detector is a third-party heuristic, not TikTok data and not proof of fraud: say "risk signals" or
"the endpoint estimated", never "fake account". Flags that repeat on every post are account-level.
Format: 4-6 bullet points, under 160 words, then one line "Next step:" that matches the screening band."""


def memo(stats: dict, model: str) -> tuple[str, dict]:
    resp = requests.post(f"{BASE}/chat/completions", headers=HEADERS, timeout=120,
                         json={"model": model, "max_tokens": 1500,
                               "messages": [{"role": "system", "content": MEMO_PROMPT},
                                            {"role": "user", "content": json.dumps(stats)}]})
    resp.raise_for_status()
    body = resp.json()
    return body["choices"][0]["message"]["content"].strip(), {"id": body.get("id"), **(body.get("usage") or {})}


def numbers_check(text: str, stats: dict) -> list[str]:
    """Every number in the memo must match a stats value (as-is, rounded, or as a percentage)."""
    allowed = set()
    for v in re.findall(r"-?\d+(?:\.\d+)?", json.dumps(stats)):
        x = float(v)
        for y in (x, x * 100):
            allowed |= {f"{y:g}", f"{y:.0f}", f"{y:.1f}", f"{y:.2f}", f"{y:,.0f}"}
    found = re.findall(r"\d[\d,]*(?:\.\d+)?", text)
    return [n for n in found if n not in allowed and n.replace(",", "") not in allowed]


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("handle")
    ap.add_argument("--posts", type=int, default=15)
    ap.add_argument("--min-age-days", type=float, default=3)
    ap.add_argument("--category", default=None, help="default|entertainment|education|product|verified_large")
    ap.add_argument("--model", default="openai/gpt-6-luna")
    a = ap.parse_args()
    t0 = time.time()
    posts, skipped = recent_posts(a.handle, a.posts, a.min_age_days)
    if not posts:
        raise SystemExit(f"no eligible posts for @{a.handle}: {skipped}")
    with ThreadPoolExecutor(max_workers=4) as pool:
        rows = list(pool.map(lambda p: detect(p, a.category), posts))
    t1 = time.time()
    stats = aggregate(a.handle, rows)
    stats["min_post_age_days"], stats["skipped_posts"] = a.min_age_days, skipped
    stats["screening_band"] = band(stats)
    facts = {k: v for k, v in stats.items() if not k.startswith("tier_")}  # raw benchmarks invite mix-ups
    text, usage = memo(facts, a.model)
    unmatched = numbers_check(text, facts)
    pin, pout = PRICE.get(a.model, (0, 0))
    usd = (usage.get("prompt_tokens", 0) * pin + usage.get("completion_tokens", 0) * pout) / 1e6
    cols = ["video", "posted", "views", "list_views", "likes", "comments", "shares", "fake_score", "fake_pct",
            "est_fake_views", "suspicious", "confidence", "reason", "insufficient"]
    with open(f"audit_{a.handle}.csv", "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=cols, extrasaction="ignore")
        w.writeheader()
        w.writerows(rows)
    with open(f"memo_{a.handle}.md", "w", encoding="utf-8") as f:
        f.write(f"# Creator audit: @{a.handle} ({time.strftime('%Y-%m-%d %H:%M UTC', time.gmtime())})\n\n"
                f"Screening band: **{stats['screening_band']}**\n\n{text}\n\n"
                f"Numbers check: {'all numbers match the stats' if not unmatched else 'UNMATCHED ' + ', '.join(unmatched)}\n\n"
                f"```json\n{json.dumps(stats, indent=1)}\n```\n")
    print(json.dumps(stats, indent=1))
    print(f"\n{text}\n\nunmatched numbers: {unmatched}")
    print(f"{len(rows)} posts, data {t1 - t0:.1f}s, total {time.time() - t0:.1f}s, "
          f"LLM {usage.get('prompt_tokens')} in / {usage.get('completion_tokens')} out, ${usd:.5f}, id {usage.get('id')}")

Four choices came out of earlier runs that went wrong:

  • Which posts count. Pinned posts are old and hand-picked. Posts younger than three days are still climbing, which pushed the NBA’s reach down in my first run. Collabs carry another author’s follower count, and the agent takes the tier from the first post, so one collab can swap the benchmark. All three are skipped and counted in skipped_posts.
  • Tier comparisons happen in code. Placing a ratio between min, avg and good is arithmetic, so vs_tier writes a label and the LLM never sees the benchmark tables. The collab bug above only showed because the memo quoted a raw benchmark; the label still prints the tier name, which is the thing to eyeball.
  • The LLM only words the memo. Every number comes from aggregate, the same rule our GPT-6.1 Sol agent test led to. GPT-6 Luna is the default because a 600-token memo costs a fraction of a cent; our model tier benchmark found it 51 of 51 on tool tasks.
  • The screening band is mine, not the vendor’s. “Escalate” at 30% suspicious posts, “review” at any suspicious post or reach below the tier minimum. An earlier draft also sent anything with median fake_score 20 or more to review. That put Ryanair in review at 21.53 with zero suspicious posts, and I dropped it. Tuning a threshold on three brand accounts is still tuning without ground truth, so treat these as starting points.

Results on three brand accounts

The final runs started at 02:00 UTC on 2026-10-04, one after another, with default options.

DuolingoNBARyanair
Followers (from the detector)18,187,03227,234,1752,924,849
Follower tierxxlargexxlargexlarge
Posts scored, post dates15, Sep 10 to 3015, all Sep 3015, Sep 14 to 30
List pages read373
Skipped: pinned / too new / collab1 / 4 / 13 / 52 / 103 / 3 / 0
fake_score median (min to max)16.26 (5.36 to 22.88)19.65 (16.4 to 24.6)19.74 (9.72 to 26.83)
is_suspicious0 of 150 of 150 of 15
Fake-view percentage 5 / 10 / 205 / 7 / 3 posts0 / 10 / 52 / 6 / 7
Estimated fake views / total views1,337,145 / 13,623,666340,026 / 1,975,011793,296 / 6,102,527
Median views per follower0.0317 (avg to good)0.0028 (below tier min)0.0431 (min to avg)
Median likes per view0.12180.08670.0416
Flags on every post2 follower-growth flags1 follower-growth flagnone
Screening bandno screening concernsreviewno screening concerns

Two of the NBA’s posts showed a percentage of 9.99, which I counted as 10. All 45 posts had confidence “Minimal” and an “Insufficient data” note on the racing-mechanism component.

SandBase model page for tiktok/analytics/detect-fake-views showing Free base price, sync execution, api model type and 2 input fields, under an API Free Week banner

Caption: The detect-fake-views model page showed a Free base price, sync execution and 2 input fields on 2026-10-04, under an “API Free Week” banner, so treat the price as dated (captured 2026-10-04).

What the numbers say, and what they don’t

The fake-view estimate is a step function. Across all 45 posts, estimated_fake_views equaled views times 5%, 10% or 20%, rounded down. The two NBA posts that reported 9.99 still used 10%. The percentage was 5 for every fake_score from 5.36 to 9.98, 10 from 12.21 to 19.99, and 20 from 20.09 to 26.83. No score fell in the gaps, so the exact cutoffs are a guess. Small score moves can double the estimate. One Duolingo video scored 25.64 in three runs between 01:38 and 01:46 UTC, then 16.26 at 01:50 and 02:00. Between the 01:46 and 01:50 runs its views grew by 570, and its estimate halved, from 666,129 to 333,121. Those two runs shared 43 posts: 3 moved by 6.7 to 10.06 points, the rest by 0.01 or less. The last two runs, 10 minutes apart, matched within 0.01 on all 45. So add up “estimated fake views” for a contract only if you’re comfortable with a number that can halve between two calls.

Account-level flags repeat on every post. Duolingo got “Followers grew 5.0x in only 18 days” and “Reached 1000000 followers from 500000 in only 20 days” on all 15 posts. The NBA got a 168-day version of the second. These come from the account’s follower history, not from the video. A brand account can plausibly grow fast in its first weeks on TikTok, and the flag doesn’t say when the growth happened. TikTok’s own account got a similar flag in the single-video guide. Count them once per account.

Low reach isn’t a fake-view signal. The NBA landed in “review” only because its median views per follower, 0.0028, sat below the detector’s tier minimum of 0.005. The NBA posts a lot: 52 posts were younger than three days, and 15 eligible posts covered a single day. More posts likely spread the same audience thinner, though I didn’t measure that. It’s a reason to ask questions about expected reach, not about fraud.

content_category changed nothing here. I re-ran Duolingo with education and with entertainment. All 15 scores were identical to the run without a category, and benchmark_category stayed verified_large each time. For this verified account the detector appears to pick the benchmark itself. I didn’t test unverified creators, where the category may matter.

The two endpoints agreed on views. View counts from the post list and from the detector’s video_metrics differed by about 0.01% at most on every post.

Did the memo stick to the numbers?

The numbers check passed on all three final memos: every number in them appeared in the stats. Reading them line by line found two problems it can’t catch. Ryanair’s memo said statistical-anomaly flags “recur across posts, so treat them as account-level”. They were on 9 of 15 posts, and the prompt reserves “account-level” for flags on every post. Duolingo’s memo ended with “Proceed with the deal”, which is more than “no screening concerns” means; the table below says to continue normal vetting. In earlier runs the memos twice wrote a fake-view total as just “estimated views”. A presence check only shows a number exists. It doesn’t show it’s attached to the right label. Read the memo next to the stats block it ships with.

What to do with the result

ResultWhat it meansWhat to do
No screening concernsNo suspicious posts, reach at or above the tier minimumContinue normal vetting: audience fit, brand safety, past campaign results
Review: reach below tier minimumViews per follower are low for this follower tierAsk for TikTok Studio reach and follower-activity screenshots; check posting frequency first
Review: some suspicious postsThe detector flagged individual videosRead their main_detection_reason and issue lines; ask for traffic-source data for those videos
Escalate: 30% or more suspiciousA pattern across posts, not one outlierPause the deal until the creator shares first-party analytics; don’t accuse anyone on this result alone
Any band, flags on every postAccount history, not the videosAsk about growth events such as a viral hit or a paid promotion; judge recent posts on their own

In every row, the endpoint is a screening signal. The creator’s own analytics are the evidence.

Batch timing and cost

Each run scored 15 posts with four parallel detector calls. Data time was 28.8 s for Duolingo, 72.6 s for the NBA and 27.2 s for Ryanair; the NBA needed 7 list pages to find 15 eligible posts. End to end, including the memo, the runs took 35.5, 80.3 and 34.2 seconds.

The data calls cost nothing at the listed price that day: both endpoints returned base_price: "0" from GET /v1/models/<model>, which needs the same Bearer key (the public model pages show prices without one). The model page above carried an “API Free Week” banner, so check before a large batch. GPT-6 Luna was listed at $0.10 input and $0.50 output per million tokens. The three memos used 600 to 609 input and 318 to 556 output tokens. GET /v1/tasks/<id>/cost, with the same Bearer key, showed $0.000220, $0.000330 and $0.000338, matching the program’s estimate. For data runs, the cost endpoint returned $0 for a user-post-videos run and “task not found” for a detector run.

The detector was also down for a while. From about 01:18 to 01:28 UTC every call returned HTTP 503 with an upstream error, including the reference’s own example video, while tiktok/analytics/video-metrics answered normally. It was back by 01:32. The program retries 5xx responses twice, then stops with the error. For a batch of creators, catch that and retry the creator later rather than writing a partial audit.

Limits

Scope: three verified brand accounts, 15 posts each, one morning (UTC) on 2026-10-04, plus earlier runs that shaped the code. No unverified or small creators, no other regions, no labeled fake-view data, so nothing here measures accuracy. The step mapping and the category result are observations from these 45 posts, not documented behavior. The raw responses are kept internally; the method, prompt, thresholds and aggregates are in this article.

Next steps

  • Read one report in full. The single-video guide explains each block and what to trust.
  • Check how stable the detector is. The detection study looks at scores across many more videos and repeat calls.
  • Add profile context. The creator research guide covers profiles, follower counts and post lists.

To try it, read the detect-fake-views reference, open the model page, and get a SandBase API key.

FAQ

How do I check a TikTok influencer for fake views?

Score their recent posts, not one video, and look at the pattern: how many posts the detector marks suspicious, whether reach per follower fits their tier, and which flags repeat. Then ask the creator for first-party analytics. On three brand accounts, 0 of 45 posts were marked suspicious.

What does fake_score mean?

It’s the detector’s overall risk score, built from eight component scores. The reference doesn’t state its range or the cutoff for is_suspicious. On these 45 posts it ran from 5.36 to 26.83, and is_suspicious was false every time.

Is the estimated fake views number reliable?

Treat it as a band. On these 45 posts it was always views times 5%, 10% or 20%, chosen by fake_score. One video’s estimate halved between two calls minutes apart.

Why skip collabs and new posts?

New posts haven’t finished collecting views; they put the NBA into “review” in my first run. Collabs carry the other author’s follower count, and one swapped the NBA’s tier benchmark in a later run.

How much does an audit cost?

On 2026-10-04 the two TikTok endpoints were listed free and one GPT-6 Luna memo cost $0.00022 to $0.00034. Fifteen posts took 34 to 80 seconds.