# Google Ads Monitor: Competitor Search Ads & Ad Copy Changes (`southern-data/google-search-ads-monitor`) Actor

See which competitors run Google search ads on your keywords, in any country or city. Get headlines, descriptions, positions and sitelinks, and a monitor mode that reports new advertisers, new ad copy and advertisers that stopped.

- **URL**: https://apify.com/southern-data/google-search-ads-monitor.md
- **Developed by:** [Southern Data](https://apify.com/southern-data) (community)
- **Categories:** SEO tools, Lead generation
- **Security:** Limited permissions
- **Stats:** 1 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $6.30 / 1,000 google results page checkeds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
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.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

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

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Google Ads Monitor: Competitor Search Ads & Ad Copy Changes

See which competitors run **Google search ads** on your keywords, in any of 50 countries and in a city, region or postcode you choose. For each advertiser you get the headline, description, every ad text variant seen, display URL, position (top, middle or bottom of the page), sitelinks and phone number. Turn on **changes mode**, schedule it, and each run returns only what changed: new advertisers, new ad copy, advertisers that came back and advertisers that stopped.

### Read this first: what it is and what it isn't

- **Google does not show ads on every page load.** The same search can show 3 ads on one load and none on the next. In our tests before release, ads appeared on **33–58% of page loads country-wide** and on **50–94% with a city set**. That is why each keyword is loaded *checks per keyword* times (default 2) and the results are merged. A keyword with no ads in any check is listed in the KEYWORDS record. Add checks or a city rather than taking that as proof that nobody bids on it.
- **Ad texts rotate.** Google assembles responsive search ads from up to 15 headlines and 4 descriptions, so the same advertiser reads differently on almost every load. You get **one row per advertiser per keyword**: the wording seen most often, plus every wording seen in `headlinesSeen` and `descriptionsSeen`.
- **It is a sample of what a searcher in that place sees right now**, not Google's auction data. There are no bids, spend, impressions or click-through rates. Google personalises results and keeps changing them, so two runs a minute apart can differ.
- **Landing-page URLs are not returned.** Ad links are Google click-tracking links; following them would bill the advertiser for a fake click, so the actor never outputs or opens them. You get the display URL and the advertiser's domain.
- **Ads only, not organic results or Shopping ads.** It reads text search ads (top, middle and bottom). Shopping/product-listing carousels, Local Services Ads and Maps ads are not included.
- **It does not use the Google Ads Transparency Center.** See *Data source and compliance* below.

### Quick start

```json
{
  "keywords": ["emergency plumber", "blocked drains", "hot water repairs"],
  "country": "au",
  "location": "Sydney, NSW",
  "checksPerKeyword": 3,
  "competitorDomains": ["competitor-plumbing.com.au"],
  "myDomain": "mybusiness.com.au",
  "mode": "changes",
  "monitorName": "sydney-plumbing"
}
```

Run it once to record the baseline, then schedule the saved task daily or weekly. Keep the same `monitorName`, because that is what it remembers between runs.

### Input

| Field | What it does |
|---|---|
| `keywords` | Search terms, one per line. Up to 1,000 per run. Case and spacing duplicates are removed. |
| `country` | One of 50 countries (two-letter code, e.g. `us`, `au`, `gb`, `de`). Sets the Google site, the proxy country and the default language. |
| `location` | Optional city, region or postcode, e.g. `Sydney, NSW`, `Austin, TX`, `London, England`, `2000,New South Wales,Australia`. It is checked against Google's own list of places before any search, for free. Typos get suggestions ("Did you mean Sydney,New South Wales,Australia?"), and a place outside `country` is refused. Use English place names (`Munich`, not `München`). |
| `language` | Optional interface language code (`en`, `de`, `pt-BR`). Empty = the country's main language. |
| `device` | `desktop` or `mobile`. Mobile can show different ads. |
| `checksPerKeyword` | 1–10, default 2. Each load is one paid check. |
| `stopWhenAdsFound` | Cheaper: stop checking a keyword once a check shows ads. You may miss rotating advertisers. |
| `competitorDomains`, `myDomain` | Flags rows with `isCompetitor` / `isYou` (subdomains count). |
| `onlyCompetitors` | Return only competitor ads and your own. The ADVERTISERS record still lists everyone. |
| `mode` | `all_ads` (every advertiser, each run) or `changes` (only what changed since the last run). |
| `monitorName`, `includeUnchanged`, `goneAfterRuns` | Changes-mode memory name; also return unchanged rows; how many runs in a row an advertiser must be missing before `advertiser_gone` (default 3). |

### How changes mode works

For each keyword plus country, language, device and location, the actor remembers every advertiser and every headline part and description sentence it has seen.

- `baseline`: the first run. Everything is recorded and returned once.
- `new_advertiser`: a domain that hasn't shown an ad on this keyword before.
- `new_copy`: an advertiser shows headline or description text that has never been seen before. `newCopy` lists the new texts and `previousHeadline` shows their last headline. Re-ordered texts are **not** a change. Because texts rotate, new copy is reported only after an advertiser has been seen on about **6 page loads**; texts seen before that are learned silently.
- `advertiser_back`: an advertiser that had been reported gone is showing again.
- `advertiser_gone`: an advertiser has been missing for `goneAfterRuns` runs in a row **on runs where the keyword showed other ads**. Runs where Google showed no ads at all don't count. Each gone advertiser is reported once.
- `unchanged`: returned only with `includeUnchanged`.

Memory is kept for 180 days in a named key-value store (`ads-monitor-<name>`).

### Output: 28 columns, same order on every row

Unknown values are `null` or `[]`, never guessed.

| Column | Meaning |
|---|---|
| keyword | The search term checked, as you typed it (spaces tidied) |
| country | Country code of the Google site and proxy location used, e.g. us, au, gb |
| language | Google interface language (hl), e.g. en |
| device | desktop | mobile — the browser type Google was asked for |
| location | City/region the search was localised to (Google place name), or null for the whole country |
| advertiserDomain | Advertiser website domain as Google shows it on the ad, e.g. booking.com. null only on rare ads without a visible domain |
| advertiserName | Advertiser name as Google shows it above the ad (often the brand, sometimes the domain). null when Google shows none |
| headline | The advertiser's ad headline as shown most often in this run (Google combines up to 3 headline texts with " | " or " - "). null on advertiser_gone rows |
| description | Description shown with that headline (longest version seen; Google may cut it with "..."). null on advertiser_gone rows |
| headlinesSeen | Every distinct headline this advertiser showed for this keyword in this run (Google rotates ad texts between page loads). \[] on advertiser_gone rows |
| descriptionsSeen | Every distinct description seen in this run. \[] on advertiser_gone rows |
| displayUrl | The green display URL / breadcrumb, e.g. "https://www.ihg.com › sydney". Not a click link: the real landing page is hidden behind Google's ad-click redirect, which this Actor never opens |
| position | top (above the results) | middle (between results) | bottom (below the results) — best position seen across checks. null on advertiser_gone rows |
| rank | Place within that position block, 1 = first ad (best seen across checks) |
| sitelinks | Extra links shown under the ad (sitelink texts, max 8) |
| phone | Call extension phone number shown on the ad, if any |
| seenInChecks | In how many of this run's checks (page loads) of the keyword this ad appeared. 0 on advertiser_gone rows |
| checks | How many Google pages were loaded for this keyword in this run |
| isCompetitor | true if the advertiser domain is one of your competitorDomains (subdomains included); null when you gave none |
| isYou | true if the advertiser domain is your myDomain; null when you gave none |
| change | changes mode: baseline (first run of this keyword) | new_advertiser | new_copy | advertiser_back | advertiser_gone | unchanged. null in all_ads mode |
| newCopy | new_copy rows: the headline parts / description sentences this advertiser had not shown before on this keyword |
| previousHeadline | changes mode: the advertiser's most recent headline from an earlier run (for gone rows: their last ad) |
| firstSeenAt | changes mode: when this monitor first saw the advertiser on this keyword (ISO 8601 UTC). null in all_ads mode |
| lastSeenBefore | changes mode: when the advertiser was last seen on this keyword before this run (ISO 8601 UTC) |
| checkedAt | When this run started (ISO 8601 UTC) |
| searchUrl | The Google search that was checked (open it to see the page yourself; results differ per person and load) |
| adKey | Stable id of this advertiser on this keyword + country/language/device/location. Same advertiser in a later run = same adKey |

The run also writes these key-value records:

- `ADVERTISERS`: every advertiser seen, the keywords they bid on, their share of your keywords, their best position and their top-ad count. This is the quickest "who are my competitors" view.
- `KEYWORDS`: per keyword, the pages loaded, pages with ads, ads found, the location Google applied and any errors.
- `RUN_SUMMARY`: totals, ad fill rate, changes by type, charged events and settings.

The dataset has four views: Ads, Ad copy, Changes and All fields.

### Pricing (pay per event)

| Event | Price | When |
|---|---|---|
| Google results page checked | **$0.007** | Per Google page loaded for one keyword. Ad rows are free. Pages that fail to load, automatic reloads, and runs with invalid input are free. |

**Store discount tiers:** free-plan and Bronze users pay the price above. Silver gets 5% off and Gold (and higher) gets 10% off.

Examples:

- **20 keywords × 2 checks** = 40 checks = **$0.28** per run, or about $8.40 a month run daily.
- **5 local keywords × 3 checks**, weekly = $0.105 per run.
- `stopWhenAdsFound` lowers the cost on keywords that show ads early.
- **Invalid input** (no keywords, an unknown place, a wrong language code) costs **$0**. The run ends with a message saying what to fix.
- The **self-test** loads one live Google page and costs one check ($0.007).

Set the run's maximum cost to cap spending. The actor never starts a check it can't pay for, and it tells you how many checks it skipped.

### Data source and compliance

- Ads are read from normal Google Search result pages, fetched through **Apify's own Google SERP proxy** (the same route as Apify's Google Search Results Scraper). The actor does no captcha solving or anti-bot circumvention of its own.
- It does **not** use the Google Ads Transparency Center. Its terms say its content may be used for information or research purposes only, and not sold or monetised.
- It never opens ad-click links, so advertisers are never charged for clicks.
- Only advertiser and ad information is collected. There is no personal data about searchers.
- You are responsible for how you use the results, including Google's terms. See COMPLIANCE.md in the source for details.

