TikTok Creative Center Scraper — top ads by market & period avatar

TikTok Creative Center Scraper — top ads by market & period

Pricing

from $0.40 / 1,000 ad rows

Go to Apify Store
TikTok Creative Center Scraper — top ads by market & period

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

T2 Solutions

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

3 days ago

Last modified

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: true rotates all seven sort orders over your filter set (the only depth the API serves — see the honest table below) and dedupes by adId, so you get strictly more distinct ads than one sort.
  • 🧾 It reports its own honesty — every run ends with exactly one RUN_SUMMARY row (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 fetchedAt moves), 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

FieldTypeDefaultDescription
jobsarray of objects (required)—1–50 top-ads jobs (see below). All topAds type in v1.
includeDetailbooleanfalseEnrich 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.
failSafeDeadlineMinutesinteger55Hard 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

FieldTypeDefaultDescription
typestring enum"topAds"v1 supports "topAds" only; anything else fails that job with a recorded reason.
labelstringjob-NYour name for the job; echoed as jobLabel on every row.
countrystring"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.
periodinteger enum30Upstream day window: 7, 30 or 180.
sortBystring 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).
industrystring idunsetindustry id from the live filters vocabulary, e.g. label_23125000000 (258 observed 2026-10-25). Unset = all industries.
objectivestring idunsetobjective id from the live filters (8 observed 2026-10-25).
languagestring idunsetad_language id from the live filters (16 observed 2026-10-25).
exhaustbooleantrue when maxAds > 20, else falseRotate 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.
maxAdsinteger100Per-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.

ColumnNullableNotes
adIdnoUpstream material id — the dedupe key, stable across runs.
jobLabelnoWhich input job produced this row.
country / periodDays / sortBynoRequest context echoed for multi-job runs.
adTitleyesTikTok's ad title/caption; null when absent.
brandNameyesNull when TikTok shows none (the 2026-10-25 probe sample was "" → null).
upstreamCostyesRaw upstream cost field — undocumented, shipped uninterpreted, never money (e2e value: 2).
ctryesClick-through ratio exactly as published.
likeCountnoLike count as published (e2e sample: 663755).
isSearchyesUpstream search-ad flag.
industryKey / industryLabelno / yesKey as returned; label resolved via the live filters vocabulary (e2e sample: Games & Utility Software), null when it doesn't resolve.
objectiveKey / objectiveLabelno / yesSame resolve convention (e2e sample: Video Views).
videoIdyesFrom video_info.vid.
videoDurationSecondsyesFrom video_info.duration.
videoCoverUrlyesSigned CDN cover URL as returned — may expire (see honest table).
landingPageUrlyesDetail enrichment; may contain TikTok's unresolved click macros (__CLICKID__, __CAMPAIGN_ID__) — shipped exactly as returned, never rewritten.
shownCountryCodes (array)yesDetail enrichment: countries the ad ran in (probe sample 3–70 codes; 2026-10-25 e2e ad had 82).
commentCount / shareCountyesDetail enrichment engagement counts (e2e sample: 3126 / 813).
objectives (array)yesDetail enrichment objective objects exactly as returned.
keywordListyesDetail enrichment; null when TikTok publishes none.
patternLabel / patternLabelNameyesDetail enrichment creative-pattern key, resolved via the filters vocabulary when available.
highlightText / voiceOveryesDetail enrichment creative synopsis; upstream empty strings ship as-is, never padded.
source / sourceKeyyesDetail enrichment ad source.
detailFetchedAtyesSet only when the detail endpoint answered for this row.
fetchedAtnoISO 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

FieldMeaning
statecomplete · 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 / rowsFetchedCapped demand vs what was delivered.
jobsTotal / jobsOk / jobsFailedPer-job counts; input jobs never launched get a not_run entry.
partialReasonnull 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.
jobResultsPer-job outcome: rows, plus duplicateAds (cross-sort dupes), skippedAds, stopped (max_ads / deadline).
inputNotesDropped/invalid input lines (e.g. jobs beyond the 50-job cap), or null.
runStartedAt / runFinishedAtUTC 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 haveThe honest reason
Keyword search over top ads — not offeredProven 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 setpage≥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 numbersTikTok 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 Librarylibrary.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 moneyRaw undocumented upstream cost field (our e2e saw 2), shipped uninterpreted and never labeled as spend. See also the spend row above.
Stable video cover URLsvideoCoverUrl is a signed CDN URL as returned — it may expire. Copy anything you need to keep; we re-fetch on each run.
Run history / diffsWe 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.com as 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_SUMMARY row 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 in depthCappedJobs).
    • state: partial → read partialReason (deadline · api_refused · rate_limited · job_cap · row_cap · input_invalid · job_failure · timeout · http_error · empty · unknown, plus the abort values jobs_missing / no_valid_jobs / fatal_input) and per-job reasons.
    • state: timed_out → the fail-safe wall was hit; delivered rows are kept and remaining jobs are not_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 result event per ad row delivered. Zero rows = zero charges; RUN_SUMMARY is never charged; includeDetail adds 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 onWhat 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-scraper2.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 shapePlatform 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):

ScenarioGross80% share− usageProfitMargin 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 includeDetail the 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-sign headers (fresh single-use set per request) against creative_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_match states — 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_out honestly.
  • Caps: 50 jobs/run · 5,000 rows/run · maxAds 1–5000/job · internal limit=20. Excess input is recorded in inputNotes / 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 by adId.

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 contract
python test_main_wiring.py # run orchestration + RUN_SUMMARY states with a fake SDK
python -m main # local run with INPUT.json in cwd
python 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).

  • 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_SUMMARY contract this actor mirrors.