# TikTok Ad Radar — New Creative Monitor (`titan_coder/tiktok-ad-radar`) Actor

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.

- **URL**: https://apify.com/titan\_coder/tiktok-ad-radar.md
- **Developed by:** [Radu Furtuna](https://apify.com/titan_coder) (community)
- **Categories:** Social media, Marketing
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per event

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?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## TikTok Ad Radar — New Creative Monitor

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

1. **No third-party account needed.** TikTok Creative Center renders its board client-side and its data
   endpoint answers `no permission` outside 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.
2. Each `watch` is a filter slice: `countryCode` (+ optional `period`, `industryId`, `objectiveId`,
   `adLanguage`). Its `watchId` owns an independent, durable history.
3. 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.
4. 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

```json
{
  "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_unknown` and 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-confirmed` is 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.claimJournalSize` reports the journal's size each run (best-effort; `null` if the queue's
  metadata could not be read, and the value lags by a few seconds because Apify's `totalRequestCount` is
  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` (or `impression`), which is deterministic — unlike
  the board's personalised `for_you` feed, which rotates its window between runs. If you deliberately pick
  `for_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 with `lease_busy`
  instead 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)

# Actor input Schema

## `monitorId` (type: `string`):

Name of this monitor's durable history (a-z, 0-9, dash; up to 40 chars). Keep it stable across scheduled runs — it is what makes 'new' mean new.

## `cdpUrl` (type: `string`):

Optional. Leave EMPTY to use the built-in stealth anti-detect browser — no third-party account or key needed. Provide your own remote browser endpoint (ws:// or wss://, e.g. a Bright Data Scraping Browser) only if you prefer to route through your own. The backend is never switched automatically and is reported in the run's coverage.

## `watches` (type: `array`):

1-10 objects. Required: watchId, countryCode. Optional: period (7 | 30 | 180, default 30), orderBy (ctr — default and deterministic | impression | for\_you — personalised, rotates), industryId, objectiveId, adLanguage, maxPages (1-5, default 5 = up to 100 ads). Example: \[{"watchId":"us-30d","countryCode":"US","period":30}]. Changing any filter of an existing watchId is rejected — use a new watchId instead, so history is never silently mixed.

## `notifyOn` (type: `string`):

new\_ads — post the webhook only when new paid ads were delivered; always — every run; never — do not call webhookUrl at all.

## `webhookUrl` (type: `string`):

Optional. Receives a JSON digest of delivered (paid) new ads. HTTPS only.

## Actor input object example

```json
{
  "monitorId": "my-tiktok-radar",
  "watches": [
    {
      "watchId": "us-30d",
      "countryCode": "US",
      "period": 30
    }
  ],
  "notifyOn": "new_ads"
}
```

# Actor output Schema

## `results` (type: `string`):

Every row this run produced. Key fields: watchId, adId, brand, title, ctr, countryCode, discoveredAt.

## `coverage` (type: `string`):

What this run actually covered and what it charged for: per-target status and reason, rows delivered and rows billed. Enough to reconcile every charge against every row.

## `digest` (type: `string`):

A short human-readable summary of what this run found, written every run.

# 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 = {
    "monitorId": "my-tiktok-radar",
    "watches": [
        {
            "watchId": "us-30d",
            "countryCode": "US",
            "period": 30
        }
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("titan_coder/tiktok-ad-radar").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 = {
    "monitorId": "my-tiktok-radar",
    "watches": [{
            "watchId": "us-30d",
            "countryCode": "US",
            "period": 30,
        }],
}

# Run the Actor and wait for it to finish
run = client.actor("titan_coder/tiktok-ad-radar").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 '{
  "monitorId": "my-tiktok-radar",
  "watches": [
    {
      "watchId": "us-30d",
      "countryCode": "US",
      "period": 30
    }
  ]
}' |
apify call titan_coder/tiktok-ad-radar --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,titan_coder/tiktok-ad-radar"
        }
    }
}
```

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/7JTnRkR871oCHYgeh/builds/AXr51X0xIw9C73dsH/openapi.json