### Reliability

- Before release, a final batch of 12 runs loaded **165 of 165 Google pages (100%)**. That included a 40-keyword US batch, changes mode on two runs, a mobile city search, competitor-only output and an unknown-location input. Every row passed the output schema.
- A daily self-test checks the ad parser on saved Google pages and makes one live search to confirm Google applied the location. If Google changes its page layout, the self-test fails and the actor is fixed.

### FAQ

**Why did a keyword show no ads?** Google showed none on the loads we made. Try 3+ checks, set a city, or run at another time of day. Some keywords (for example, some insurance and legal terms) are restricted or rarely served to new sessions.

**Why don't top ads appear as often as when I search?** Google decides ad placement per load. Bottom-of-page ads are common in these samples.

**Can I get the advertiser's landing page?** No. See above. Use the display URL and domain.

**Can I track Shopping ads?** Not yet.

# Actor input Schema

## `keywords` (type: `array`):

Search terms to check for ads, one per line — the words your customers type, e.g. "emergency plumber sydney". Each keyword is loaded on Google <b>checks per keyword</b> times; every page load is one paid check.

## `country` (type: `string`):

Google site and searcher location. Ads are fetched through Apify's Google proxy in this country, so you see the ads people there see.

## `location` (type: `string`):

Localise to a city, region or postcode. Type it like "Sydney, NSW", "Austin, TX", "London, England" or Google's full name "Sydney,New South Wales,Australia". It is checked against Google's own list of places before any search (free): typos get suggestions, and a place in another country is refused. Leave empty for the whole country. Local ads (plumbers, dentists, lawyers) show far more often with a city set.

