# TikTok Creative Center Scraper — top ads by market & period (`t2solutions/tiktok-creative-center-ads-fetch`) Actor

Scrape TikTok Creative Center top ads into clean JSON: CTR, likes, video, industry, landing page. Batch jobs, honest run summaries.

- **URL**: https://apify.com/t2solutions/tiktok-creative-center-ads-fetch.md
- **Developed by:** [T2 Solutions](https://apify.com/t2solutions) (community)
- **Categories:** Social media, Videos
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $0.40 / 1,000 ad rows

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## 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

| 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:

```json
{
  "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):

```json
{
  "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):

```json
{
  "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 `adId`s 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:

  ```bash
  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 `reason`s.
  - `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 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 `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)

```bash
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).

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

# Actor input Schema

## `jobs` (type: `array`):

One or more top-ads filter-set jobs (1-50). Each job fetches TikTok Creative Center top ads for one country x period x sort, optionally narrowed by industry / objective / language id. Every job shares one fail-safe deadline and one run-level RUN_SUMMARY row. v1 supports type "topAds" only - there is deliberately no keyword, proxy, token or credential field (plain-HTTP no-login access is a hard constraint).

## `includeDetail` (type: `boolean`):

Enrich each ad with the detail endpoint: landing page URL, shown countries, comment/share counts, objectives, keyword list, creative pattern and synopsis. One paced GET per distinct ad (cached per run). Detail columns stay null when false or when a call does not answer - the reason is recorded in RUN_SUMMARY.detailUnavailable. Must be a real boolean; a non-boolean value aborts with an input error rather than coercing. Default: false.

## `failSafeDeadlineMinutes` (type: `integer`):

Hard wall for the whole run, 0-59 minutes. On expiry running stops, rows fetched so far are kept, and RUN_SUMMARY records state=timed_out. Values above 59 are clamped (a fail-safe larger than the platform's ~60min kill is no fail-safe); 0 or negative means 'already reached' and is recorded honestly as timed_out. Default: 55.

## Actor input object example

```json
{
  "jobs": [
    {
      "type": "topAds",
      "label": "us-30d-likes",
      "country": "US",
      "period": 30,
      "sortBy": "likes",
      "maxAds": 100
    }
  ],
  "includeDetail": false,
  "failSafeDeadlineMinutes": 55
}
```

# Actor output Schema

## `ads` (type: `string`):

Dataset of TikTok Creative Center top ads (one row per ad: ids, title/brand, ctr/likes, video, industry/objective labels, optional detail enrichment) plus a final RUN_SUMMARY record.

## `summary` (type: `string`):

RUN_SUMMARY stored in the key-value store under key OUTPUT (state, row/job counts, partialReason, depthCappedJobs, detailUnavailable).

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "jobs": [
        {
            "type": "topAds",
            "label": "us-30d-likes",
            "country": "US",
            "period": 30,
            "sortBy": "likes",
            "maxAds": 100
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("t2solutions/tiktok-creative-center-ads-fetch").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = { "jobs": [{
            "type": "topAds",
            "label": "us-30d-likes",
            "country": "US",
            "period": 30,
            "sortBy": "likes",
            "maxAds": 100,
        }] }

# Run the Actor and wait for it to finish
run = client.actor("t2solutions/tiktok-creative-center-ads-fetch").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "jobs": [
    {
      "type": "topAds",
      "label": "us-30d-likes",
      "country": "US",
      "period": 30,
      "sortBy": "likes",
      "maxAds": 100
    }
  ]
}' |
apify call t2solutions/tiktok-creative-center-ads-fetch --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,t2solutions/tiktok-creative-center-ads-fetch"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/AE5tmDEPIirEC232t/builds/kRApgf9UICYOYL63z/openapi.json
