# TikTok Ad Creative Benchmark — CTR percentiles by industry (`titan_coder/tiktok-ad-creative-benchmark`) Actor

Turns the public TikTok Top Ads board into performance benchmarks: CTR distribution, video length, engagement and copy patterns per industry, objective and country. One row per benchmark, not another dump of ads.

- **URL**: https://apify.com/titan\_coder/tiktok-ad-creative-benchmark.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 Creative Benchmark — is your creative above or below market?

Every other TikTok ads Actor hands you a pile of ads. That answers "what is running", not the question
an advertiser actually has: **"my creative gets 1.2% CTR in Beauty in the US — is that good?"**

This Actor turns the public **TikTok Creative Center Top Ads** board into benchmarks. One row is one
benchmark — a country, a time window and an industry (and/or campaign objective) — with the **CTR
distribution**, video length, engagement and copy patterns of the ads inside it.

### What you get

One row per benchmark group:

| Field | Meaning |
|---|---|
| `countryCode`, `period`, `orderBy` | which slice of the board this benchmark is built from |
| `industryKey`, `objectiveKey` | what the group is (`*` for the dimension you did not group by) |
| `industryName`, `objectiveName` | the same, as human-readable names |
| `adCount` | how many distinct ads back it |
| `ctrAdCount`, `likesAdCount`, `costAdCount`, `durationAdCount` | the denominator of each metric — an ad missing a field is counted in `adCount` but not in that metric |
| `isTruncatedSample` | `true` if the slice hit the page cap while the source still reported more results (only possible when you opt in) |
| `isExploratory` | `true` when the group holds fewer than 10 ads: readable, but too thin to quote as a market benchmark |
| `industryLevel`, `taxonomySnapshotDate` | which granularity and which taxonomy snapshot produced the row |
| `ctrP25`, `ctrMedian`, `ctrP75`, `ctrP90`, `ctrMean` | the CTR distribution — where the middle of the market is, and where the top decile starts |
| `likesMedian`, `likesMean` | engagement |
| `costMedian` | the board's cost indicator |
| `durationMedianSec`, `durationP90Sec` | how long the winning videos actually are |
| `titleLengthMedian` | how long the ad copy is |
| `hashtagsPerAdMean`, `shareWithHashtags` | how much these advertisers lean on hashtags |
| `topHashtags` | the 5 most common hashtags in that group |
| `runId`, `scrapedAt` | run identity and UTC timestamp |

**Percentiles, not averages.** A single outlier drags a mean; the median and P75/P90 tell you where
the market sits and what "good" costs. That is the whole point of the product.

### How it works

1. **No third-party account needed.** Creative Center renders client-side and refuses requests made
   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 point it at your own remote browser (`cdpUrl`) instead;
   the backend is never switched automatically and is reported in the run's coverage. Either way this
   Actor only reads what the public board loads for an ordinary visitor.
2. You pick **countries** and **periods**. Every `country × period` pair is one slice of the board,
   swept to its full depth — up to **100 ads** (5 pages × 20), the source's own ceiling.
3. Ads are **de-duplicated by ad id** before they enter a group, so an ad on both the US and GB boards
   is counted once.
4. Ads are grouped, groups under `minAdsPerGroup` are dropped, and the rest are returned ranked by size.

### Input

```json
{
  "countryCodes": ["US", "GB", "DE"],
  "periods": [7, 30],
  "orderBy": "ctr",
  "groupBy": "industry",
  "industryLevel": "top",
  "minAdsPerGroup": 10,
  "maxBenchmarks": 300
}
```

- `groupBy` — `industry`, `objective`, or `industryAndObjective`. Country and period always split a
  group; this picks what else does. `industryAndObjective` is the sharpest cut but needs more ads per
  group to clear the minimum.
- `industryLevel` — TikTok's taxonomy has **258** detailed industries, which cuts a run into groups too
  small to mean anything. `top` rolls them up into the **21** parent categories and is the default.
  Measured 10.09.2026 on a live sweep of US/30d + GB/30d (140 distinct ads): `detailed` produced 79
  groups of which **7** cleared the minimum, `top` produced 32 groups of which **11** cleared it — with
  the largest holding 21 ads instead of 7. Use `detailed` only when you sweep many countries and periods.
