How to See Google Ads in Other Countries Without Clicking
How to see Google ads in other countries: Ads Preview first, then a clean browser behind a country proxy, no clicks, and a daily script that logs ad copy.
Knowing how to see Google ads in other countries matters the day a campaign targeting Germany goes live and you're sitting in Toronto, or the day a client asks what a rival is bidding on in Brazil. Searching from your own desk tells you nothing: Google places you where you are, and repeated self-searches can quietly hide your own ad from you. This guide sets up a check you can trust, from Google's own preview tool to a scripted daily log of ad copy per keyword per country.
What a country ad check has to prove
PPC managers ask two different questions, and the setup differs for each.
"Is my geo-targeted campaign actually serving there?" You own the account, so you have Google's own diagnostic tool. A live check from the target country is a second opinion, useful when the preview and reality seem to diverge or when a stakeholder wants a screenshot of the real page.
"What are competitors running abroad?" You don't own their account. Google's Ads Transparency Center shows what an advertiser has run; Google's help pages say you can "search by advertiser name or website name" and "filter their search results by serving region and date." What it won't tell you is which of your keywords trigger that ad today, in which slot, with which headline. That needs a live results page from the market. The broader research workflow, including what the ad libraries cover, is in our guide to proxies for competitor ad research, so this post stays on the mechanics of the search page itself.
Either way, a valid check proves four things at once:
| Must prove | How you prove it | If you skip it |
|---|---|---|
| The page was served to a visitor in the target country | Exit IP country logged with every capture | You record your home market by mistake |
| The visitor carried no history | Fresh profile or fresh API session, signed out | Personalization and ad fatigue skew what appears |
| Nothing was clicked | No navigation to ad redirect URLs, ever | You charge an advertiser and may breach Google's invalid-click policy |
| The page was a real results page | Block and consent markers checked on the HTML | A challenge page gets logged as "no ads today" |
Most broken checks fail on the last row. "No ads found" is the most common false result in this work, and it usually means "no results page found."
Ads Preview first: the free, impression-free check
If the campaign is yours, open Google's Ad Preview and Diagnosis tool before you touch a proxy. In Google Ads it sits under Tools, in the Troubleshooting section. Google's help page describes it as a tool that "helps you understand why your ad or ad asset might not be appearing" and says using it "is preferable to conducting a Google search yourself" because it "avoids accumulating ad impressions and impacting your performance statistics."
Google's Search Ads 360 help for the same tool says you enter a search term "and other criteria like language and location," so you can point it at Munich or São Paulo without leaving your desk. Run every keyword you plan to monitor through it once, per target country, and note which ones return a preview and which return a diagnosis such as a budget or bid issue.
Know its limits before you treat it as ground truth. Google's own wording: "the tool may not reflect all campaign activity," "the search results returned by this tool may change over time," and "Keywords with limited search history may not trigger preview simulations." The SA360 page adds that a preview "may no longer appear in the tool when your campaign reaches its daily budget." So an empty preview at 6 p.m. local time is often a spent budget, not a targeting fault.
Ads Preview also can't show competitors in a way you can log over time, and it isn't evidence a client can verify independently. That's where the live view comes in.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
The no-click rule, and why impressions count too
Never click a sponsored result during a country check. Not yours, not a competitor's, not "just once to see the landing page."
Google's invalid-clicks page defines them as "clicks on your ads that don't represent a genuine interest in your business," and lists "Clicks from automated tools, bots, or other deceptive software" and manual "Clicks that increase your ad costs or inflate publisher profits." A monitoring script that follows an ad link fits the first description. A person clicking a rival's ad to peek fits the second. If you need the landing page, take the final URL from the ad's visible display URL or from the Transparency Center, and fetch that directly.
Impressions are the quieter problem. Google's "Check where your ad appears" help page puts it plainly: "By performing searches that trigger your ad, you'll accumulate impressions without clicks, which can lower your clickthrough rate and prevent your ad from appearing as often as it should." It goes further: "if you repeatedly look for your own ad using Google search but never click on it, you might stop seeing it entirely," because "Google's system stops showing you ads that it thinks you aren't interested in."
That last sentence is why the clean profile in the next section isn't optional. A PPC manager who self-searches from the same browser every morning is training Google to hide the ad from exactly that browser, then filing a ticket because the ad "disappeared." It also sets a volume rule for automation: every live capture is a real search that can register impressions for the advertisers on the page. Capture each keyword once per day per country, not every ten minutes.
A clean browser profile behind a country exit
For a person looking at the page, three things change together: the IP, the browser state, and the language. Changing only the IP leaves cookies and your Google account pointing home.
Google's location-targeting help says targeting relies on "a variety of signals, including users' settings, devices, and behavior on our platform," and calls it "best effort." A signed-out, cookie-free browser strips away the settings and behavior signals you carry, which leaves the network location doing most of the work. That's the part a country proxy supplies.
On Windows, a separate Chrome profile with a German exit looks like this:
:: Throwaway profile per market, German exit on the Sticky Sessions port
"C:\Program Files\Google\Chrome\Application\chrome.exe" ^
--user-data-dir="C:\ad-checks\profile-de" ^
--proxy-server="http://gateway.sparkproxy.io:11002" ^
--lang=de-DE ^
--no-first-run ^
"https://www.google.com/search?q=kfz+versicherung+vergleich&hl=de"
Chrome will ask for the proxy credentials. Country is chosen in the username: the sample on sparkproxy.io shows the form user-country-us, so Germany is user-country-de and France is user-country-fr. Whitelisting your office IP in the dashboard removes the prompt. Step-by-step proxy setup for Chrome and Firefox, including PAC files, lives in our Chrome and Firefox proxy guide.
Why port 11002? That's Sticky Sessions, which holds one exit IP for 5 minutes. A person scrolling, changing the query and paging through results should look like one visitor; Random Rotation on port 11000 would hand each request a new IP mid-session. Our explainer on sticky session proxies covers when that matters. For scripts, the opposite holds and each capture should be a fresh visitor.
A short pre-flight inside the proxied window, every time:
- Load an IP-lookup page and confirm the country before searching.
- Stay signed out; never sign in to Google in a check profile.
- Answer the cookie-consent screen the way a local visitor would. In the EU it is part of what real users see.
- Run the keyword, screenshot the full page, and write down the time in the market's own time zone.
- Close the profile, and delete the profile folder weekly so it never accumulates history.
Keep one profile folder per market. Mixing Germany and France in one profile carries consent cookies and language settings across, and the French page you screenshot may carry German preferences.
Reading the sponsored block correctly
Google changed the ad presentation in October 2025. Its post of 13 October 2025 introduced a grouped "Sponsored results" label with a "Hide sponsored results" control, said "you'll still never see more than four text ads in a grouping," and stated the updates were "rolling out globally on desktop and mobile." Shopping units carry "Sponsored products."
Three consequences for a country check:
- The label is translated. A German or Brazilian page shows the label in the interface language, so a script that searches the HTML for the English word "Sponsored" will report zero ads on every non-English page. Detect ads by page structure, and save raw HTML so you can confirm the selector when Google changes markup.
- A grouping holds at most four text ads. If your parser returns six from one block, it is double-counting nested elements.
- Top and bottom blocks are different placements. Record which block an ad came from. An ad that slides from the top block to the bottom one in a market is a bid or Quality Score story, not a missing ad.
What to record per ad: block (top or bottom), position within the block, advertiser display domain, headline, description, and the visible display URL path. Sitelinks and other assets are worth a separate column, since they change more often than headlines.
The daily capture pipeline, end to end
The scripted version replaces the person, and keeps every rule above.
keywords.csv (keyword, market, language)
|
v
scheduler (cron, 07:00 in each market)
|
v
SparkProxy Scraping API -- country_code=DE, render_js=true, json_response=true
| (or Playwright through gateway.sparkproxy.io with user-country-de)
v
decode base64 body -> block/consent check -> parse sponsored blocks
|
v
SQLite: captures (one row per run) + ads (one row per ad) + hash of ad copy
|
v
daily diff report: new advertisers, changed headlines, missing own ads
Proxy decisions for the scripted version:
| Decision | Choice | Reason |
|---|---|---|
| Rotation | Random Rotation (or the API's default per-request exit) | Each keyword should be searched by a fresh visitor with no ad fatigue |
| Country | One code per market, matching the query language | A French query from a German exit is neither market |
| Concurrency | 1 to 2 requests per market at a time | Searches are low volume; bursts invite challenges and add nothing |
| Timing | Same local hour every day per market | Budgets and day-parting change the ad block through the day |
| Devices | Desktop by default; mobile as a separate run | Mobile and desktop are separate auctions with separate layouts |
The Scraping API is the simpler path for HTML. If you need mobile emulation or full-page screenshots under your own control, run Playwright through the gateway, shown later.
Capture script: one keyword, one country, one row per ad
This uses the SparkProxy Scraping API at https://scrape.sparkproxy.io/api/v1 with the key in the X-API-Key header. render_js defaults to true, which matters because the results page needs a real browser. json_response=true wraps the result in an envelope with status_code, credits_used and body, and the body arrives Base64-encoded. Decode it before looking for anything. (The format parameter is unrelated; it converts content to Markdown, PDF and so on, and format=json does not give you this envelope.)
import base64
import os
import time
from urllib.parse import urlencode
import requests
API = "https://scrape.sparkproxy.io/api/v1"
KEY = os.environ["SPARKPROXY_API_KEY"]
BLOCK_MARKERS = ("/sorry/index", "captcha-form", "g-recaptcha")
CONSENT_MARKERS = ("consent.google.", "before you continue")
def google_search_url(keyword, lang):
return "https://www.google.com/search?" + urlencode({"q": keyword, "hl": lang})
def fetch_results_page(keyword, country, lang):
params = {
"url": google_search_url(keyword, lang),
"country_code": country,
"json_response": "true",
}
resp = requests.get(API, params=params, headers={"X-API-Key": KEY}, timeout=150)
resp.raise_for_status()
env = resp.json()
html = base64.b64decode(env["body"]).decode("utf-8", errors="replace")
return env.get("status_code"), env.get("credits_used", 0), html
def classify(html):
lowered = html.lower()
if any(m in lowered for m in BLOCK_MARKERS):
return "blocked"
if any(m in lowered for m in CONSENT_MARKERS) and "id=\"search\"" not in lowered:
return "consent"
if "id=\"search\"" not in lowered and "id=\"rso\"" not in lowered:
return "not_a_results_page"
return "ok"
The markers are heuristics, not a Google contract: /sorry/index is the path Google's challenge page has used, and the results container ids are what we check for in saved pages. Re-confirm all of them against a stored capture whenever counts drop to zero across every market at once, because that pattern means markup changed, not that every advertiser paused.
Parsing the sponsored blocks needs an HTML parser. This version uses BeautifulSoup:
from urllib.parse import urlparse
from bs4 import BeautifulSoup
# Assumed containers for the top and bottom ad blocks. Verify on a saved page.
AD_BLOCKS = {"top": "#tads", "bottom": "#bottomads"}
def parse_ads(html):
soup = BeautifulSoup(html, "html.parser")
ads = []
for block_name, selector in AD_BLOCKS.items():
block = soup.select_one(selector)
if block is None:
continue
position = 0
for link in block.select("a[href]"):
heading = link.select_one("[role=heading], h3")
if heading is None:
continue
position += 1
card = link.find_parent("div", attrs={"data-text-ad": True}) or link.parent
text = " ".join(card.get_text(" ", strip=True).split())
title = heading.get_text(" ", strip=True)
ads.append({
"block": block_name,
"position": position,
"headline": title,
"display_domain": urlparse(link.get("data-pcu", "") or "").netloc,
"snippet": text.replace(title, "", 1).strip()[:500],
})
if position >= 4: # Google caps a grouping at four text ads
break
return ads
Note what the code never does: it never requests the href of an ad. The link is read as text and discarded. The data-pcu attribute is an assumption about where Google exposes the display URL; if it is missing in your captures, take the domain from the visible display-URL text instead. For organic-result parsing and the general structure of a results page, see how to scrape Google search results.
Storing ad copy so changes stand out
Two tables are enough. One row per capture records whether the page was valid; one row per ad holds the copy. A hash of the copy makes "what changed since yesterday" a single query.
import hashlib
import sqlite3
from datetime import datetime, timezone
SCHEMA = """
CREATE TABLE IF NOT EXISTS captures (
id INTEGER PRIMARY KEY, captured_at TEXT, market TEXT, keyword TEXT,
verdict TEXT, upstream_status INTEGER, credits INTEGER, ad_count INTEGER
);
CREATE TABLE IF NOT EXISTS ads (
capture_id INTEGER, block TEXT, position INTEGER, display_domain TEXT,
headline TEXT, snippet TEXT, copy_hash TEXT
);
"""
def save_capture(db, market, keyword, verdict, status, credits, ads):
cur = db.execute(
"INSERT INTO captures (captured_at, market, keyword, verdict, upstream_status,"
" credits, ad_count) VALUES (?, ?, ?, ?, ?, ?, ?)",
(datetime.now(timezone.utc).isoformat(timespec="seconds"), market, keyword,
verdict, status, credits, len(ads)),
)
for ad in ads:
digest = hashlib.sha256((ad["headline"] + "|" + ad["snippet"]).encode()).hexdigest()
db.execute(
"INSERT INTO ads VALUES (?, ?, ?, ?, ?, ?, ?)",
(cur.lastrowid, ad["block"], ad["position"], ad["display_domain"],
ad["headline"], ad["snippet"], digest[:16]),
)
db.commit()
def open_db(path="ad_checks.sqlite"):
db = sqlite3.connect(path)
db.executescript(SCHEMA)
return db
The rule that keeps the data honest: a capture with verdict blocked, consent or not_a_results_page gets a captures row and zero ads rows, and reports never read it as "no ads." Only ok captures with ad_count = 0 mean the market showed no ads for that keyword.
A useful daily query lists copy that appeared today and not yesterday, per market:
SELECT c.market, c.keyword, a.display_domain, a.headline
FROM ads a JOIN captures c ON c.id = a.capture_id
WHERE date(c.captured_at) = date('now') AND c.verdict = 'ok'
AND a.copy_hash NOT IN (
SELECT a2.copy_hash FROM ads a2 JOIN captures c2 ON c2.id = a2.capture_id
WHERE date(c2.captured_at) = date('now', '-1 day')
AND c2.market = c.market AND c2.keyword = c.keyword);
For your own campaigns, flip it: alert when your domain is absent from an ok capture in a market where Ads Preview said it should serve.
Scheduling, failure handling and retries
The runner ties the pieces together, retries only on failures that a fresh exit can fix, and spaces requests out:
import csv
import random
import sys
def run(keywords_csv="keywords.csv", attempts=3):
db = open_db()
with open(keywords_csv, newline="", encoding="utf-8") as f:
jobs = list(csv.DictReader(f)) # columns: keyword, market, lang
for job in jobs:
verdict, status, spent, ads = "error", None, 0, []
for n in range(attempts):
try:
status, credits, html = fetch_results_page(job["keyword"], job["market"], job["lang"])
spent += credits
verdict = classify(html)
if verdict == "ok":
ads = parse_ads(html)
break
except requests.RequestException:
verdict = "error"
if verdict == "consent":
break # a new IP will not remove the consent screen
time.sleep(20 * (n + 1) + random.uniform(0, 10))
save_capture(db, job["market"], job["keyword"], verdict, status, spent, ads)
time.sleep(random.uniform(4, 9))
if __name__ == "__main__":
run(sys.argv[1] if len(sys.argv) > 1 else "keywords.csv")
Schedule one run per market at that market's morning, so the comparison is like for like:
# Germany and France at 07:00 CET (06:00 UTC), Brazil at 07:00 BRT (10:00 UTC)
0 6 * * * cd /opt/ad-checks && python3 capture.py keywords_eu.csv >> logs/eu.log 2>&1
0 10 * * * cd /opt/ad-checks && python3 capture.py keywords_br.csv >> logs/br.log 2>&1
Which failures a proxy can fix, and which it can't:
| What you see | Cause | Proxy fixes it? | Fix |
|---|---|---|---|
| Verdict `blocked` | Challenge page for that exit | Yes | Retry after a pause on a new exit; try `proxy_type=premium` if a market keeps failing |
| Exit IP in the wrong country | Wrong username suffix or `country_code` | Yes | Fix the country code, log the exit country |
| API returns 429 | Plan rate or concurrency limit | No | Lower concurrency, honor `retry_after_seconds` |
| API returns 530 or 500 | Scrape failed upstream | Partly | Retry later; the docs say credits are refunded for these |
| Verdict `consent` | EU consent interstitial | No | Handle consent in a browser session (Playwright route below) |
| `ok` page, your ad absent, Ads Preview shows it | Budget spent, day-parting, or ad fatigue in a reused profile | No | Check budget pacing and schedule; never reuse profiles |
| `ok` page, zero ads in every market | Google markup changed | No | Re-check the selectors against saved HTML |
The Playwright route through the gateway suits mobile runs and consent handling. It also adds a hard guard: any request to an ad-click redirect is aborted before it leaves the browser.
import os
from urllib.parse import quote_plus
from playwright.sync_api import sync_playwright
CLICK_GUARDS = ("googleadservices.com", "/aclk", "/pagead/aclk")
def guard(route):
if any(g in route.request.url for g in CLICK_GUARDS):
route.abort()
else:
route.continue_()
def capture_mobile(keyword, country, lang, out_png):
proxy = {
"server": "http://gateway.sparkproxy.io:11000",
"username": os.environ["SPARKPROXY_USER"] + "-country-" + country.lower(),
"password": os.environ["SPARKPROXY_PASS"],
}
with sync_playwright() as p:
browser = p.chromium.launch(proxy=proxy)
context = browser.new_context(**p.devices["iPhone 13"], locale=lang)
page = context.new_page()
page.route("**/*", guard)
page.goto("https://www.google.com/search?hl=" + lang + "&q=" + quote_plus(keyword),
wait_until="domcontentloaded", timeout=60000)
page.wait_for_timeout(3000)
page.screenshot(path=out_png, full_page=True)
html = page.content()
browser.close()
return html
Feed the returned HTML through the same classify and parse_ads functions so both routes land in one table.
What a daily run costs
On the Scraping API, a rendered request costs 5 credits and country_code adds 5, so each capture is 10 credits. A screenshot through the API adds another 5. If a market forces proxy_type=premium, the docs list 25 credits with JavaScript, plus 5 for the country. New accounts get 1,000 free credits.
Plan ladder as listed on sparkproxy.io/scraping-api, read 30 October 2026, prices in US dollars per month excluding VAT: Starter $49 (250,000 credits, 50 concurrent), Growth $99 (1,000,000 credits, 100 concurrent), Pro $249 (3,000,000 credits), Scale $599 (8,000,000 credits).
Worked examples with assumed workloads and 25% headroom for retries:
| Assumed workload | Captures per month | Credits (10 each, +25%) | Fits |
|---|---|---|---|
| 10 keywords, 2 countries, daily | 600 | 7,500 | Starter |
| 40 keywords, 4 countries, daily | 4,800 | 60,000 | Starter |
| 150 keywords, 6 countries, daily, desktop and mobile | 54,000 | 675,000 | Growth |
| Same as above, all on `proxy_type=premium` (30 each) | 54,000 | 2,025,000 | Pro |
These are arithmetic on assumed inputs, not measured spend. Failed 500 and 530 requests are refunded per the docs, which pulls real spend down.
On the gateway route, proxy plans are packs "Starting from $75/month" with unlimited bandwidth, so full-page mobile screenshots don't raise the bill; you pay for the plan and the server running the browsers. Term discounts are 5%, 10% and 15% for 3, 6 and 12 months, and a 24-hour free trial needs no credit card, enough to run your keyword list through every market once before committing.
When the live view and Ads Preview disagree
This is the case that brings most PPC managers here. Work through it in order, cheapest first:
- Time of day. Ads Preview at your 4 p.m. may be the market's 10 p.m., after the daily budget ran out. Compare captures taken at the same local hour.
- Location option. Google Ads offers "Presence or Interest" and "Presence" targeting. Google's help describes the broad option as reaching people who are in your locations or "have shown an interest in" them. If the campaign is set to Presence, only a visitor placed in the country should see it, which a clean country exit reproduces; Ads Preview is set by the location you type.
- Ad fatigue in a reused profile. If the human check used a profile that searched the keyword yesterday, Google may be suppressing the ad for it, per the help text quoted earlier.
- Language. A campaign targeting German speakers will not show to a browser whose language says English. Match
hland the browser locale to the campaign's language setting. - A real serving gap. If all four check out and three days of morning captures miss the ad while Ads Preview shows it, you have evidence worth escalating with screenshots and timestamps.
The broader discipline of checking your own delivery, beyond search, is covered in how brands use proxies for ad verification. For running this across hundreds of keywords and several engines, our notes on proxies for SERP scraping at scale cover the throughput side.
One limit no setup removes: SparkProxy sells datacenter IPs, Google doesn't publish how it treats datacenter ranges, and we haven't measured a challenge rate for our exits on Google, so we quote none. The verdict column is how you find out for your own keyword set.
Frequently asked questions
FAQ
For your own campaigns, use the Ad Preview and Diagnosis tool in Google Ads and enter the target location and language. For competitors, the Ads Transparency Center filters an advertiser's ads by serving region. To see the live results page with its ad block as a local visitor gets it, you need an exit IP in that country.
It can. Google's help says self-searches "accumulate impressions without clicks," which can lower your clickthrough rate, and that repeatedly searching without clicking means "you might stop seeing it entirely." Use Ads Preview, which avoids accumulating impressions.
No. Google defines invalid clicks to include manual "Clicks that increase your ad costs" and clicks from automated tools. Fetch the landing page directly from the display URL or the Transparency Center instead of through the ad.
The usual causes, in order: the daily budget was spent by the time you checked, the location option differs from what you tested, the browser profile had ad fatigue, or the language did not match. Compare captures taken at the same local hour in a fresh profile before calling it a serving problem.
Use Sticky Sessions, which hold one IP for 5 minutes, when a person is browsing and paging in one window. Use Random Rotation for scheduled captures so every keyword is searched by a fresh visitor.
A rendered capture with country_code costs 10 credits. Forty keywords in four countries daily is about 60,000 credits a month with retry headroom, inside the $49 Starter plan as listed on 30 October 2026, and a first test fits in the 1,000 free credits.
Get 20% off your first month
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Save up to 15% more on quarterly, half-yearly and yearly plans
Related articles
Crawlee Proxy Configuration: Rotation That Keeps Sessions
Crawlee proxy setup with proxyUrls, newUrlFunction and tieredProxyUrls: how SessionPool binds a proxy, and how to stop the session-retired loop on login crawls.
Build Your Own Rank Tracker With Python and Proxies
Build your own rank tracker in Python: a keyword CSV, SERP fetches through rotating proxies or the Scraping API, Postgres history and a weekly movement report.
Squid cache_peer Upstream Proxy With Authentication
Set up a Squid cache_peer upstream proxy with login=user:pass, never_direct and LAN ACLs so tools without proxy auth can use a paid proxy. Fix TCP_MISS/503.
