When a competitor launches a new offer, you usually see it in their ads first: a new headline, a new landing page, a new video, a webinar promo. You can watch for these yourself, because Google, LinkedIn and Microsoft all publish public ad libraries that show who is advertising and what they are running.
What these libraries don't give you is a way to say "tell me when something new shows up." This tutorial covers how to turn them into an automated feed of new competitor ads, delivered to Slack or email every morning, plus how to pull the same data with Python and analyze it with pandas.
What you'll build:
- A daily job that checks your competitors on Google (Search, Display, YouTube), LinkedIn and Bing
- Output that contains only ads that appeared since the previous run
- Alerts in Slack or email, or a webhook into Zapier, Make or n8n
- A small Python script that collects the results and counts new ads per competitor per week
Disclosure: I built the three Apify actors used in this guide. I've tried to be clear about what they can't do. Each section on limits comes straight from the actors' documentation.
Why ad libraries exist, and what each one shows
Ad transparency libraries exist so the public can see who is paying to show them ads. For Europe, the EU Digital Services Act (DSA) is the main driver. It's why LinkedIn and Microsoft publish run dates, reach and targeting for ads shown in the EU. For marketers, these libraries are the most reliable public source of competitor creative, because the platforms publish the data themselves.
| Google Ads Transparency Center | LinkedIn Ad Library | Microsoft Ad Library | |
|---|---|---|---|
| Website | adstransparency.google.com | linkedin.com/ad-library | adlibrary.ads.microsoft.com |
| Coverage | Ads on Search, YouTube, Display and Shopping, any country | Ads that ran on LinkedIn in roughly the last year | Bing / Microsoft Advertising ads with impressions in the EU/EEA only |
| Who's behind it | Verified advertiser name and ID | Advertiser, "Paid for by" | Advertiser, "paid for by" (e.g. an agency) |
| Dates | First and last shown | Start/end dates for EU ads | First/last impression in the EU/EEA |
| Reach | Impression ranges per country (where Google shows them) | Impression range and share by country (EU ads) | Impression range and share by country |
| Targeting | Not shown | Language, location and which parameters were used (EU ads) | Targeting types (Location, Age, Gender, Audiences) |
| Spend / clicks | No | No | No |
The last row matters: none of these libraries publish spend or clicks for commercial ads. If a tool claims to show competitors' exact budgets from these sources, be skeptical.
The manual way, and why it doesn't scale
You can do all of this by hand:
- Open each library and search for a competitor.
- Scroll through the results, newest first.
- Try to remember (or screenshot) what was there last week.
- Repeat for every competitor, every country and every platform.
With five competitors on three platforms you're running 15 searches, and that's before you add countries. The real problem is step 3. None of these websites tells you what's new since you last looked, so you end up eyeballing grids of creatives and comparing them from memory. Most teams do this once, maybe twice, and then stop.
Automation fixes exactly that step. A script checks every competitor on a schedule, remembers which ads it has already seen, and reports only the difference.
The tools
I'll use three actors from Apify Store. Each one wraps one library and follows the same conventions:
-
Google Ads Transparency Scraper & New Ads Monitor: input competitor domains, advertiser IDs (
AR…) or advertiser names. - LinkedIn Ad Library Scraper & New Ads Monitor: input company names, keywords from ad copy, or payers. No LinkedIn login or cookies needed.
- Bing Ads Library Scraper – Microsoft Ads Monitor (EU): input advertiser names, advertiser IDs, or search terms. It uses Microsoft's documented public Ad Library API.
What they have in common is an "Only new ads" mode (onlyNewAds). Each actor stores the IDs of ads it has seen in a named key-value store on your Apify account. On later runs it outputs only ads it hasn't seen before. Every ad row has an isNew flag, and every competitor/search gets a free summary row with a newAdsSinceLastRun count.
Step 1: Run your first check in Apify Console
- Create an Apify account (the free plan includes monthly platform credit you can use to try these).
- Open the actor page, e.g. Google Ads Transparency Monitor, and click Try for free.
- Switch the input editor to JSON and paste an input (examples below).
- Click Start. When the run finishes, open the Output tab and export as JSON, CSV or Excel.
Input JSON for each platform
Google – competitors by domain, US only, new ads only:
{
"domains": ["zappos.com", "nike.com"],
"regions": ["US"],
"onlyNewAds": true
}
Useful options: regions accepts any country or "anywhere". formats filters to text, image or video. lastDays (or dateFrom/dateTo) sets a date window. extractAdContent returns ad copy and YouTube video IDs where Google exposes them. enrichDetails adds per-country dates and impression ranges.
LinkedIn – B2B competitors by company name:
{
"companies": ["HubSpot", "Salesforce"],
"onlyNewAds": true,
"maxAdsPerSearch": 50
}
Add "includeDetails": true to get the full ad copy, CTA, landing page, payer and, for EU ads, run dates, impressions and targeting. Add "exactCompanyMatch": true if a name search also matches partner pages (searching "HubSpot" also returns pages like "HubSpot Platinum Partner"). "skipMemberAds": true drops ads posted from individual members' profiles.
Bing / Microsoft – advertiser by name, EU/EEA:
{
"advertiserNames": ["Booking.com B.V."],
"onlyNewAds": true,
"maxAdsPerTarget": 100
}
To research a theme instead of a brand, use searchTerms, e.g. { "searchTerms": ["car insurance"], "countries": ["DE"], "lastDays": 30, "onlyNewAds": false }.
What the output looks like
Here's a real (shortened) Google ad row from an October 2026 run:
{
"type": "ad",
"target": "zappos.com",
"region": "US",
"advertiserName": "Zappos IP, Inc.",
"creativeId": "CR11795179083397070849",
"format": "text",
"firstShown": "2026-04-02T18:12:14+00:00",
"lastShown": "2026-10-07T18:48:08+00:00",
"daysRunning": 189,
"transparencyUrl": "https://adstransparency.google.com/advertiser/AR04821233212191670273/creative/CR11795179083397070849",
"isNew": true
}
And a LinkedIn row with details turned on. The landingPageUrl UTM parameters often reveal the campaign name:
{
"type": "ad",
"searchQuery": "SAP Fioneer",
"format": "Video Ad",
"advertiserName": "SAP Fioneer",
"headline": "Bring structure to broker settlements",
"cta": "Learn more",
"landingPageUrl": "https://www.sapfioneer.com/blog/why-broker-settlements-belong-inside-receivables-control/?utm_campaign=FS-CD+%257C+Insurance+%257C+2026&utm_source=linkedin&utm_medium=paid",
"startDate": "2026-10-06",
"impressions": "5k-10k",
"targetingParameters": [{ "parameter": "Company", "targeted": true, "excluded": true }, { "parameter": "Job", "targeted": true, "excluded": false }],
"isNew": true
}
Each competitor also gets a summary row ("type": "summary") that isn't charged. Failed checks appear as "type": "error" rows with a hint, and those aren't charged either.
Step 2: Understand "only new ads" (monitor mode)
onlyNewAds is on by default in all three actors. Here's how it works:
-
First run: by default (
"firstRunBehavior": "outputAll"), you get everything currently in the library, and all of it is recorded as "seen." - Every later run: you get only ads whose IDs weren't seen before.
If you don't want a big first export, start silently:
{
"domains": ["rozetka.com.ua"],
"regions": ["UA"],
"firstRunBehavior": "baselineOnly",
"stateStoreName": "my-ua-monitor"
}
baselineOnly records the existing ads and outputs nothing. From the next run on, you only get changes.
Two practical tips:
-
Use a separate
stateStoreNamefor each monitor (e.g. one per client). The "seen" list is stored per competitor and filter combination inside that store, so two unrelated monitors sharing one name would interfere with each other. - Spending caps don't lose ads. If a run stops at your max-ads or max-cost limit, ads that weren't delivered are not marked as seen, so they show up on the next run.
Step 3: Schedule it daily
- With your input filled in, click Save as a new task. One task per client or competitor set is a good pattern. You'll end up with tasks like
acme-google,acme-linkedinandacme-bing. - Go to Schedules → Create new, pick a time (e.g. every day at 08:00 in your time zone) and add the task(s).
- Turn on Apify's run-failure notifications, so a broken run shows up as an alert instead of an empty inbox.
Because monitor mode returns only new ads, every scheduled run's dataset is your alert list. A dataset with only summary rows means nothing changed.
Step 4: Send new ads to Slack or email
Open the task and go to the Integrations tab:
- Slack or Gmail integration: sends you a message when a run finishes.
- Webhook on the "Run succeeded" event: passes the run to Zapier, Make, n8n or your own endpoint.
For custom formatting (say, one Slack message per new ad with its headline and link), the webhook route works best. The run payload includes the dataset ID, and your workflow fetches the rows from:
https://api.apify.com/v2/datasets/{defaultDatasetId}/items
Filter to type == "ad", then post advertiserName, headline or title, and transparencyUrl / detailUrl / destinationUrl to your channel.
Step 5: Call the actors from Python
Install the official client and pandas:
pip install "apify-client>=3" pandas
export APIFY_TOKEN=your_token_here # Console → Settings → API & Integrations
This script runs all three monitors, tags each ad with the platform and the date it was detected, and appends the results to a CSV log:
import os
from decimal import Decimal
import pandas as pd
from apify_client import ApifyClient
client = ApifyClient(os.environ["APIFY_TOKEN"])
MONITORS = {
"google": ("ivan-petrus-g/google-ads-transparency-monitor", {
"domains": ["zappos.com", "nike.com"],
"regions": ["US"],
"onlyNewAds": True,
"stateStoreName": "acme-google",
}),
"linkedin": ("ivan-petrus-g/linkedin-ad-library-monitor", {
"companies": ["HubSpot", "Salesforce"],
"onlyNewAds": True,
"maxAdsPerSearch": 50,
"stateStoreName": "acme-linkedin",
}),
"bing": ("ivan-petrus-g/microsoft-ads-library-monitor", {
"advertiserNames": ["Booking.com B.V."],
"onlyNewAds": True,
"maxAdsPerTarget": 100,
"stateStoreName": "acme-bing",
}),
}
rows = []
for platform, (actor_id, run_input) in MONITORS.items():
# max_total_charge_usd caps what a single run can cost (pay-per-event actors)
run = client.actor(actor_id).call(run_input=run_input,
max_total_charge_usd=Decimal("0.50"))
if run is None or run.status != "SUCCEEDED":
print(f"{platform}: run did not succeed")
continue
for item in client.dataset(run.default_dataset_id).iterate_items():
if item.get("type") == "error":
print(f"{platform}: {item.get('error')} ({item.get('hint')})")
if item.get("type") != "ad":
continue # skip free summary rows
rows.append({
"detected": run.started_at.date().isoformat(),
"platform": platform,
# Google and Bing use `target`, LinkedIn uses `searchQuery`
"competitor": item.get("target") or item.get("searchQuery"),
"advertiser": item.get("advertiserName"),
"ad_id": item.get("creativeId") or item.get("adId"),
"text": item.get("headline") or item.get("title") or item.get("adCopy"),
"url": item.get("transparencyUrl") or item.get("detailUrl") or item.get("destinationUrl"),
})
log = "new_ads_log.csv"
pd.DataFrame(rows).to_csv(log, mode="a", index=False, header=not os.path.exists(log))
print(f"{len(rows)} new ads logged")
Note: apify-client v3 returns typed objects (run.default_dataset_id). On the older v1.x client, use run["defaultDatasetId"] and run["status"] instead.
Or call it with plain HTTP
Any language works with the run-sync endpoint. It starts the actor, waits for it to finish, and returns the dataset items in one request:
curl -s -X POST \
"https://api.apify.com/v2/acts/ivan-petrus-g~microsoft-ads-library-monitor/run-sync-get-dataset-items" \
-H "Authorization: Bearer $APIFY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"advertiserNames": ["Booking.com B.V."], "onlyNewAds": true, "maxAdsPerTarget": 50}'
Note the ~ in place of / in the actor ID in the URL. Synchronous requests are time-limited, so for big inputs (or slow country filters on Microsoft's side) start the run asynchronously and use a webhook or poll for the result.
Step 6: A small analysis – new ads per competitor per week
Once the script has run daily for a few weeks, new_ads_log.csv shows how actively each competitor launches creative:
import pandas as pd
df = pd.read_csv("new_ads_log.csv", parse_dates=["detected"])
df = df.drop_duplicates(subset=["platform", "ad_id"])
weekly = (
df.groupby([pd.Grouper(key="detected", freq="W-MON"), "platform", "competitor"])
.size()
.unstack(["platform", "competitor"], fill_value=0)
)
print(weekly.tail(8))
# Who launched the most new creative in the last 4 weeks?
recent = df[df["detected"] >= df["detected"].max() - pd.Timedelta(weeks=4)]
print(recent.groupby(["competitor", "platform"]).size().sort_values(ascending=False).head(10))
A few things to look for:
- Spikes. A jump in new ads from one competitor usually means a launch, a promotion, or a new agency testing creative.
- Platform shifts. A competitor that goes quiet on Google but busy on LinkedIn may be moving budget toward B2B audiences.
-
Long-running ads. Ads with a high
daysRunning(Google, Bing) are probably the ones that work for them. Read those closely.
Keep in mind that this counts ads detected per week. The first week of a monitor includes everything that was already live, unless you started with baselineOnly.
What it costs
All three actors are pay-per-event, with no separate proxy or compute charges. Prices and examples below come from the actors' README pages:
| Actor | Price | README example |
|---|---|---|
| Google Ads Transparency | $0.005 per competitor checked (one domain/advertiser in one region) + $1 per 1,000 ads + $0.001 per run | 20 competitors daily, ~5 new ads each: about $0.20/day |
| LinkedIn Ad Library | $0.005 per search + $1.50 per 1,000 ads, +$1.50 per 1,000 ads with details + $0.005 per run | 10 competitors daily, ~5 new ads each, with details: about $0.21/day |
| Microsoft (Bing) Ad Library | $1.50 per 1,000 ads (details included) + $0.002 per run | 10 competitors daily, ~5 new ads each: about $0.08/day |
With "Only new ads" on, you only pay for new ads, so a quiet day costs close to nothing. Summary rows and failed checks aren't charged. To keep spending predictable, set the max-ads limit (maxAdsPerTarget for Google and Bing, maxAdsPerSearch for LinkedIn) and a maximum cost per run.
Limitations (read before you rely on it)
All platforms
-
No spend or clicks. None of the three libraries publish them for commercial ads. Google shows impression ranges per country (with
enrichDetails), while LinkedIn and Microsoft show impression ranges for EU ads. - "New" means new to the monitor, not necessarily launched today. It's the first time the actor saw that ad ID in the library.
- Many Search ads are archived as rendered images, so you get an
imageUrlrather than text. Ad copy is extracted only when Google provides it as text. - The monitor keeps up to 20,000 seen IDs per monitor.
- Advertiser names can be ambiguous. Advertiser IDs (
AR…) are the most precise input.
- Run dates, impressions and targeting exist only for ads shown in the EU (DSA). Other ads have only creative, advertiser and payer. Impressions by country can take up to 48 hours to appear.
- About one year of history.
- Company search is a name search, so use
exactCompanyMatchwhen you need one exact advertiser. - It reads public web pages, not an official API. If LinkedIn changes its layout, runs fail with explicit error rows (not silent empty data) until the actor is updated. LinkedIn also rate-limits, so large runs take longer.
- For ads posted from an individual member's profile, the actor never outputs the person's name, photo or profile URL.
Microsoft (Bing)
- EU/EEA only. Ads shown only in the US or UK aren't in the library.
- About 1,000 results per query. For very large advertisers, narrow the query with
countriesor a date range. -
1–3 day delay before new impressions appear, and results aren't returned in chronological order. Daily runs with a window like
"lastDays": 7give the best coverage. - Country filters can be slow on Microsoft's side (sometimes 20–90 s per page).
FAQ
Do I need a LinkedIn or Microsoft account?
No. The LinkedIn actor uses public, logged-out pages, and the Microsoft actor uses Microsoft's public API without sign-up. You only need an Apify account.
How do I find a Google advertiser ID?
Open the advertiser on adstransparency.google.com. The AR… part of the URL is the ID. You can also just enter the domain or advertiser name.
Can I monitor Bing ads in the US?
Not with this approach. Microsoft's Ad Library only covers ads with impressions in the EU/EEA.
Can I monitor keywords instead of brands?
Yes, on LinkedIn (keywords) and Bing (searchTerms), e.g. to see who advertises on "crm" or "car insurance". Google's input is by domain, advertiser ID or advertiser name.
How do I reset a monitor and get everything again?
Use a new stateStoreName, or set onlyNewAds to false for a one-off full export.
Is this legal?
These libraries are published by the platforms for transparency, and the actors don't log in. You're still responsible for complying with each platform's terms and applicable law (e.g. GDPR) in how you use the data.
Wrapping up
Competitor ad research usually fails because nobody keeps checking, not because the data is hard to get. With a scheduled run per platform, "only new ads" mode and a Slack or email integration, the check runs every morning. The Python log then gives you a record of competitor activity over time.
Links to the actors:
- Google Ads Transparency Scraper & New Ads Monitor
- LinkedIn Ad Library Scraper & New Ads Monitor
- Bing Ads Library Scraper – Microsoft Ads Monitor (EU)
Ivan Petrus builds competitor-intelligence actors on Apify Store.
Top comments (0)