## `language` (type: `string`):

Google interface language code, e.g. en, de, pt-BR. Empty = the country's main language.

## `device` (type: `string`):

Desktop and mobile can show different ads.

## `checksPerKeyword` (type: `integer`):

How many times each keyword is loaded on Google in this run. Google does NOT show ads on every page load (in our tests: 33–58% of loads country-wide, 50–94% with a city set), and rotates advertisers, so 2–3 checks find many more ads than 1. Each check is one paid event; results merge into one row per ad with "seen in X of Y checks".

## `stopWhenAdsFound` (type: `boolean`):

Cheaper: once a check of a keyword shows ads, skip its remaining checks. You may miss advertisers that rotate in on other loads.

## `competitorDomains` (type: `array`):

Websites to flag, e.g. "competitor.com" (subdomains count). Their ads get isCompetitor = true.

## `myDomain` (type: `string`):

Your own site, e.g. "mybusiness.com.au". Your ads get isYou = true, so you can see where you show and where you don't.

## `onlyCompetitors` (type: `boolean`):

Hide ads from other advertisers. The ADVERTISERS record still lists everyone.

## `mode` (type: `string`):

<b>changes</b>: remembers each keyword's advertisers and ad texts between runs and returns only what is new: new advertisers, new ad copy, advertisers that came back or stopped showing. The first run returns everything as "baseline". Schedule it daily or weekly. Google rotates each advertiser's ad texts, so new ad copy is only reported after an advertiser has been seen on about 6 page loads (texts seen before that are learned silently).

