TikTok Creative Center Scraper — top ads by market & period
Pricing
from $0.40 / 1,000 ad rows
TikTok Creative Center Scraper — top ads by market & period
Scrape TikTok Creative Center top ads into clean JSON: CTR, likes, video, industry, landing page. Batch jobs, honest run summaries.
Pricing
from $0.40 / 1,000 ad rows
Rating
0.0
(0)
Developer
T2 Solutions
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
3 days ago
Last modified
Categories
Share
TikTok Creative Center Top Ads — market & period fetch
Fetch the top-performing TikTok ads for one filter set (country × period × sort,
optionally industry / objective / language) as clean JSON: one row per ad with title,
brand, CTR, likes, video id & duration, industry/objective labels, and — with
includeDetail — landing page, shown countries, engagement counts and creative
synopsis. The ads-intel cluster's TikTok leg: the same honesty contract, batch input
and agent-facing run summary our Google Flights fetcher ships, aimed at Creative
Center top ads.
No headless browser. No login. No proxies. One paced plain-HTTP signed GET per request, exactly like the Creative Center page itself — because that is what works: the top-ads data is served by a documented-by-reference JSON endpoint, and this actor signs it the way the page does, then maps it through a test-pinned, stable-column contract (143 tests, offline).
Status: live on the Apify Store since 2026-10-25 — pay-per-event pricing active (
result@ $0.0004 per ad row; start event at Apify's $0.00005 default).
Why use this actor
- 🎯 Real top-ad rows, not screenshots — 32 stable columns per ad, one ad per row, nullable exactly where TikTok publishes nothing. No derived numbers, ever.
- 🌍 One input, one market watchlist —
"jobs": [ … ]batches a whole country × period × sort list in a single run under one fail-safe deadline. - 📚 Depth where upstream offers it —
exhaust: truerotates all seven sort orders over your filter set (the only depth the API serves — see the honest table below) and dedupes byadId, so you get strictly more distinct ads than one sort. - 🧾 It reports its own honesty — every run ends with exactly one
RUN_SUMMARYrow (complete/partial/timed_out/zero_match), so an empty result set never masquerades as a success and a depth shortfall is stated, not hidden. - 🤖 Agent-ready — deterministic rows (only
fetchedAtmoves), batch input, and an MCP section below: an agent can drive it unattended and read the summary row as ground truth. - 🛡️ Fail-safe deadline — never hangs past a wall you set; partial results are flushed and honestly labeled instead of vanishing.
Input
| Field | Type | Default | Description |
|---|---|---|---|
jobs | array of objects (required) | — | 1–50 top-ads jobs (see below). All topAds type in v1. |
includeDetail | boolean | false | Enrich each ad with the detail endpoint (landing page, shown countries, comment/share counts, objectives, keywords, creative synopsis). One paced GET per distinct ad. Must be a real boolean — a non-boolean (e.g. the string "false") aborts the run with a recorded input error, never coerced. |
failSafeDeadlineMinutes | integer | 55 | Hard wall for the whole run (0–59). On expiry running stops, fetched rows are kept, and RUN_SUMMARY records timed_out. Values > 59 are clamped. |
Job object
| Field | Type | Default | Description |
|---|---|---|---|
type | string enum | "topAds" | v1 supports "topAds" only; anything else fails that job with a recorded reason. |
label | string | job-N | Your name for the job; echoed as jobLabel on every row. |
country | string | "US" | ISO-3166 alpha-2, validated against the live Creative Center filters vocabulary (28 countries observed 2026-10-25). An unknown code fails that job with a recorded reason — never a silent empty result. |
period | integer enum | 30 | Upstream day window: 7, 30 or 180. |
sortBy | string enum | "for_you" | for_you · likes · ctr · impressions · watch_2s_rate · watch_6s_rate · conversion_rate. Mapped to the upstream order_by values (like, impression, play_2s_rate, play_6s_rate, cvr). |
industry | string id | unset | industry id from the live filters vocabulary, e.g. label_23125000000 (258 observed 2026-10-25). Unset = all industries. |
objective | string id | unset | objective id from the live filters (8 observed 2026-10-25). |
language | string id | unset | ad_language id from the live filters (16 observed 2026-10-25). |
exhaust | boolean | true when maxAds > 20, else false | Rotate all seven sort orders over this filter set to approach upstream total_count — the only depth mechanism upstream serves. Any shortfall is reported in depthCappedJobs. |
maxAds | integer | 100 | Per-job cap, clamped to 1–5000. Actor hard cap: 5,000 rows per run across all jobs (50 jobs max). |
There are deliberately no proxy, token, credential or keyword fields: plain-HTTP
no-login access is a hard constraint (the signed headers are an implementation detail,
never input), and keyword search is proven dead logged-out (table below). limit is
internal — upstream rejects any value other than 20 with code:40000.
Example input — a broad exhaust sweep plus one narrow market check:
{"jobs": [{ "type": "topAds", "label": "us-30d-likes", "country": "US", "period": 30,"sortBy": "likes", "maxAds": 100 },{ "type": "topAds", "label": "de-7d-ctr", "country": "DE", "period": 7,"sortBy": "ctr", "industry": "label_23125000000", "exhaust": true }],"includeDetail": false,"failSafeDeadlineMinutes": 55}
What you get
One ad per row, then exactly one RUN_SUMMARY final row. 32 columns, always
present on every row (explicit null where TikTok publishes nothing) — pinned by
fetcher.ROW_COLUMNS and asserted by tests.
| Column | Nullable | Notes |
|---|---|---|
adId | no | Upstream material id — the dedupe key, stable across runs. |
jobLabel | no | Which input job produced this row. |
country / periodDays / sortBy | no | Request context echoed for multi-job runs. |
adTitle | yes | TikTok's ad title/caption; null when absent. |
brandName | yes | Null when TikTok shows none (the 2026-10-25 probe sample was "" → null). |
upstreamCost | yes | Raw upstream cost field — undocumented, shipped uninterpreted, never money (e2e value: 2). |
ctr | yes | Click-through ratio exactly as published. |
likeCount | no | Like count as published (e2e sample: 663755). |
isSearch | yes | Upstream search-ad flag. |
industryKey / industryLabel | no / yes | Key as returned; label resolved via the live filters vocabulary (e2e sample: Games & Utility Software), null when it doesn't resolve. |
objectiveKey / objectiveLabel | no / yes | Same resolve convention (e2e sample: Video Views). |
videoId | yes | From video_info.vid. |
videoDurationSeconds | yes | From video_info.duration. |
videoCoverUrl | yes | Signed CDN cover URL as returned — may expire (see honest table). |
landingPageUrl | yes | Detail enrichment; may contain TikTok's unresolved click macros (__CLICKID__, __CAMPAIGN_ID__) — shipped exactly as returned, never rewritten. |
shownCountryCodes (array) | yes | Detail enrichment: countries the ad ran in (probe sample 3–70 codes; 2026-10-25 e2e ad had 82). |
commentCount / shareCount | yes | Detail enrichment engagement counts (e2e sample: 3126 / 813). |
objectives (array) | yes | Detail enrichment objective objects exactly as returned. |
keywordList | yes | Detail enrichment; null when TikTok publishes none. |
patternLabel / patternLabelName | yes | Detail enrichment creative-pattern key, resolved via the filters vocabulary when available. |
highlightText / voiceOver | yes | Detail enrichment creative synopsis; upstream empty strings ship as-is, never padded. |
source / sourceKey | yes | Detail enrichment ad source. |
detailFetchedAt | yes | Set only when the detail endpoint answered for this row. |
fetchedAt | no | ISO timestamp of the fetch — the only non-deterministic field. |
Every detail*-class column above is null when includeDetail=false or when that
one detail call didn't answer (the reason lands in RUN_SUMMARY.detailUnavailable,
never an error loop). Upstream favorite is deliberately not shipped: it is a
logged-out UI state, not data.
RUN_SUMMARY — the honesty row
| Field | Meaning |
|---|---|
state | complete · partial (some jobs failed, input was dropped, or requested enrichment was incomplete) · timed_out (deadline wall) · zero_match (every job fetched fine but upstream matched nothing). |
rowsRequested / rowsFetched | Capped demand vs what was delivered. |
jobsTotal / jobsOk / jobsFailed | Per-job counts; input jobs never launched get a not_run entry. |
partialReason | null unless partial/timed_out. Contract values deadline · api_refused (non-zero upstream code, e.g. 40101) · rate_limited (429/403/silent-empty block signal). Amended 2026-10-25 (Stage-2 authority, code-review D1): input- and abort-level conditions record their honest reason instead of being forced into a refusal value — job_cap · row_cap · input_invalid · job_failure · timeout · http_error · empty · unknown · jobs_missing · no_valid_jobs · fatal_input. |
depthCappedJobs | [{label, available, delivered}] — jobs where upstream served no further rows: we delivered all rows available through sort rotation, not all rows that exist. |
detailUnavailable | [{label, reason}] — jobs whose detail calls didn't answer (rate_limited · api_refused · http_error · timeout · empty · deadline). Rows still delivered. |
jobResults | Per-job outcome: rows, plus duplicateAds (cross-sort dupes), skippedAds, stopped (max_ads / deadline). |
inputNotes | Dropped/invalid input lines (e.g. jobs beyond the 50-job cap), or null. |
runStartedAt / runFinishedAt | UTC wall-clock bounds of the run. |
complete shape (field-for-field what main.py emits; counts illustrative of a
2-job run — one that hit its own cap, one that upstream capped short):
{"recordType": "RUN_SUMMARY","state": "complete","rowsRequested": 120,"rowsFetched": 120,"jobsTotal": 2,"jobsOk": 2,"jobsFailed": 0,"partialReason": null,"depthCappedJobs": [{ "label": "gb-7d-for-you", "available": 26, "delivered": 20 }],"detailUnavailable": null,"jobResults": [{ "label": "us-30d-exhaust", "status": "ok", "rows": 100,"duplicateAds": 24, "stopped": "max_ads" },{ "label": "gb-7d-for-you", "status": "ok", "rows": 20 }],"inputNotes": null,"runStartedAt": "2026-10-25T11:22:35Z","runFinishedAt": "2026-10-25T11:23:31Z"}
Note the second job: 20 rows delivered against an upstream total_count of 26 — the
actor states the shortfall instead of pretending it fetched a full catalog.
zero_match shape (a filter set upstream serves empty — a normal, honest outcome;
the run's result dataset contains only this row):
{"recordType": "RUN_SUMMARY","state": "zero_match","rowsRequested": 100,"rowsFetched": 0,"jobsTotal": 1,"jobsOk": 1,"jobsFailed": 0,"partialReason": null,"depthCappedJobs": null,"detailUnavailable": null,"jobResults": [{ "label": "us-30d-likes", "status": "ok", "rows": 0 }],"inputNotes": null,"runStartedAt": "2026-10-25T11:24:02Z","runFinishedAt": "2026-10-25T11:24:14Z"}
This exact shape was observed live on 2026-10-25 (US/30d + industry=label_23125000000
→ 0 materials, total_count 0, probe ok: true — _bmad-output/e2e-live-2026-10-25.json).
Charging matches this row: you pay per ad row (result event); the RUN_SUMMARY
row is never charged, and a zero-match run charges zero.
What we don't have (and what upstream limits us to)
Every row below is evidence-dated 2026-10-25 from this repo's probe/spike files. We would rather write them here than let you discover them mid-run.
| We don't have | The honest reason |
|---|---|
| Keyword search over top ads — not offered | Proven dead logged-out: keyword=sneakers, keyword=a and a nonsense string all returned code:0, materials:0 for the same filter set that returns 20 ads without the param; query= is accepted but ignored (returns the unfiltered set). Cut from v1 on that evidence — there is no input field for it. Evidence: _bmad-output/spike-pacing-depth-2026-10-25.json, spike-depth-followup-2026-10-25.json, architecture.md finding 5. |
| Pagination past page 1 — one page per filter set | page≥2 returns a silent empty success (code:0, 0 materials, total_count:0) while page 1's has_more:True lies — the exact trap RUN_SUMMARY exists to catch. limit is fixed at 20 (limit=50/100 → code:40000). We never request page ≥ 2; depth comes only from exhaust sort rotation (7 sorts × 20 rows, deduped), and any shortfall ships as depthCappedJobs with upstream available vs our delivered. Evidence: spike-pacing-depth-2026-10-25.json, spike-depth-followup-2026-10-25.json. |
| Exact spend, CPM or conversion numbers | TikTok publishes tiers and ratios only. The raw cost field has undocumented semantics (our e2e saw 2) — shipped uninterpreted as upstreamCost, never labeled as money. We do not estimate spend. |
| EU Commercial Content Library | library.tiktok.com (the DSA-mandated EU/EEA/UK surface with reach/targeting breakdowns) is not in v1. The 2-call X-CCL-STR handshake exists in public references but is unproven from our line — a bounded-probe v2 candidate, not a promise. |
upstreamCost is not money | Raw undocumented upstream cost field (our e2e saw 2), shipped uninterpreted and never labeled as spend. See also the spend row above. |
| Stable video cover URLs | videoCoverUrl is a signed CDN URL as returned — it may expire. Copy anything you need to keep; we re-fetch on each run. |
| Run history / diffs | We hold no history: re-run the query (Apify schedules cover alerts) and diff adIds yourself — identical input yields identical rows apart from fetchedAt. |
Use with AI agents (MCP)
Every Apify actor is callable by AI agents — this one is built to behave when they drive it:
-
Apify MCP server — add
https://mcp.apify.comas an MCP server in your agent (Claude, Cursor, …), authenticate with an Apify API token, and this actor appears as a tool whose input is the plain JSON schema above. -
REST — one call starts a run, one call reads the results:
curl -X POST "https://api.apify.com/v2/acts/YOUR~tiktok-creative-center-ads-fetch/runs?token=YOUR_TOKEN" \-H "Content-Type: application/json" \-d '{"jobs":[{"label":"us-30d","country":"US","period":30,"sortBy":"likes","maxAds":100}]}'# results: GET /v2/acts/YOUR~tiktok-creative-center-ads-fetch/runs/{runId}/dataset/items?token=YOUR_TOKEN -
Batch by design —
"jobs": [ {...}, {...} ]resolves a whole watchlist (market × period × sort) in one run, which is how agents usually want to use it: one run per watchlist, not one run per filter set. -
Safe to hand over — the actor is written to run under Apify's
LIMITED_PERMISSIONS: a run can only do what a run needs (its own dataset and key-value store, plus the Creative Center endpoints it calls), nothing else in your account. The permission level itself is set on the actor in Apify Console at publish time (human step). -
The
RUN_SUMMARYrow is the agent's ground truth. Never infer success from an empty dataset — read the last row:state: complete→ all jobs fetched everything upstream served (shortfalls, if any, are itemized indepthCappedJobs).state: partial→ readpartialReason(deadline·api_refused·rate_limited·job_cap·row_cap·input_invalid·job_failure·timeout·http_error·empty·unknown, plus the abort valuesjobs_missing/no_valid_jobs/fatal_input) and per-jobreasons.state: timed_out→ the fail-safe wall was hit; delivered rows are kept and remaining jobs arenot_run.state: zero_match→ filters matched nothing upstream; treat as a valid empty answer, not an error and not a retry loop.
-
PPE charging model — one
resultevent per ad row delivered. Zero rows = zero charges;RUN_SUMMARYis never charged;includeDetailadds no extra event (you pay per ad, not per request). An agent can therefore retry an empty run safely.
Migrating from another TikTok ads scraper?
An honest comparison — including where another tool may still be right. (Figures are
what the Store pages showed in our 2026-10-25 recon, RECON-TIKTOK-ADS.json; verify
live stats yourself.)
| If you're on | What changes |
|---|---|
| codebyte/tiktok-creative-center-top-ads (volume leader) | 69,440 runs/30d, 0% failed, unrated (0 reviews), $0.0025/ad — a working, hugely popular actor: there is no reliability grievance to poach here, and we won't pretend otherwise. What changes: ~6× cheaper per ad, jobs[] batch input, the RUN_SUMMARY honesty contract (including depthCappedJobs, so you know when you got all upstream had rather than a silent page-1), and exhaust sort rotation with documented dedupe. |
| lexis-solutions/tiktok-top-ads-scraper | $0.00399/ad, 5.00★ (1 review), 1,676 runs/30d, ~1.9% failed — the top-priced named ad actor. Same per-ad unit here at roughly one-tenth the price. |
| data_xplorer/tiktok-ads-library-fast | $0.0015/result, 137 users/30d, ~2.7% failed — ad-library breadth (other platforms' libraries). This actor is TikTok Creative Center-specific: filters, sort rotation, detail enrichment. |
| doliz/tiktok-creative-center-scraper | $0.002 per fetch, 5.00★ (1), 4,267 runs/30d — a direct Creative Center sibling priced per fetch; here the unit is the ad row, with a per-job maxAds cap so you know the ceiling before you run. |
| silva95gustavo/tiktok-ads-scraper | 2.56★ (2 reviews) at 12,536 runs/30d — the field's one weak listing. Field-wide, though, failure is not the wedge: the aggregate fail rate across 517,947 runs/30d is 0.79%, so we compete on the agent contract and price, not on rivals' outages. |
Not the right tool if: you need keyword search over ads, exact spend/CPM/conversion money, EU Commercial Content Library coverage, TikTok videos/users/hashtags/comments/Shop data, or historical backfill — we don't have them, and we won't fake them.
Pricing
Pay-per-event, live since 2026-10-25: one result event per ad row delivered at
$0.0004 per row, plus Apify's default start fee ($0.00005 per run). The
RUN_SUMMARY row is never charged and a zero-match run charges zero.
| Price per ad row | |
|---|---|
| This actor (live) | $0.0004 |
Field median (n=163 priced, RECON-TIKTOK-ADS.json 2026-10-25) | $0.002 |
Volume leader codebyte/tiktok-creative-center-top-ads | $0.0025 |
data_xplorer/tiktok-ads-library-fast | $0.0015 |
lexis-solutions/tiktok-top-ads-scraper | $0.00399 |
Cheapest ads listing seen in recon (jmlp/tiktok-creative-center-scraper, 29 runs/30d) | $0.00005 |
Why $0.0004 — the math, cost side first. This actor is compute-only: plain signed
HTTP (~481 ms median latency, 2026-10-25 spike), no proxy fleet, no browser, no
logins. One list GET returns 20 ads; a 100-row exhaust run is 1 filters GET + ~6
list GETs ≈ 15 s of fetch time at the 1.5 s + jitter pacing (≈ 30 s wall including
container start). includeDetail adds one paced GET per distinct ad (≈ +2.2 s each).
Apify's documented usage rates (2026 docs: 1 CU = 1024 MB × 1 h; $0.20/CU at the
default FREE/BRONZE tier; external transfer $0.20/GB; dataset writes $0.005/1,000,
key-value writes $0.05/1,000) put a 512 MB run at roughly:
| Run shape | Platform usage (estimate) |
|---|---|
| 100 rows, list-only, ~30 s wall | ≈ $0.0014 (compute $0.0008 + ~101 dataset writes $0.0005 + 2 kv writes $0.0001; ~1 MB transfer rounds away) |
100 rows, includeDetail=true, ~4.5 min wall | ≈ $0.008 (compute dominates: 270 s × 512 MB ≈ $0.0075, plus writes) |
| 20-row single-sort run, ~15 s wall | ≈ $0.0006 |
| zero-match / failed run | ≈ $0.0004–$0.001 — and it charges nothing |
Revenue side at $0.0004/row (Apify keeps 20%, so profit = 0.8 × revenue − usage;
margin below is profit as a share of the 80% net):
| Scenario | Gross | 80% share | − usage | Profit | Margin of net |
|---|---|---|---|---|---|
| Typical 100-row exhaust run (list-only) | $0.040 | $0.032 | $0.0014 | $0.0306 | ~96% |
Same run with includeDetail | $0.040 | $0.032 | $0.008 | $0.024 | ~75% |
| 20-row single-sort run | $0.008 | $0.0064 | $0.0006 | $0.0058 | ~91% |
- Break-even: ≈ 2 rows (fixed start ≈ $0.0004 — container + filters GET +
storage writes — against $0.00032 net per row). With
includeDetailthe per-row cost is ~$0.00006 higher but the break-even still lands at ≈ 2 rows. - MONEY.md's ≥50% margin guardrail clears from ≈ 3 rows (list-only) / ≈ 5
rows (
includeDetail) upward, and is comfortably exceeded at any typical run size. - The one loss case: a run that delivers zero rows (zero-match or a refusal) costs us the run's platform usage (≈ $0.0004–$0.001) and bills nothing. Bounded by the fail-safe deadline, and it is the price of never charging a buyer for an empty answer.
- Caveat (Apify docs): users on the free plan generate usage but no revenue — they are excluded from the profit formula above, so small free-plan runs are pure cost to us.
- At field median ($0.002/ad) the same 100-row run would gross $0.20 — we are pricing ~5× under the median on purpose: this is a volume/agent-traffic play on a plain-HTTP cost structure, mirroring the undercut our Google Flights actor runs at ($0.0002/row, live since 2026-10-25).
Limitations & house patterns
- Plain-HTTP signed access only. md5
anonymous-user-id/timestamp/user-signheaders (fresh single-use set per request) againstcreative_radar_api/v1/top_ads/*. No proxy, no login, no browser, no TLS impersonation — and that is a hard constraint, not a knob: if TikTok starts requiring proxies, captchas or logged-in sessions to serve this data, this actor stops rather than growing a proxy fleet (the exact clause that reverted our Meta Ad Library actor on 2026-10-25). - It's a private API. Response shapes are pinned in the test suite; drift fails
tests loudly, and runs degrade to honest
partial/timed_out/zero_matchstates — never silent truncation. - Pacing: ≥1.5 s + 0.5 s jitter between requests (54/54 requests clean at 1.5–2.0 s, 2026-10-25 spike; median latency 481 ms), with 2 bounded retries (3 s / 6 s backoff, fresh signature each attempt). Escalation stops at retries — no browser, no proxy, no login.
- Fail-safe deadline: default 55 min, clamped to a 59-min platform-safe ceiling;
on expiry the run keeps fetched rows and records
timed_outhonestly. - Caps: 50 jobs/run · 5,000 rows/run ·
maxAds1–5000/job · internallimit=20. Excess input is recorded ininputNotes/job_cap, never silently dropped. - Counts-only telemetry (pinned by tests):
requests_total,retries_total,api_refusals{code},empty_body_signals,detail_calls,detail_failures,sets_requested,sets_exhausted,depth_capped. No URLs, no ad content, no keywords — counts and flags only. - Deterministic output: the same query twice returns identical rows apart from
fetchedAt, so agents can diff byadId.
FAQ
How fresh are the ads? As fresh as the run: one signed GET per list request at run time, straight from TikTok's own Creative Center. We hold no cache and no history.
Why didn't I get 143 rows for US/30d? Because upstream serves one page per sort
(20 rows) and no further pages — see the honest table. Set exhaust: true and raise
maxAds to rotate all seven sorts; depthCappedJobs then tells you exactly what was
available upstream vs what was delivered.
Can I search by keyword? No — proven dead logged-out on 2026-10-25 (keyword= empty
for every variant, query= ignored). Use industry / objective / language ids
instead; they are validated against the live vocabulary.
includeDetail=true but landingPageUrl is null. The detail call didn't answer for
that ad. Rows still ship; RUN_SUMMARY.detailUnavailable records the label and reason.
A job failed but others succeeded. Expected: one failing job never aborts the others.
Read jobResults[].reason and partialReason — you'll see the recorded cause, not a
silent gap.
Is upstreamCost what the advertiser paid? Unknown and undocumented — we ship the
raw field and never interpret it. Do not treat it as money.
Development (this repo)
python -m pytest -q # 143 tests — 94 fetcher + 49 wiring, offline (fake HTTP seam)python test_fetcher.py # column/param/signing/pacing contractpython test_main_wiring.py # run orchestration + RUN_SUMMARY states with a fake SDKpython -m main # local run with INPUT.json in cwdpython e2e_live_check.py # bounded live e2e (budget ≤10 signed requests; 2026-10-25 run: 9, PASS)
Design/parity docs: _bmad-output/architecture.md (spike findings, failure-mode
table, charging design), _bmad-output/build-log-2026-10-25.md (interpretation log +
live e2e results), spec in _bmad-output/specs/spec-tiktok-creative-center-scraper/
(SPEC.md + input-output.md). Field recon: RECON-TIKTOK-ADS.json (2026-10-25).
Related actors
- Google Ads Transparency Scraper — the ad-intel cluster's hero: search competitor ads by keyword, domain or advertiser id across Google's transparency surface.
- Google Flights Price Fetcher — the sibling actor whose README, input schema and
RUN_SUMMARYcontract this actor mirrors.