DEV Community

ivanpetrus
ivanpetrus

Posted on

How to Monitor Your Competitors' Ads Automatically (Google, LinkedIn and Bing) with Apify

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:

  1. Open each library and search for a competitor.
  2. Scroll through the results, newest first.
  3. Try to remember (or screenshot) what was there last week.
  4. 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:

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

  1. Create an Apify account (the free plan includes monthly platform credit you can use to try these).
  2. Open the actor page, e.g. Google Ads Transparency Monitor, and click Try for free.
  3. Switch the input editor to JSON and paste an input (examples below).
  4. 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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

baselineOnly records the existing ads and outputs nothing. From the next run on, you only get changes.

Two practical tips:

  1. Use a separate stateStoreName for 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.
  2. 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

  1. 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-linkedin and acme-bing.
  2. Go to Schedules → Create new, pick a time (e.g. every day at 08:00 in your time zone) and add the task(s).
  3. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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}'
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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.

Google

  • Many Search ads are archived as rendered images, so you get an imageUrl rather 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.

LinkedIn

  • 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 exactCompanyMatch when 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 countries or 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": 7 give 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:

Ivan Petrus builds competitor-intelligence actors on Apify Store.

Top comments (0)