- `minAdsPerGroup` — default **10**. At n=5 the 90th percentile is nearly the maximum and moves with
  every single ad; 10 is the smallest group we are willing to call a benchmark. Values of 5-9 are
  allowed but every row from such a group is flagged `isExploratory: true`. Below 5 is refused outright.
  Groups under the threshold are dropped **before** they are returned and **before** they are billed.
- `orderBy` — `ctr` and `impression` are deterministic and repeatable; `for_you` is personalised and
  its window rotates between runs. Benchmarks you intend to compare over time must use `ctr` or
  `impression`.

### Pricing

Pay per event:

- **`run-started`** — charged **once per run, and only after the board has actually answered with
  parsable data**. A blocked or empty run is not billed at all.
- **`slice-swept`** — charged per `country × period` slice that returned parsable data **and was used
  for benchmarks**. Each slice is a separate browser session against the board, and that is where the
  real cost sits. A slice that failed, or that was excluded for incomplete or truncated pagination, is
  **not** charged: excluding it is our quality policy, not something you should pay for.
- **`benchmark-returned`** — charged per row, **after** the row is written to the dataset. A run where
  no group clears the threshold returns nothing and is charged nothing beyond the run event.

**What happens if a run is interrupted.** Apify can migrate a run mid-flight. This Actor keeps a durable
four-state ledger in its key-value store: *intent → written → charge started → billed*. On restart a
billed row is skipped; a row whose charge was started but never confirmed is **not charged again** and is
counted in `chargeUncertainRows` instead; a written-but-uncharged row is charged without being written
again; a row with only an intent is written again, because a duplicate row costs you nothing while
billing for a row that never reached the dataset costs you money. Apify's pay-per-event call takes no
idempotency key, so exactly-once is not achievable and this Actor does not claim it — it makes the
residual uncertainty visible and small instead.

### Completeness: which slices are allowed to become a benchmark

A benchmark is scoped **inside** one `country × period` slice, so a neighbouring slice failing does not
corrupt it — the good slices are still returned and billed normally.

Two kinds of slice are kept out of the benchmarks:

- a slice whose **pagination broke partway** — excluded always;
- a slice that **hit the page cap while the source still reported more results** — excluded by default.
  Set `allowTruncatedSlices: true` to include it anyway; every row built from it is then flagged
  `isTruncatedSample: true`.

The reasoning is the same in both cases. Percentiles
computed over half a window look exactly as precise as percentiles over a full one, and you would have
no way to tell them apart. Such a slice is reported in the coverage record as `excluded` with the reason
`pagination_incomplete`, and its ads enter no group at all.

A run that fails outright delivers nothing and bills nothing beyond the run event.

### Honest limits

- **This benchmarks the top of the board, not all TikTok advertising.** The source ranks and caps at
  100 ads per slice. The numbers describe high-performing ads in that slice — which is exactly what
  you want to compare against, but it is not a census of every ad running.
- **The board's industry/objective URL filters are ignored by the source.** We verified this by
  measurement, so this Actor does not offer filters it cannot honour — it groups after collection
  instead, which gives the same answer honestly.
- **Ads with no industry or objective are grouped under `unknown`**, not silently discarded, so the
  numbers add up.
- **Industry and objective names come from a taxonomy snapshot** taken from the board's own filter
  endpoint on 10.09.2026. An unknown code is returned as-is with a `null` name rather than guessed at.
- **`period` is the board's window, not an ad's lifetime.** TikTok does not publish an ad's real
  first-appearance date and this Actor does not invent one.

### How much you get from one run

From the live sweep above (2 slices, 140 distinct ads), the defaults returned **11 benchmarks**, the
largest backed by 21 ads. Grouping by `objective` instead returned 9, the largest backed by 31 ads.
Benchmarks scale with slices swept: more countries and both time windows give both more groups and
more ads behind each one.

### Coverage record

