You want to know when a competitor starts a new Google campaign: a new search ad, a new banner, a new YouTube spot. Google shows all of this publicly in the Ads Transparency Center. The catch is that someone has to open it every few days, find the advertiser and scroll through hundreds of creatives to spot what changed.
This guide sets up a small n8n workflow that does it for you. Every morning it checks your competitors and posts only the ads that are new since yesterday to Slack. On quiet days it posts nothing and costs about a tenth of a cent.
The honest answer first: there's no official API
If you searched for "Google Ads Transparency Center API", here's what you'll find:
- There's no public API for the Transparency Center. The Google Ads API support team said so on the official forum: "there is no API endpoint available for the Google Ads Transparency Centre". The Google Ads API only covers your own accounts.
-
The BigQuery dataset is partial. Google publishes
bigquery-public-data.google_ads_transparency_center(tablescreative_statsandremoved_creative_stats). It's free to query within BigQuery's free tier and great for market-wide SQL. But it only covers ads served in the European Economic Area and Turkey. Ads shown only in the US, UK, Canada or Australia aren't in it. That's why this Stack Overflow question finds 3 creatives in BigQuery for an advertiser that shows about 200 on the website.
So if your competitors advertise in the EEA and you're comfortable with SQL, start with BigQuery. For anything else, including "tell me when something new shows up", you have to read the public website itself. That's what the rest of this post does.
What we'll build
Every day at 08:00
→ Set competitors and alert settings (the only node you edit)
→ Fetch new competitor ads (HTTP call to an Apify Actor, onlyNew = true)
→ Build Slack summary (one message, ad text + links)
→ Anything new to report? (no → stop, nothing posted)
→ Post new ads to Slack
↳ on error: Post failure notice to Slack
The scraping is done by Google Ads Transparency Scraper, an Actor on the Apify platform. Disclosure: we built it (CleanData Labs), and this tutorial is partly there to show it off. Other Actors and APIs read the Transparency Center too (SerpApi and SearchApi have endpoints for it, for example). The n8n side works the same with any of them as long as you can ask for "only new" results, or keep track of creative IDs yourself.
We use it here because of one feature: with onlyNew turned on, the Actor remembers which ad IDs it has already returned and skips them. You're only charged for ads you haven't seen before. So the daily check stays cheap and you don't need a database in n8n to compute the diff.
"No code" is mostly true. There's one Code node that formats the Slack message, but it's ready to use and you don't have to touch it.
You need: an n8n instance (self-hosted or n8n Cloud), a free Apify account and a Slack workspace.
Step 1: find your competitor's advertiser (the AR... ID)
Every advertiser account in the Transparency Center has an ID that starts with AR, like AR17410177287600472065. You don't strictly need it, because the workflow can search by website. But it helps to know who you're actually watching, since one website often has several advertiser accounts.
By hand: open adstransparency.google.com, type the brand or website into the search box and pick the advertiser. The ID is in the URL:
https://adstransparency.google.com/advertiser/AR17410177287600472065?region=anywhere
└──────── the ID ──────┘
For a list of websites: run the Actor in advertisers mode. It returns one row per advertiser account that runs ads pointing to that domain. Here's a real run for hellofresh.com in the US (shortened):
{ "domains": ["hellofresh.com"], "resultType": "advertisers", "regions": ["US"] }
[
{ "advertiserId": "AR00930880903813529601", "name": "Grocery Delivery E-Services USA Inc.", "countryCode": "US", "verified": true, "adsInSample": 186 },
{ "advertiserId": "AR17410177287600472065", "name": "HelloFresh SE", "countryCode": "DE", "verified": true, "adsInSample": 6 },
{ "advertiserId": "AR11301126289508794369", "name": "Launchigo Media FZCO", "countryCode": "AE", "verified": true, "adsInSample": 1 }
]
This is a good example of why this step is worth doing. HelloFresh's US ads run under a legal entity whose name doesn't include "HelloFresh", the German parent company has its own account, and there's a third-party account (an affiliate or agency) sending ads to the same domain. Watching by website catches all three. Watching by advertiser ID gives you exactly one account and nothing else. Both work in the workflow (see Customization below). That run cost $0.007.
Step 2: import the workflow
Copy the workflow JSON at the end of this post and paste it into an empty n8n canvas. n8n turns pasted JSON into nodes.
It contains no credentials or IDs, so there are three things to connect:
-
Apify token. In Apify Console go to Settings → API & Integrations and copy your API token. In n8n, open Fetch new competitor ads → Credential for Header Auth → Create new: name
Authorization, valueBearer <your token>. - Slack. Connect your Slack account in Post new ads to Slack and Post failure notice to Slack, and pick a channel in each. The failure notice can go to a different channel than the alerts if you like.
- Your competitors. Open Set competitors and alert settings:
| Field | Example | What it does |
|---|---|---|
competitorDomains |
hellofresh.com, blueapron.com |
Websites the ads point to, comma-separated |
regions |
US or US, GB
|
Countries where the ads were shown. Leave empty for anywhere |
stateKey |
competitor-ads-daily |
Name of the "already seen" memory. One per workflow |
maxAdsPerCompetitor |
50 |
How many ads to fetch per competitor per run |
includeAdText |
true |
Headline/description as Google shows it (+$0.30 per 1,000 ads) |
maxCostPerRunUsd |
0.25 |
Hard spending cap per run. Apify's minimum is $0.10 |
Step 3: how the Apify call works
Fetch new competitor ads is a plain HTTP Request node. It calls Apify's run-sync-get-dataset-items endpoint, which starts the Actor, waits for it to finish and returns the results as JSON in one request:
POST https://api.apify.com/v2/acts/cleandata-labs~google-ads-transparency-explorer/run-sync-get-dataset-items
?maxTotalChargeUsd={{ $json.maxCostPerRunUsd }}&timeout=280
The body is built from the settings node:
{{ JSON.stringify({
domains: String($json.competitorDomains || '').split(',').map(s => s.trim()).filter(Boolean),
regions: String($json.regions || '').split(',').map(s => s.trim()).filter(Boolean),
maxAdsPerTarget: $json.maxAdsPerCompetitor,
includeAdText: $json.includeAdText,
onlyNew: true,
stateKey: $json.stateKey
}) }}
Two details I learned the hard way:
-
Lists must be real JSON arrays. Apify checks the input against the Actor's schema before the run starts, so
"domains": "hellofresh.com, blueapron.com"gets rejected with400 invalid-input: Field input.domains must be array. That's what the.split(',')is for. The same applies to numbers and booleans:"50"and"true"in quotes are rejected too. (Our own README used to claim strings were fine. They're not, and it's fixed now.) -
maxTotalChargeUsdcan't go below $0.10. Apify refuses the run otherwise. With pay-per-result pricing you're charged for what's delivered, not the cap, so $0.10–0.25 is just a safety net.
The node's On Error setting is Continue (using error output). A wrong token, an exhausted credit or a timeout goes to the failure branch and posts a short warning to Slack. Without it, a broken check looks exactly like a quiet day.
Step 4: the first run is your baseline
Click Execute workflow once by hand. The Actor has never seen these competitors, so the first run returns everything that's currently live (up to maxAdsPerCompetitor each), and all of it gets posted. That's your baseline. It's also the most expensive run you'll have (see costs below).
Here's the Slack message from a real baseline run (hellofresh.com, US, 3 ads):
📣 3 new Google ads from the competitors you watch
• Grocery Delivery E-Services USA Inc. (TEXT, hellofresh.com), first shown 2026-08-06
View in Transparency Center · Creative
• Grocery Delivery E-Services USA Inc. (IMAGE, hellofresh.com), first shown 2023-06-22
"HelloFresh Calorie Smart Meal Kit - Two Meals for Two People Per Week | HelloFresh"
View in Transparency Center
• Grocery Delivery E-Services USA Inc. (TEXT, hellofresh.com), first shown 2026-03-16
View in Transparency Center · Creative
"New" means new to you: the second ad has been running since 2023, but it's the first time this workflow saw it. From the second run on, only creatives that weren't there before come back.
Two of those text ads have no quoted text. Google archives some text ads as images, so the words are inside the picture behind the Creative link. You aren't charged for the text add-on on those.
Then click Activate. In my tests, the next runs returned 0 new ads and posted nothing, which is exactly what you want. They took 35–72 seconds on a large advertiser because the Actor still checks the most recently shown ads (up to 2,000 per competitor) against its memory. That checking is free.
What it costs
The Actor is pay-per-event. The prices below are for Apify's free plan. Paid plans pay less per ad.
| What | Price |
|---|---|
| Each ad delivered | $1.50 per 1,000 |
| Ad text add-on (only when text was found) | +$0.30 per 1,000 |
| Run start | $0.001 |
| Already-seen ads, empty searches, errors | free |
What that means for a daily check on two competitors:
-
Baseline run: at most 2 × 50 ads × $0.0018 = $0.18, usually less. If you want the baseline to fit under the default $0.25 cap with room to spare, set
maxAdsPerCompetitorto 30 for the first run. - Quiet day: $0.001. That's about 3 cents a month if nothing changes.
- Busy day (a competitor launches 12 new ads): about $0.02.
Apify's free plan includes $5 of credit per month, which covers this many times over. n8n is free self-hosted, or part of your n8n Cloud plan. If you turn includeAdText off, the cost per ad drops by up to 17%, but the alerts become a lot less useful.
What you can't get (and what to do instead)
This is the part most tools gloss over. The Transparency Center is a transparency tool, not a spy tool, and some things simply aren't published.
Spend and budgets. Google publishes spend only for election/political ads. For commercial advertisers nobody has the real number, whatever a tool's landing page says. Workarounds:
- Use proxies you can see: how many creatives are active (
isActive), how long ads stay live (shownForDays, since an ad that runs for months is probably profitable) and how often new ones appear (this workflow). - For EU-served ads, Google publishes "times shown" ranges per country (the Actor's
includeDetailsoption returns them asimpressionsMin/impressionsMax). - Third-party tools (Semrush, SpyFu, Similarweb) estimate search spend from rankings and CPCs. Treat those numbers as estimates, not data.
Targeting outside the EU. Targeting information and reach are only published for ads shown in the EEA, because of the Digital Services Act. For the US, UK and elsewhere you get the creative, its dates and its countries, not the audience. Workarounds:
- Use
regionsas a rough geo-targeting signal: run with["US"], then["CA"], and compare.includeDetailsalso returns per-country first/last shown dates. - If your competitor also advertises in an EU country,
includeDetailsreturns the targeting information Google publishes for those ads. It only describes the EU campaigns, but it's a hint at how they think about audiences.
Keyword search ("who bids on project management software?"). The Transparency Center is organized by advertiser, so you can't search it by keyword, and neither can any tool that reads it. Workarounds:
- In your own Google Ads account, Auction Insights lists the domains you compete with in the same auctions.
- Search your keywords on Google in the countries you care about and write down the domains in the Sponsored results. Do that for a week and you'll have a solid list.
- Feed that list of domains into
competitorDomains. From then on, the workflow watches what they launch.
Also good to know: date filters only work for about the last 12 months, landing pages come back as a domain rather than a full URL, and video ads give you a YouTube link rather than a file.
Customization
-
Watch exact accounts instead of websites: in the HTTP body, replace
domains: ...withadvertiserIds: ["AR00930880903813529601"]. Or usequeriesto mix brand names, IDs and Transparency Center links. -
Only video or only images: add
adFormat: "VIDEO"(or"IMAGE","TEXT") to the body. Addplatforms: ["YOUTUBE"]for YouTube only. -
Somewhere other than Slack: swap the two Slack nodes for Gmail, Microsoft Teams, Telegram or Discord. The message is in
{{ $json.message }}. -
Weekly instead of daily: change the schedule.
onlyNewdoesn't care how long it's been since the last run. -
Start over: change
stateKey. The old memory stays in the key-value storegoogle-ads-transparency-statein your Apify account, and you can delete it there.
One rule: don't use the same stateKey in two workflows that might run at the same time. They share one memory, so one will "use up" the new ads before the other sees them.
Wrapping up
There's no Transparency Center API, and BigQuery covers only the EEA and Turkey. But "tell me when a competitor launches something new" is a small, cheap job: a schedule, one HTTP call with onlyNew, and a Slack message. Set it up once, let the baseline run, and from then on you only hear about it (and only pay for it) when something actually changes.
If you try it, I'd like to hear what you're watching and what broke. And if you need something the Transparency Center doesn't publish, the honest answer is usually that no one has it.
The workflow JSON
Click to expand: the full n8n workflow (paste it into an empty canvas)
{
"name": "Get Slack alerts for new competitor Google ads from the Ads Transparency Center",
"nodes": [
{
"parameters": {
"content": "## Get a Slack alert when a competitor launches a new Google ad\n\n### How it works\nEvery morning this workflow checks the Google Ads Transparency Center for the competitor websites you list and posts **only the ads they launched since the last check** to Slack: advertiser, format, first-shown date, ad text and links to the ad and its creative.\n\nIt calls the Apify Actor *Google Ads Transparency Scraper* (cleandata-labs) with `onlyNew` on. The Actor remembers which ads it already returned (under your `stateKey`), so repeats are skipped and not charged. The first run returns the current ads as a baseline; after that, quiet days post nothing.\n\n### Setup\n1. Create a free Apify account and copy your API token (Apify Console → Settings → API & Integrations).\n2. In **Fetch new competitor ads**, create a *Header Auth* credential: name `Authorization`, value `Bearer YOUR_APIFY_TOKEN`.\n3. Connect Slack in both Slack nodes and pick your channel.\n4. Edit **Set competitors and alert settings**: websites, countries, `stateKey`.\n5. Run it once by hand to create the baseline, then activate.\n\n### Customization\nSwap Slack for Gmail or Telegram, change the schedule, or set `includeAdText` to false to cut the cost by up to 17%. Cost: about $1.50 per 1,000 new ads on Apify's free plan plus $0.30 per 1,000 for ad text; days with no new ads cost only the run start fee.\n\nDisclosure: the Apify Actor used here is built by the template author.",
"height": 760,
"width": 520,
"color": 1
},
"id": "245aea6d-0a32-4ca2-a724-d75a184ac11b",
"name": "Overview",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-80,
-420
]
},
{
"parameters": {
"content": "## 1. Schedule and settings\nRuns daily at 08:00. All values you need to change live in one node.",
"height": 360,
"width": 460,
"color": 7
},
"id": "267486d7-7d5c-4324-9575-af7f90c29283",
"name": "Section config",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
480,
-120
]
},
{
"parameters": {
"content": "## 2. Fetch only new ads\nOne Apify call per run. `onlyNew` + `stateKey` return just the ads not seen before.",
"height": 360,
"width": 460,
"color": 7
},
"id": "3ad1e9bb-37a4-4f9d-863e-97b59d9b7a9f",
"name": "Section fetch",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
960,
-120
]
},
{
"parameters": {
"content": "## 3. Post to Slack\nOne message per run, only when there is something new. Failed Apify calls post a notice.",
"height": 640,
"width": 700,
"color": 7
},
"id": "1c34be0f-bf09-4bb1-9ee5-a7af6c741d1e",
"name": "Section alert",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
1440,
-120
]
},
{
"parameters": {
"rule": {
"interval": [
{
"triggerAtHour": 8
}
]
}
},
"id": "0e52ac2d-9b2e-4940-9b99-b26bce15ad50",
"name": "Every day at 08:00",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
540,
40
]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "f38af90e-43c1-4deb-9e96-1d3c840aaa37",
"name": "competitorDomains",
"value": "hellofresh.com, blueapron.com",
"type": "string"
},
{
"id": "9f004f65-a010-4a41-890d-a26a6ef48d89",
"name": "regions",
"value": "US",
"type": "string"
},
{
"id": "f3ed34fb-04bf-4a4a-8e82-87d784a41ded",
"name": "stateKey",
"value": "competitor-ads-daily",
"type": "string"
},
{
"id": "9a6e281c-de0c-47f6-9aaa-b044b8c0f21f",
"name": "maxAdsPerCompetitor",
"value": 50,
"type": "number"
},
{
"id": "6cd8002d-697a-44bf-8c91-f0b3beb48c51",
"name": "includeAdText",
"value": true,
"type": "boolean"
},
{
"id": "e14e09d0-9ef3-41aa-a5d6-88217613d075",
"name": "maxCostPerRunUsd",
"value": 0.25,
"type": "number"
}
]
},
"options": {}
},
"id": "2a8a8a02-9f0d-4df0-9e93-cbf665fbc974",
"name": "Set competitors and alert settings",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
740,
40
]
},
{
"parameters": {
"method": "POST",
"url": "https://api.apify.com/v2/acts/cleandata-labs~google-ads-transparency-explorer/run-sync-get-dataset-items",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "maxTotalChargeUsd",
"value": "={{ $json.maxCostPerRunUsd }}"
},
{
"name": "timeout",
"value": "280"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ domains: String($json.competitorDomains || '').split(',').map(s => s.trim()).filter(Boolean), regions: String($json.regions || '').split(',').map(s => s.trim()).filter(Boolean), maxAdsPerTarget: $json.maxAdsPerCompetitor, includeAdText: $json.includeAdText, onlyNew: true, stateKey: $json.stateKey }) }}",
"options": {
"timeout": 300000
}
},
"id": "bfe75446-41ee-432b-8e44-6277e88fe17a",
"name": "Fetch new competitor ads",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1040,
40
],
"alwaysOutputData": true,
"onError": "continueErrorOutput"
},
{
"parameters": {
"jsCode": "// Turn the Actor rows into one Slack message.\n// Rows: type \"ad\" = a new ad, \"error\" = a target that could not be checked, \"noResults\" = nothing found.\nconst rows = $input.all().map(i => i.json).filter(r => r && r.type);\nconst ads = rows.filter(r => r.type === 'ad');\nconst problems = rows.filter(r => r.type === 'error');\nconst day = s => (s ? String(s).slice(0, 10) : '?');\n// Slack treats < > & as control characters, so escape them in ad text and names.\nconst esc = s => String(s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');\nconst MAX_LINES = 20;\n\nconst lines = ads.slice(0, MAX_LINES).map(ad => {\n const text = esc((ad.adText || '').replace(/\\s+/g, ' ').trim().slice(0, 140));\n const media = ad.youtubeUrl || ad.imageUrl || '';\n return `• *${esc(ad.advertiserName || ad.domain)}* (${ad.format}, ${ad.domain || ad.searchTarget}), first shown ${day(ad.firstShown)}` +\n (text ? `\\n \"${text}\"` : '') +\n `\\n <${ad.adUrl}|View in Transparency Center>` + (media ? ` · <${media}|Creative>` : '');\n});\n\nlet message = `:mega: *${ads.length} new Google ad${ads.length === 1 ? '' : 's'}* from the competitors you watch`;\nif (lines.length) message += '\\n\\n' + lines.join('\\n');\nif (ads.length > MAX_LINES) message += `\\n\\n…and ${ads.length - MAX_LINES} more in the Apify dataset.`;\nif (problems.length) {\n message += `\\n\\n:warning: Could not check: ` + problems.map(p => `${esc(p.target || 'input')} (${esc(p.error)})`).join('; ');\n}\n\nreturn [{ json: { newAdCount: ads.length, problemCount: problems.length, message } }];\n"
},
"id": "386e1bba-6e06-4ed5-b479-22099ca9df39",
"name": "Build Slack summary",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1500,
20
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "3cf489d2-3558-4569-b39f-0ceb37164168",
"leftValue": "={{ $json.newAdCount + $json.problemCount }}",
"rightValue": 0,
"operator": {
"type": "number",
"operation": "gt"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "e666bc7a-76d5-4a63-bfd0-429ea5c2156a",
"name": "Anything new to report?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1720,
20
]
},
{
"parameters": {
"select": "channel",
"channelId": {
"__rl": true,
"value": "",
"mode": "list",
"cachedResultName": ""
},
"text": "={{ $json.message }}",
"otherOptions": {}
},
"id": "ba01487a-19db-44b2-b2b8-4c35fe2f0a63",
"name": "Post new ads to Slack",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.3,
"position": [
1960,
0
]
},
{
"parameters": {
"select": "channel",
"channelId": {
"__rl": true,
"value": "",
"mode": "list",
"cachedResultName": ""
},
"text": "=:warning: Competitor ad check failed: {{ $json.error && $json.error.message ? $json.error.message : JSON.stringify($json.error || $json) }}\nCheck your Apify token and the run log in Apify Console.",
"otherOptions": {}
},
"id": "8b0a6862-8206-48f0-ab53-6948760eeedf",
"name": "Post failure notice to Slack",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.3,
"position": [
1960,
360
]
}
],
"connections": {
"Every day at 08:00": {
"main": [
[
{
"node": "Set competitors and alert settings",
"type": "main",
"index": 0
}
]
]
},
"Set competitors and alert settings": {
"main": [
[
{
"node": "Fetch new competitor ads",
"type": "main",
"index": 0
}
]
]
},
"Fetch new competitor ads": {
"main": [
[
{
"node": "Build Slack summary",
"type": "main",
"index": 0
}
],
[
{
"node": "Post failure notice to Slack",
"type": "main",
"index": 0
}
]
]
},
"Build Slack summary": {
"main": [
[
{
"node": "Anything new to report?",
"type": "main",
"index": 0
}
]
]
},
"Anything new to report?": {
"main": [
[
{
"node": "Post new ads to Slack",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"settings": {
"executionOrder": "v1"
},
"meta": {
"templateCredsSetupCompleted": false
}
}
Top comments (0)