DEV Community

Matvey
Matvey

Posted on

123 TikTok Shop Videos, 123 Growth Figures Below 100%. Then the Same Column Returned +220,000%

I run a visa agency in Bali. The other half of my week goes into Apify Actors, and one of them reads TikTok Shop's shoppable videos: which clips are actually selling, with revenue, views, ad ROAS and TikTok's AI-generated flag, in 15 markets.

The output has a column called revenueGrowthPercent. It is the field a client asks for first — show me what's taking off — and it is the one field in that row I now refuse to put on a chart.

123 rows, not one of them above −117%

Buyers hit this Actor on a schedule, so I had a clean pile of real output to diff. Between 20 and 22 October 2026, seven runs fired with the same input:

{"country": "US", "period": "last30Day", "sortBy": "revenue",
 "maxResultsPerSource": 20, "addAnalytics": false, "aiVideos": "all"}
Enter fullscreen mode Exit fullscreen mode

Top shoppable videos in the US by revenue over the last 30 days. 123 video rows, 23 distinct videos (the US top 20 barely moves day to day — that's a separate article).

Column Across 123 rows
revenue $174,133 – $499,291, median $202,464
views median 10,220,203
adsRoas 2.31 – 4.42
isAd true on 123 of 123
revenueGrowthPercent −230.99 to −117.53, median −197.31

Read that last row again. Not one value was positive. Not one value was above −100%. A revenue decline of −117% means a video earned less than nothing, and −231% means it did that twice over. Whatever this column is, it is not "revenue fell by X%".

The values cluster hard, too: 109 of the 123 sit within 15 points of −200%. That is the shape of an arithmetic artefact, not of a market.

The sibling endpoints get it right

My Actor does not compute this number. It reads the upstream field revenue_growth_rate and rounds it to two decimals — one line of JavaScript, no arithmetic of my own:

revenueGrowthPercent: round2(num(r.revenue_growth_rate)),
Enter fullscreen mode Exit fullscreen mode

The same line appears in three of my TikTok Shop Actors, pointed at three different list endpoints of the same provider. So I pulled six recent runs from each of the other two and ran the identical check:

Endpoint Rows Range Positive Below −100%
Products 120 −70.31 … +77,603.46 100 0
Creators 120 −31.93 … +407.80 96 0
Videos 123 −230.99 … −117.53 0 123

Products and creators behave exactly as a growth rate should: mostly positive, occasionally down, never below −100% because that floor is a mathematical one. The video endpoint clears that floor on every single row.

That ruled out my own code, my rounding, and the provider as a whole. It is this one list.

Sort by the column and it flips sign entirely

If a field is simply broken, sorting by it is meaningless. sortBy: "revenueGrowth" is a documented option, so I spent $0.03 finding out. Same country, same period, ten rows:

Rank revenueGrowthPercent revenue views
1 +220,000 $1,101 13,075
2 +100,000 $501 16,801
3 +60,000 $10,234 1,117,571
4 +46,666.67 $1,406 50,288
5 +36,000 $3,620 287,936
10 +20,888.89 $142.35 5,548

So the column is not stuck, not clamped, and not always negative. It just never produced a sane number in either direction. Those suspiciously round figures — 220000, 100000, 60000, 36000, 30000 twice — are the tell: they come from dividing this period's revenue by a previous-period baseline of a few dollars. Rank 10 earned $142.35 and the implied baseline is 68 cents.

Median revenue in that growth-sorted top ten: $852.50, against $202,464 in the revenue-sorted list. Median views 18,441 against 10.2 million. Zero of the ten appear anywhere in the 23 revenue leaders. Sorting by growth doesn't surface tomorrow's winners, it surfaces whoever sold nothing last month and three units this month.

And it isn't a proxy for "new", either: the +220,000% row at the top was published on 26 November 2025, eleven months before the run.

The Actor's own README suggests pairing a growth sort with a revenue floor, so I tested that too — minRevenue: 10000:

revenueGrowthPercent revenue adsRoas
+60,000 $10,234 2.32
+20,000 $18,786 2.59
+20,000 $12,524 1.89
+4,032.26 $10,496 1.86

Better — these are real videos moving real money, median revenue $12,905, all ten running as ads. The ranking is now usable. The numbers are still fiction: three different revenue figures sharing an identical +20,000% is bucketing, not measurement. Use the list, drop the column.

What to compute instead

Two dated runs and your own subtraction. The revenue figure itself is solid — it's the derived field that isn't.

import requests, json, datetime as dt

TOKEN = "<APIFY_TOKEN>"
URL = ("https://api.apify.com/v2/acts/"
       "lergassy~tiktok-shop-videos-scraper/run-sync-get-dataset-items")

def pull(period):
    rows = requests.post(URL, params={"token": TOKEN}, json={
        "country": "US", "period": period, "sortBy": "revenue",
        "maxResultsPerSource": 100, "addAnalytics": False,
    }).json()
    return {r["videoId"]: r for r in rows if r.get("type") == "video"}

last7 = pull("last7Day")
last30 = pull("last30Day")

for vid, now in last7.items():
    then = last30.get(vid)
    if not then or now["revenue"] <= 0:
        continue
    prior23 = then["revenue"] - now["revenue"]          # days 8–30
    if prior23 <= 0:
        continue
    weekly_prior = prior23 / 23 * 7
    lift = (now["revenue"] - weekly_prior) / weekly_prior * 100
    print(f'{now["creatorUsername"]:<24} {lift:+8.1f}%  ${now["revenue"]:,.0f}')
Enter fullscreen mode Exit fullscreen mode

Three things worth copying out of that snippet. type == "video" — the last row of every run is a free notice row carrying the run's own accounting (Run summary: 10 row(s) delivered, 0 free; charged video ×10 = $0.03), and left in a dataframe it becomes a video with no revenue. videoId as the join key — the URL embeds the creator handle and handles get renamed. And guard the denominator yourself, because a near-zero baseline is exactly what produced +220,000% upstream; decide whether you want those rows or a floor, and make that decision in code where you can see it.

The Actor is TikTok Shop Videos Scraper — shoppable videos with revenue, views, ad ROAS and the AI flag, by keyword, category, product link, creator or shop, at $0.003 per row.

Two more fields to read carefully

adRevenueSharePercent stops at 80. Across the 123 rows the maximum was exactly 80, and 77 of 123 sat at 79.9 or above. I don't know whether that's a real attribution cap or a display ceiling, so I pass it through and treat anything ≥ 79.9 as "mostly paid" rather than as a measurement.

publishedAt routinely predates the window. 15 of the 23 videos in a last 30 days revenue list were published before that window opened; the oldest was from 10 January 2026. The period filters the revenue, not the upload date — so "newest sellers" is a publishedAt filter you apply, not a period you request.

When this is the wrong tool

  • You want a growth metric you can trust out of the box. You want two dated pulls and your own delta.
  • You want organic creative research. All 123 top-revenue rows were isAd: true. The top of TikTok Shop by revenue is a paid-media leaderboard; if you need organic winners, sort by views and filter isAd yourself.
  • You want your own shop's numbers. This is public marketplace data. TikTok's Seller Center is the system of record for your own GMV, and it will disagree with this.
  • You want unit economics per row. unitsSold, gpm and adCpa arrive only with the analytics option, which costs an order of magnitude more per row because each one is a separate paid data call.

If you keep one thing: before a scraped field reaches a chart, check its range against the bound the metric cannot cross. Percent declines stop at −100. Shares stop at 100. Conversion rates stop at 1. My 123 rows were all plausible-looking three-digit numbers with a minus sign, and they would have sat in a dashboard for months looking like a collapsing market.

Top comments (1)

Collapse
 
stae_him_ae35092536b300a4 profile image
STAE HIM •

The videoId join is the right choice. I would also keep each pull's window end and collected_at: last7Day and last30Day fetched at different moments aren't perfectly aligned. Since both are top-N lists, a missing ID should stay unknown rather than become zero revenue.