## `monitorName` (type: `string`):

Memory name, e.g. "plumbing-competitors". Keep it the same between runs; changing it starts a fresh baseline. Empty = a name made from the keywords and settings (or the saved task).

## `includeUnchanged` (type: `boolean`):

Return every ad with change = unchanged as well, for a full picture each run.

## `goneAfterRuns` (type: `integer`):

An advertiser is reported advertiser_gone after this many runs in a row where the keyword showed ads but none of theirs. Runs where Google showed no ads at all don't count. Higher = fewer false alarms.

## `selfTest` (type: `boolean`):

Maintenance: check the parser on saved pages plus one live Google check. Ignores the other fields.

## Actor input object example

```json
{
  "keywords": [
    "emergency plumber sydney",
    "hotels sydney"
  ],
  "country": "au",
  "device": "desktop",
  "checksPerKeyword": 2,
  "stopWhenAdsFound": false,
  "onlyCompetitors": false,
  "mode": "all_ads",
  "includeUnchanged": false,
  "goneAfterRuns": 3,
  "selfTest": false
}
```

# Actor output Schema

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

One row per advertiser per keyword (changes mode: only changes unless includeUnchanged). Views: Ads, Ad copy, Changes, All fields.

## `advertisers` (type: `string`):

ADVERTISERS record: every advertiser seen, how many of your keywords they bid on, best position, top-ad count.

## `keywords` (type: `string`):

KEYWORDS record: per keyword, pages loaded, pages with ads, ads, advertisers, errors, search link.

## `runSummary` (type: `string`):

RUN_SUMMARY record: pages loaded/failed, ad fill rate, ads, changes by type, charged events, settings, any input problem.

# 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 = {
    "keywords": [
        "emergency plumber sydney",
        "hotels sydney"
    ],
    "country": "au"
};

// Run the Actor and wait for it to finish
const run = await client.actor("southern-data/google-search-ads-monitor").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 = {
    "keywords": [
        "emergency plumber sydney",
        "hotels sydney",
    ],
    "country": "au",
}

# Run the Actor and wait for it to finish
run = client.actor("southern-data/google-search-ads-monitor").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 '{
  "keywords": [
    "emergency plumber sydney",
    "hotels sydney"
  ],
  "country": "au"
}' |
apify call southern-data/google-search-ads-monitor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,southern-data/google-search-ads-monitor"
        }
    }
}
```

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/Uho0miLJXsedxRZOP/builds/PVrEiQyJ1N2sKSIlW/openapi.json