Every run writes a `coverage` record to the key-value store: slices requested vs attempted, per-slice
status and reason, `adsSeen`, `groupsFound`, `groupsQualifying`, `groupsSkippedByThreshold`,
`groupsSkippedByCap`, `benchmarksDelivered`, `benchmarksBilled`, `runStartCharged`, `completeSlices`,
`excludedSlices`, `slicesBilled`, `deliveryBlocked`, `ledgerResumed`, `chargeUncertainRows`, `pushedUnbilledRows`,
`allowTruncatedSlices`, `groupBy`, `industryLevel`
and `minAdsPerGroup`. Those numbers are enough to reconcile every charge against every row.

Author: OmniCoder (https://t.me/OmniCoder)

# Actor input Schema

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

## `countryCodes` (type: `array`):

Two-letter country codes to sweep, e.g. US, GB, DE. Each country is a separate slice of the board. Up to 28 (the source's own list).

## `periods` (type: `array`):

Which time windows to sweep: 7, 30 and/or 180. Each period is a separate slice, so \[7,30] doubles the volume.

## `orderBy` (type: `string`):

How the board ranks ads. CTR and Reach are deterministic and repeatable; For You is personalised and its window rotates between runs.

## `maxPagesPerSlice` (type: `integer`):

1-5. The source hard-caps each slice at 100 ads (5 pages x 20), so 5 is the maximum useful value.

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

Optional. Receives a JSON summary of the run. HTTPS only.

## `groupBy` (type: `string`):

Every benchmark is always scoped to one country and one period. This picks what else splits it. 'Industry x objective' gives the sharpest comparison but needs more ads per group to clear the minimum.

## `minAdsPerGroup` (type: `integer`):

A group is returned only if it holds at least this many distinct ads. Default 10: at n=5 the 90th percentile is nearly the maximum and moves with every single ad. Values of 5-9 are allowed but every row from such a group is flagged isExploratory. Groups under the threshold are dropped before they are returned and before they are billed.

## `maxBenchmarks` (type: `integer`):

Hard stop for the run, applied after ranking by ad count. This is the number of rows you actually receive and pay for.

## `industryLevel` (type: `string`):

TikTok's taxonomy has 258 detailed industries, which splits a run into groups too small to be meaningful. 'Top-level' rolls them up into the 21 parent categories and is the default. Use 'detailed' only when you sweep many countries and periods.

## `allowTruncatedSlices` (type: `boolean`):

A slice that hit the page cap while the source still reported more results is a limited sample, not a full window. By default such a slice is excluded from the benchmarks. Turn this on to include it anyway: every row built from it is then flagged isTruncatedSample.

## Actor input object example

```json
{
  "countryCodes": [
    "US",
    "GB",
    "DE"
  ],
  "periods": [
    7,
    30
  ],
  "orderBy": "ctr",
  "maxPagesPerSlice": 5,
  "groupBy": "industry",
  "minAdsPerGroup": 10,
  "maxBenchmarks": 300,
  "industryLevel": "top",
  "allowTruncatedSlices": false
}
```

# Actor output Schema

## `benchmarks` (type: `string`):

One row per group: adCount with per-metric denominators, CTR percentiles P25/median/P75/P90, likes, cost, video length, copy length, hashtag usage, top hashtags, industryName/objectiveName, isExploratory and isTruncatedSample flags.

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

Per-slice status and reason (including slices excluded for incomplete or truncated pagination), adsSeen, groupsFound/Qualifying/SkippedByThreshold/SkippedByCap, benchmarksDelivered/Billed, runStartCharged, chargeUncertainRows.

# 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 = {
    "countryCodes": [
        "US",
        "GB",
        "DE"
    ],
    "periods": [
        7,
        30
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("titan_coder/tiktok-ad-creative-benchmark").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 = {
    "countryCodes": [
        "US",
        "GB",
        "DE",
    ],
    "periods": [
        7,
        30,
    ],
}

# Run the Actor and wait for it to finish
run = client.actor("titan_coder/tiktok-ad-creative-benchmark").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 '{
  "countryCodes": [
    "US",
    "GB",
    "DE"
  ],
  "periods": [
    7,
    30
  ]
}' |
apify call titan_coder/tiktok-ad-creative-benchmark --silent --output-dataset

```

## MCP server setup

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

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/wbyuv4qf7VAIMBcVO/builds/QCg7MedbejE3qHfdX/openapi.json
