TikTok Ad Radar — New Creative Monitor
Pricing
Pay per event
TikTok Ad Radar — New Creative Monitor
Every TikTok ads actor is a one-shot dump: 100 creatives today, the same 100 tomorrow, billed again. This one keeps a durable memory of the Creative Center board you watch and charges only for creatives it has never shown you. Never twice for the same ad, never for a blocked check.
Pricing
Pay per event
Rating
0.0
(0)
Developer
Radu Furtuna
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
a day ago
Last modified
Categories
Share
Watch a slice of the TikTok Creative Center Top Ads board (country + period, optionally industry, objective and ad language) and get told when a creative shows up in it that you have not been shown before. You pay for the check and for each newly observed ad — never twice for the same ad, and never for a failed check.
What "new" means here, precisely: newly observed in this monitored Top Ads window. The monitor uses
a deterministic ranking (ctr by default, or impression) rather than the board's personalised
"For You" feed, so the tracked window is stable between runs. An older creative can still enter the window
when its ranking rises, and it is reported then. TikTok does not publish an ad's real first-appearance
date, and this actor does not claim one.
This is not another bulk scraper. The TikTok ads actors reviewed while building this one are all one-shot pulls ("extract up to N ads per run"): they do not remember what they already showed you, so on a schedule you re-download — and re-pay for — the same creatives every single run. This one keeps durable state per watch.
How it works
- No third-party account needed. TikTok Creative Center renders its board client-side and its data
endpoint answers
no permissionoutside a real browser session, so a real anti-detect browser is required — this Actor uses a built-in stealth browser by default. You can still point it at your own remote browser (cdpUrl, e.g. a Bright Data Scraping Browser) if you prefer; the backend is never switched automatically and is reported in the run's coverage. Either way, this Actor only reads what the page loads for an ordinary visitor. - Each
watchis a filter slice:countryCode(+ optionalperiod,industryId,objectiveId,adLanguage). ItswatchIdowns an independent, durable history. - Every run opens the board for that slice, collects up to 100 ads (the source's own hard ceiling: 5 pages × 20), and diffs them against the ads already seen for that watch.
- Ads not previously observed for that watch go to the dataset and are billed once each. A quiet check — nothing newly observed — costs only the check event.
The first run of a new watchId is a free baseline: it records what is currently on the board and
bills no new-ad-detected events at all. "New" starts from your second run.
Input
{"monitorId": "my-tiktok-radar","watches": [{ "watchId": "us-30d", "countryCode": "US", "period": 30 },{ "watchId": "gb-beauty-7d", "countryCode": "GB", "period": 7, "industryId": "label_25000000000" }],"notifyOn": "new_ads","webhookUrl": "https://example.com/webhook"}
| Field | Meaning |
|---|---|
monitorId | Name of the durable history. Keep it stable across scheduled runs. |
cdpUrl | Optional. Leave empty to use the built-in stealth browser. If set, stored as a secret and never logged. |
watches[].watchId | Independent history key for one filter slice. |
watches[].countryCode | Two-letter country, e.g. US. Required. |
watches[].period | 7, 30 or 180 days. Default 30. |
watches[].industryId / objectiveId / adLanguage | Optional narrowing filters. |
watches[].orderBy | ctr (default), impression (Reach), or for_you. Prefer the first two: they are deterministic. |
watches[].maxPages | 1–5. Default 5 (up to 100 ads — the source's ceiling). |
notifyOn | new_ads (default), always, never. |
webhookUrl | Optional HTTPS endpoint receiving a JSON digest of the paid new ads. |
Add more watches later under the same monitorId — each keeps its own history. Changing a filter of an
existing watchId is rejected (watch_config_mismatch, before any network request): a different filter
is a different board, and silently mixing the two histories would hide real results from you. Use a new
watchId instead.
Output
One dataset row per new ad:
watchId, adId, title, brand, ctr, cost, likes, industryKey, objectiveKey, videoId,
durationSec, coverUrl, videoUrl, countryCode, period, orderBy, monitorId, runId,
discoveredAt, eventId, billed.
The key-value store also holds coverage (requested / attempted / delivered / billed) and digest.
Billing
Two events:
target-check-confirmed— once per successful check of a watch, including the free baseline. It is charged only after the board actually answered with a valid, recognised payload; a timeout, a block, or a changed page structure charges nothing. This event covers the real cost of driving a browser.new-ad-detected— once per ad newly observed in that watch's window since its previous check.
Delivery guarantee for new-ad-detected: at-most-once (not exactly-once)
You will never be charged twice for the same ad. The right to write an ad row and to charge for it is
granted by a single atomic primitive — one addRequest(uniqueKey) into a dedicated, named claim-journal
Request Queue (<prefix>-<monitorId>-claims). Exactly one run ever wins that key. Claim requests are
never deleted and never handled: the queue is a permanent journal of irreversible attempts, not a work
list.
Stated honestly, including the part that changed:
- It is not exactly-once, and a crash can now lose an ad. If a run wins the claim and then dies
before the row reaches the dataset (or before the charge completes), that ad is lost: it closes as
dataset_unknown/charge_unknownand is never re-delivered. Earlier builds replayed such an ad from a payload stored inside the claim; that replay has been removed on purpose, because it could not be distinguished from a second live run holding the same claim — and the only safe resolution of that ambiguity is to refuse. We deliberately prefer losing a delivery over double-charging you. target-check-confirmedis deliberately outside this gate. It is not deduplicated between runs: a repeated successful check is real work actually performed (a real browser session), not a duplicate.- Boundary of the guarantee: it holds for as long as the named claim-journal queue exists. Anyone with account access can delete or re-create that queue through the Apify Console/API; a fresh journal starts empty, and previously delivered ads could then be delivered and billed again. That is an inherent limit of any durable storage, not a defect of the protocol.
- Migration boundary: the guarantee applies from the build that introduced the claim gate onward.
Older builds of this actor must not keep running against the same
monitorId— they predate the journal and would not see the claims it holds. coverage.claimJournalSizereports the journal's size each run (best-effort;nullif the queue's metadata could not be read, and the value lags by a few seconds because Apify'stotalRequestCountis eventually consistent). Use it to watch growth, not to make decisions.- No storage rename here. Unlike the other actors in this wave, this one's storage names already fit Apify's 63-character limit exactly, so nothing was renamed: your existing history, checkpoints and dedup carry over untouched and there is no re-baseline.
Price vs. the alternatives
target-check-confirmed $0.010 · new-ad-detected $0.006.
Honest comparison for the same job — monitoring one US slice daily for a month, with ~5 newly observed creatives a day (30 checks, 150 new ads; a bulk scraper would re-download all 100 board ads on every one of those 30 runs):
| cost / month | keeps state? | tells you what's new? | |
|---|---|---|---|
| Bulk scraper at $3.00 / 1,000 | ≈ $9.00 | no | no |
| Bulk scraper at $1.50 / 1,000 | ≈ $4.50 | no | no |
| Bulk scraper at $0.49 / 1,000 | ≈ $1.47 | no | no |
| TikTok Ad Radar (30 checks + 150 new ads) | ≈ $1.20 | yes | yes |
| Cheapest bulk dumper at $0.01 / 1,000 | ≈ $0.03 | no | no |
Being straight about the last row: one bulk actor is cheaper in absolute dollars than anything short of free. What it gives you is 100 rows a day, all of them again every day, with no idea which ones you have already seen — you still have to build and host the diffing, the history and the alerting yourself. This actor sells that part, not the rows.
Limits, honestly
- The source caps a filter slice at 100 ads (5 pages × 20). This actor monitors the top-100 window of
your slice, not "every TikTok ad ever". If your run hits the ceiling with more pages still available, the
run is reported as partial with reason
window_truncated. - Ranking. By default the monitor sorts by
ctr(orimpression), which is deterministic — unlike the board's personalisedfor_youfeed, which rotates its window between runs. If you deliberately pickfor_you, expect ads to drop out and come back; they are reported again as present, but never billed a second time — a durable per-ad receipt, not the recent-IDs index, decides what has already been paid for. Either way, an older creative entering your window for the first time is reported (and billed) as newly observed: that is the honest meaning of the signal. - Only what the public board shows an ordinary visitor is read; no internal API is called directly.
- One run at a time per
monitorId(durable lease). A second concurrent run exits withlease_busyinstead of corrupting the history. Every read-modify-write of the monitor's state happens inside a short atomic lock, so a stalled run that wakes up late cannot overwrite state another run has already closed.
Author: OmniCoder (https://t.me/OmniCoder)