🎉 Premium Proxies · 24-Hour Free TrialClaim Now
Use Cases

How to Check Your Website From Different Countries

Check website from different countries with a script: compare status, redirects, language, currency and CDN edge per country, then find geo-blocks and WAF hits.

S SparkProxy 2 28 min read
Share

The fastest way to check website from different countries is to stop asking users abroad for screenshots and fetch the page yourself, through an exit in each of their countries, with every response header kept. A ticket that says "your site is broken in Brazil" usually hides one of five different faults: a country rule in your WAF, a redirect that loops, a cached page in the wrong language, a price in the wrong currency, or a problem on the user's own network that you can't touch at all.

This guide builds a small Python tool that requests one URL from eight countries plus your own connection, then prints one row per country: exit IP, status code, redirect hops, lang, Content-Language, the currency it found, which CDN edge answered, cache status and a verdict. You'll get the diagnosis order we use, the code, how to read the table, and the fix for each cause, including the ones a proxy can't help with.

What the report from abroad usually says

Users don't send headers. They send a sentence and, if you're lucky, a screenshot. Map the sentence to what you need to capture before you start guessing:

What the user saysWhat they probably sawWhat to capture from each country
"Access denied" or "you have been blocked"A WAF or CDN block page, often HTTP 403Status, block page text, Ray ID or request ID
"It keeps loading and then asks me to verify"A challenge page`cf-mitigated` header, page title
"The page goes blank" or "too many redirects"A redirect loop between locale pathsEvery `Location` hop
"It's in the wrong language"Locale chosen from IP, header or a cached copy`lang`, `Content-Language`, cache status
"The price is in dollars, I'm in Germany"Currency chosen from IP, or a cached variantCurrency symbol in the HTML, edge, cache status
"Unavailable for legal reasons"HTTP 451, a deliberate legal blockStatus and the `Link` header
"It works on Wi-Fi but not on my phone"Carrier network, DNS or the deviceNot reproducible from a proxy

Cloudflare's own wording is worth knowing by heart, because users quote it. Its documentation titles error 1009 "Access Denied: Country or region banned" and says the site owner "has banned the country or region your IP address from accessing the website." Error 1020 is "Access denied" from a security rule with a Block action, and Cloudflare says it returns it with HTTP 403. If a user pastes either one, ask for the Ray ID printed on the same page. You'll need it shortly.

What a country exit reproduces, and what it can't

A proxy exit in Germany makes your CDN, WAF and application believe the request came from a German IP. Anything they decide from the IP, you'll see. Anything decided further down the user's path, you won't.

CauseReproducible from a country exit?Why
WAF or CDN rule on countryYesThe rule reads the source IP's country, which the exit supplies
Geo-redirect or redirect loopYesSame input: the IP's country
Wrong currency or language picked from IPYesSame input again
Stale variant cached at one edgeUsuallyYou reach a nearby edge, though not always the user's exact one
Bot rule triggered by datacenter IP rangesPartlyYou'll see it, but real home users may not; confirm on a residential exit
The user's IP is mapped to the wrong countryOnly if you know their IPYou need their address to look up what your CDN thinks it is
National ISP or DNS-level blockingNoThat happens inside the user's ISP, not at your edge
Browser extensions, old browsers, corporate proxiesNoClient side
Card or payment-method refusal at checkoutNoDecided by the payment provider

The datacenter row matters more than it looks. SparkProxy's proxy pools are datacenter IPs, and some WAF rules treat hosting ranges differently from home broadband. A block you see from a datacenter exit is a lead, not a verdict, until you've tried the same country from a residential IP. Our comparison of residential vs datacenter proxies explains why the two get treated differently.

Geolocation databases also disagree with each other. AWS says CloudFront maps IPs to countries with a third-party database and gives its overall accuracy as 99.8% "based on recent tests", which is good but still means some real users land in the wrong country. If you need to check what different databases think of one address, our guide to validating IP geolocation accuracy walks through it. For the concept of country targeting itself, see what geo-targeting means in proxies.

Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

Diagnosis order, cheapest check first

Don't start with a script across forty countries. Work down this list and stop when you've found the cause:

  1. Collect the user's evidence. Time of the failure (with timezone), the exact URL, a screenshot, any Ray ID or request ID on the page, and their public IP if they're willing to share it.
  2. Search your own logs. Cloudflare's 1020 troubleshooting page tells site owners to search Security Events for the Ray ID or client IP; the event names the rule that blocked the request. This costs nothing and often ends the investigation.
  3. One request through one country exit. A single curl from the user's country tells you whether the problem reproduces at all.
  4. The full per-country table. If it reproduces, run every market at once to see whether it's one country, one region, or everyone except your home market.
  5. Split IP from Accept-Language. For wrong-language and wrong-currency reports.
  6. Residential confirmation. For blocks, before you change a security rule.
  7. A rendered pass. Only if the problem lives in JavaScript, such as a client-side geo-redirect or a currency switcher that runs after load.

The single-request check from step 3 looks like this. -sS -o /dev/null -D - prints only the response headers:

curl -sS -o /dev/null -D - \
  -x "http://$SPARKPROXY_USER-country-br:$SPARKPROXY_PASS@gateway.sparkproxy.io:11000" \
  -H "Accept-Language: pt-BR,pt;q=0.9" \
  "https://shop.sparkproxy.io/checkout"

If the status line and the edge header already look wrong, you have your reproduction. If they look fine, the problem is either intermittent, tied to one edge, or on the user's side, and the table below will help you tell which.

Setting up exits in eight countries

The tool uses the SparkProxy gateway at gateway.sparkproxy.io. Port 11000 is Random Rotation, a new exit per request. Port 11002 is Sticky Sessions, which holds one exit for 5 minutes. Port 13000 is SOCKS5. The credential example on SparkProxy's homepage shows the country as a username suffix (user-country-us); copy the exact format from your dashboard. You need the Worldwide Rotating pool for this job, because the USA Rotating pool is US-only.

DecisionChoice for this jobReason
RotationSticky Sessions (port 11002)Every redirect hop and the IP lookup should come from one exit, or a mid-chain change of IP can produce a loop that real users never see
CountriesWhere your tickets come from, plus your home market as a controlA control row shows what "normal" looks like on the same run
BaselineYour own connection, no proxySeparates "broken everywhere" from "broken abroad"
Concurrency4 workersNine rows finish in seconds; more threads only add noise to your WAF logs
RenderingOff for the main tableHeaders and redirects come from plain HTTP; render only for JavaScript-driven logic

Plans are packs billed by thread count, starting from $75/month with unlimited bandwidth, so heavy pages and repeated runs don't add traffic charges. Four workers barely touch a 100-thread Starter plan.

The signals worth recording per country

Status code alone hides most geo problems, because a wrong-language page and a soft block page both return 200. Record these for every country:

SignalWhere it comes fromWhat a bad value looks like
Exit IPAn IP echo through the same sessionNot in the country you asked for
StatusFinal response403, 451, 5xx, or 200 on a block page
Redirect hopsEach `Location` headerA loop, or a hop to the wrong locale
`lang`The `` attribute`en` on a page meant to be German
`Content-Language`Response headerMissing, or disagreeing with `lang`
CurrencyFirst price-like match in the HTML`$` where `€` was expected
Edge`cf-ray`, `X-Amz-Cf-Pop` or `X-Served-By`An edge far from the country, or one edge always serving the wrong variant
Cache`cf-cache-status` or `X-Cache``HIT` on the wrong variant
Challenge`cf-mitigated` header`challenge`

Each CDN tells you which edge answered in its own way. Cloudflare documents that Cf-Ray ends in "a three-letter code corresponding to the data center's location", with the example 230b030023ae2822-SJC. CloudFront responses carry X-Amz-Cf-Pop; one we fetched on 30 October 2026 read BLR50-P2, next to X-Cache: Hit from cloudfront. Fastly documents X-Served-By in the format cache-{datacenter}{nodeid}-{datacenter}, and warns that node and data center codes can be reused, so don't build logic on a specific node ID.

For challenges, Cloudflare documents a header rather than a page you have to parse: a challenge response "will have the cf-mitigated header present and set to challenge", and challenge is the only valid value. Use it before any text matching.

One caution from our own testing. Two plain requests from the same machine, a few seconds apart on 30 October 2026, reached Cloudflare's CDG edge on the first and LAX on the second. Anycast routing can move you between edges on back-to-back requests, so judge an edge problem from several runs, never one.

The per-country check script

The tool is one file, geocheck.py, shown here in four parts. Paste them in order. It needs Python 3 and requests; we ran it with Python 3.14.2 and requests 2.32.5.

Part one holds the configuration and builds a proxy URL per country. LOCAL means no proxy, which gives you the baseline row:

import os
import sys
from concurrent.futures import ThreadPoolExecutor

import requests

GATEWAY = "gateway.sparkproxy.io"
STICKY_PORT = 11002            # Sticky Sessions: one exit held for 5 minutes
USER = os.environ.get("SPARKPROXY_USER")
PASSWORD = os.environ.get("SPARKPROXY_PASS")

COUNTRIES = ["US", "GB", "DE", "FR", "BR", "IN", "JP", "AE"]
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
      "(KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36")


def proxies_for(cc):
    """None means 'go direct': the baseline row from your own network."""
    if cc == "LOCAL" or not USER:
        return None
    url = f"http://{USER}-country-{cc.lower()}:{PASSWORD}@{GATEWAY}:{STICKY_PORT}"
    return {"http": url, "https": url}

Part two follows redirects by hand. requests would follow them for you, but then you'd lose the hops, and the hops are where loops and wrong-locale jumps show up:

from urllib.parse import urljoin

MAX_HOPS = 8


def fetch_chain(url, cc, accept_language="en-US,en;q=0.9"):
    """Follow redirects by hand so every hop is recorded, all through one exit."""
    s = requests.Session()
    s.headers.update({"User-Agent": UA, "Accept-Language": accept_language})
    s.proxies = proxies_for(cc) or {}
    hops = []
    for _ in range(MAX_HOPS):
        r = s.get(url, allow_redirects=False, timeout=30)
        if r.is_redirect and "location" in r.headers:
            hops.append(f"{r.status_code}>{r.headers['location']}")
            url = urljoin(url, r.headers["location"])
            continue
        break
    try:
        ip = s.get("https://api.ipify.org", timeout=15).text.strip()
    except requests.RequestException:
        ip = "?"
    return r, hops, ip

Eight hops without a final page is itself a finding: the last row will show a 30x status and a long hop list, which is a loop.

Part three turns a response into signals and a verdict. The documented checks (cf-mitigated, status 451, CloudFront's error marker) come first; the title match for "Just a moment..." is a heuristic and is labelled as one in the output:

import re
from collections import Counter

CURRENCY = re.compile(r"(US\$|R\$|A\$|C\$|€|£|¥|₹|\$|AED|CHF|EUR|GBP|USD|INR|JPY|BRL)\s?\d")
LANG = re.compile(r"<html[^>]*\blang=[\"']?([A-Za-z-]+)", re.I)


def edge_pop(h):
    """Which CDN edge answered, from each CDN's own header."""
    if "cf-ray" in h:
        return "CF " + h["cf-ray"].rsplit("-", 1)[-1]          # e.g. ...-CDG
    if "x-amz-cf-pop" in h:
        return "CloudFront " + h["x-amz-cf-pop"]                # e.g. FRA56-P3
    if "x-served-by" in h:
        return "Fastly " + h["x-served-by"].split(",")[-1].rsplit("-", 1)[-1]
    return h.get("server", "-")


def verdict(r, body):
    h = r.headers
    if h.get("cf-mitigated") == "challenge":
        return "CHALLENGE (Cloudflare)"
    if r.status_code == 451:
        return "LEGAL BLOCK 451"
    if r.status_code == 403:
        if re.search(r"error\s*(code)?:?\s*1009", body, re.I):
            return "COUNTRY BAN (CF 1009)"
        if re.search(r"error\s*(code)?:?\s*1020", body, re.I):
            return "RULE BLOCK (CF 1020)"
        if "error from cloudfront" in h.get("x-cache", "").lower():
            return "403 CloudFront (geo/WAF?)"
        return "403 origin/WAF"
    if r.status_code >= 500:
        return f"SERVER {r.status_code}"
    if "Just a moment..." in body[:3000]:
        return "CHALLENGE? (title match)"
    return "ok"


def signals(r):
    body = r.text[:400_000]
    cur = Counter(m.group(1) for m in CURRENCY.finditer(body)).most_common(1)
    lang = LANG.search(body)
    return {
        "status": r.status_code,
        "lang": lang.group(1) if lang else "-",
        "c-lang": r.headers.get("content-language", "-"),
        "currency": cur[0][0] if cur else "-",
        "edge": edge_pop(r.headers),
        "cache": (r.headers.get("cf-cache-status")
                  or r.headers.get("x-cache", "-")).split(",")[0],
        "verdict": verdict(r, body),
    }

The currency regex picks the most frequent symbol that sits directly before a digit. That's crude on purpose: it's a tripwire for "dollars where euros should be", not a price parser. Swap in your own selector if your prices live in a known element.

Part four runs every country in parallel and prints the table:

def check(url, cc, accept_language="en-US,en;q=0.9"):
    try:
        r, hops, ip = fetch_chain(url, cc, accept_language)
        row = signals(r)
    except requests.RequestException as e:
        return {"cc": cc, "ip": "-", "verdict": f"PROXY/NET ERROR {type(e).__name__}"}
    row.update(cc=cc, ip=ip, hops=" ".join(hops) or "-", final=r.url)
    return row


COLS = ["cc", "ip", "status", "hops", "lang", "c-lang", "currency", "edge", "cache", "verdict"]


def print_table(rows):
    cell = lambda r, c: str(r.get(c, "-"))[:60]
    w = {c: max([len(c)] + [len(cell(r, c)) for r in rows]) for c in COLS}
    print("  ".join(c.upper().ljust(w[c]) for c in COLS))
    for r in rows:
        print("  ".join(cell(r, c).ljust(w[c]) for c in COLS))


def main(url, countries, workers=4):
    targets = ["LOCAL"] + countries
    with ThreadPoolExecutor(max_workers=workers) as pool:
        rows = list(pool.map(lambda cc: check(url, cc), targets))
    print_table(rows)
    return rows


if __name__ == "__main__":
    main(sys.argv[1], sys.argv[2].split(",") if len(sys.argv) > 2 else COUNTRIES)

Run it with python geocheck.py https://shop.sparkproxy.io/ DE,BR,JP, or with no country list to use all eight. A proxy failure is reported as its own verdict (PROXY/NET ERROR) so a dead exit never looks like a blocked site.

We ran the finished script on 30 October 2026 in baseline mode (no proxy credentials set) against three public sites on Cloudflare, Fastly and CloudFront, and it parsed each CDN's edge and cache headers correctly. We didn't have gateway credentials in that test environment, so the proxied rows weren't part of that run.

Reading the table

Here's an illustrative run against a hypothetical shop at shop.sparkproxy.io. The IPs come from the documentation ranges, and the faults were chosen to show one of each kind:

CC     IP            STATUS  HOPS                 LANG  C-LANG  CURRENCY  EDGE     CACHE    VERDICT
LOCAL  198.51.100.7  200     -                    en    en      $         CF IAD   HIT      ok
US     203.0.113.10  200     -                    en    en      $         CF EWR   HIT      ok
GB     203.0.113.21  200     302>/uk/             en    en-GB   £         CF LHR   HIT      ok
DE     203.0.113.33  200     302>/de/             en    -       $         CF FRA   HIT      ok
FR     203.0.113.45  302     302>/fr/ 302>/ ...   -     -       -         CF CDG   -        ok
BR     203.0.113.58  403     -                    -     -       -         CF GRU   -        COUNTRY BAN (CF 1009)
IN     203.0.113.62  200     -                    en    en      ₹         CF BOM   DYNAMIC  ok
JP     203.0.113.77  403     -                    -     -       -         CF NRT   -        RULE BLOCK (CF 1020)
AE     203.0.113.89  200     -                    en    en      $         CF DXB   HIT      CHALLENGE? (title match)

Read it row by row, not as a score:

  • GB is healthy: a redirect to /uk/, British English declared, pounds on the page.
  • DE redirected correctly to /de/, but the page says lang="en", sends no Content-Language, shows dollars and is a cache HIT. That's the signature of a variant cached without the country in its cache key. The first German visitor after an American got the American page.
  • FR never reached a page. Eight hops alternating between /fr/ and / is a loop: one rule sends French IPs to /fr/, another sends /fr/ back to / when no locale cookie is set.
  • BR is a deliberate country rule in the WAF, or one someone forgot about.
  • JP is a different block: a security rule matched, not a country ban. Search the Ray ID from that row in Security Events. Cloudflare notes that requests blocked by WAF custom or managed rules get the WAF block response rather than a 1020, so a plain 403 origin/WAF row that still carries a cf-ray can be your own Cloudflare rule too.
  • IN has a dynamic, uncached page with rupees. Fine.
  • AE returned 200 but the title looked like a challenge, without cf-mitigated. Open the saved body before you believe it either way.

The useful pattern to remember: a problem in exactly one country with a cache HIT is a caching problem, and a problem in one country with no cache status at all is usually a rule.

IP or Accept-Language: which one picked the variant

Wrong-language tickets have two common causes that look identical from the user's side. Either the site picks the locale from the IP, or it reads the browser's Accept-Language header. A traveller with an English browser in Germany gets different results under each design, and the fix differs too.

Run each country twice from the same sticky exit, once with a neutral English header and once with the local language, and compare the redirect, lang and currency:

LOCAL_LANG = {"DE": "de-DE,de;q=0.9", "FR": "fr-FR,fr;q=0.9", "BR": "pt-BR,pt;q=0.9",
              "JP": "ja-JP,ja;q=0.9", "AE": "ar-AE,ar;q=0.9", "IN": "hi-IN,en-IN;q=0.8"}


def split_signals(url, cc, baseline):
    """Same exit, two Accept-Language values: which signal drives the variant?"""
    neutral = check(url, cc, "en-US,en;q=0.9")
    native = check(url, cc, LOCAL_LANG.get(cc, "en-US,en;q=0.9"))
    keys = ("hops", "lang", "currency")
    by_header = [k for k in keys if neutral.get(k) != native.get(k)]
    by_ip = [k for k in keys if neutral.get(k) != baseline.get(k)]
    return {"cc": cc,
            "header_driven": ",".join(by_header) or "-",
            "ip_driven": ",".join(by_ip) or "-"}


us = check("https://shop.sparkproxy.io/", "US")
for cc in ["DE", "FR", "JP"]:
    print(split_signals("https://shop.sparkproxy.io/", cc, us))

If ip_driven lists lang and header_driven is empty, the site ignores the browser and trusts the IP, so every English-speaking visitor in Germany gets German. If it's the other way round, the IP doesn't matter and the user's browser settings explain the ticket. A mix usually means two layers disagree, for example a CDN rule redirecting by country and an application choosing language by header.

What the right behaviour is for your audience is a product decision, and our post on proxies for localization testing covers the hreflang and translation side in depth. This test only tells you which input your site is actually using, which is usually the thing nobody on the team can answer with confidence.

Confirming a block from a residential exit

Before you loosen a security rule because Brazil looks blocked, make sure the rule is about Brazil and not about datacenter IPs. Repeat the request from a residential IP in the same country. The SparkProxy Scraping API does this in one call: proxy_type=premium selects the premium pool, which the docs describe as residential, and country_code picks the country.

Two details from the API docs decide how you read the result. json_response=true is what returns the envelope (job_id, status_code, duration_ms, credits_used and body), and the body is Base64, so decode it before looking for block markers. Don't use format=json for this: format only converts page content (html, md, mdx, json, screenshot, pdf) and never returns the envelope.

import base64
import os

import requests

API = "https://scrape.sparkproxy.io/api/v1"
MARKERS = ("1009", "1020", "Access denied", "Just a moment...")


def residential_check(url, cc, render=False):
    params = {
        "url": url,
        "country_code": cc,
        "proxy_type": "premium",
        "render_js": "true" if render else "false",
        "json_response": "true",
        "tag": f"geocheck:{cc}",
    }
    r = requests.get(API, params=params, timeout=200,
                     headers={"X-API-Key": os.environ["SPARKPROXY_API_KEY"]})
    if r.status_code == 530:                     # scrape failed; credits refunded
        err = r.json()
        return {"cc": cc, "result": "530", "reason": err.get("reason"),
                "captcha_type": err.get("captcha_type")}
    r.raise_for_status()
    env = r.json()
    html = base64.b64decode(env.get("body") or "").decode("utf-8", errors="replace")
    hits = [m for m in MARKERS if m in html[:20000]]
    return {"cc": cc, "result": env.get("status_code"),
            "credits": env.get("credits_used"), "markers": hits}


print(residential_check("https://shop.sparkproxy.io/checkout", "BR"))

Read it against the datacenter row. Blocked on both means the rule is about the country, and real Brazilian users hit it. Blocked on datacenter but fine on residential means the rule targets hosting networks, which real home users don't come from; leave it alone unless your customers browse from cloud desktops or corporate VPNs. A 530 whose captcha_type is cloudflare_turnstile means even the residential request was challenged.

Turn on render=True only when the geo logic runs in the browser, such as a JavaScript redirect or a currency switcher that fires after load. On the API docs' credit table, a premium request costs 10 credits without rendering and 25 with it, and country_code adds 5, so this check costs 15 or 30 credits per country. Failed requests are refunded. When we tried the API on 30 October 2026 it returned HTTP 521 from our test machine, so we haven't included a live result from it here.

Fixes for each cause

Match the verdict to a fix. The right-hand column is the honest part: some of these are yours to change, and some aren't.

FindingFixWho owns it
Country ban (CF 1009) you didn't intendRemove or narrow the IP Access rule or country ruleYou
Rule block (CF 1020)Find the rule from the Ray ID in Security Events, then adjust itYou
403 from CloudFront in one country onlyCheck the distribution's geographic restriction list, then AWS WAF geo matchesYou
Redirect loop between locale pathsRedirect by country only when the visitor hasn't chosen a localeYou
Wrong currency or language on a cache `HIT`Put the country in the cache keyYou
Blocked on datacenter, fine on residentialUsually nothing; it's working as designedYou decide
HTTP 451Intended legal block; check the `Link` header for the blocking authorityLegal
Fine from every exit, broken for the userTheir ISP, DNS, device or networkNot fixable from your side

Cloudflare country rules. If you do want to restrict a country, prefer a Managed Challenge over a Block, and exempt paths such as your status page. Cloudflare's rules language uses ip.src.country, which its docs describe as the two-letter ISO 3166-1 code (the older ip.geoip.country field is deprecated). A rule expression that only covers the checkout path:

(ip.src.country in {"BR" "AR"} and starts_with(http.request.uri.path, "/checkout"))

CloudFront 403s. AWS's 403 troubleshooting page lists seven causes, from a wrong alternate CNAME and AWS WAF to signed URL problems, with geographic restrictions as just one of them. A 403 everywhere is rarely geo. A 403 in one country while the rest return 200 points straight at the geo restriction list or a WAF geo match rule. When we requested a missing object through CloudFront on 30 October 2026, the 403 came back with X-Cache: Error from cloudfront, which is what the script's verdict keys on.

Redirect loops. The loop in the FR row comes from redirecting on country even after the visitor has picked a locale. Make the country redirect conditional on the absence of a locale cookie. In nginx behind Cloudflare, where CF-IPCountry carries the visitor's country code, one map avoids nested if blocks:

map "$cookie_locale:$http_cf_ipcountry" $geo_redirect {
    default      "";
    "~^:DE$"     "/de/";
    "~^:FR$"     "/fr/";
    "~^:GB$"     "/uk/";
}

server {
    listen 443 ssl;             # ssl_certificate lines omitted
    server_name shop.sparkproxy.io;

    location = / {
        if ($geo_redirect) {
            return 302 $geo_redirect;
        }
        try_files /index.html =404;
    }
}

A visitor with any locale cookie never matches a pattern, so the country redirect fires once at most, and the locale pages must not redirect back to /.

Cached variants in the wrong country. If the page varies by country, the cache has to know that. Cloudflare's guide to serving tailored content shows a URL Rewrite Rule that turns /products/item into /products/item?loc=ca for visitors from Canada, "creating a distinct cache entry". On other CDNs, add the country to the cache key or stop caching the localized HTML.

HTTP 451. RFC 7725 defines it as "Unavailable For Legal Reasons" and says the response should carry a Link header with rel="blocked-by" naming the entity doing the blocking. If you return 451 deliberately, include it; if a user reports a 451 you didn't send, it came from somewhere between you and them.

The user's own path. If every exit in their country returns a clean page, ask for their public IP and look it up in your CDN's logs. If your CDN thinks they're in another country, that's a geolocation database error, and the fix is to report it to the database vendor and add an allow rule meanwhile. If the CDN never saw their request, the block is in their network, and no proxy will reproduce it.

Scheduling, storage and monthly cost

Once a diagnosis is done, keep the script running. Geo problems come back with every new WAF rule, CDN change and pricing deploy, and a daily table catches them before tickets do. Append each run to a JSON Lines file and print only what changed:

import json
import time
from pathlib import Path

LOG = Path("geocheck.jsonl")


def record_and_diff(url, rows):
    """Append this run, then report countries whose verdict or status changed."""
    prev = {}
    if LOG.exists():
        for line in LOG.read_text(encoding="utf-8").splitlines():
            rec = json.loads(line)
            if rec["url"] == url:
                prev[rec["cc"]] = rec
    stamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
    changes = []
    with LOG.open("a", encoding="utf-8") as f:
        for r in rows:
            r = {**r, "url": url, "ts": stamp}
            f.write(json.dumps(r, ensure_ascii=False) + "\n")
            old = prev.get(r["cc"])
            if old and (old.get("verdict"), old.get("status")) != (r.get("verdict"), r.get("status")):
                changes.append(f"{r['cc']}: {old.get('verdict')}/{old.get('status')} -> "
                               f"{r.get('verdict')}/{r.get('status')}")
    return changes


for url in ["https://shop.sparkproxy.io/", "https://shop.sparkproxy.io/checkout"]:
    for line in record_and_diff(url, main(url, COUNTRIES)):
        print("CHANGED", line)

Run it from cron a few times a day; a timestamped log doubles as evidence when a user says it was broken yesterday:

15 */6 * * * cd /opt/geocheck && /usr/bin/python3 daily.py >> logs/geocheck.log 2>&1

Two rules keep the alerts honest. Treat PROXY/NET ERROR rows as gaps, not outages; one failed exit shouldn't page anyone. And alert on a verdict change that persists across two consecutive runs, because single edge readings move. If you're building a full alerting setup around this, proxies for uptime and synthetic monitoring covers alert design and thread sizing.

Cost. Prices below are in US dollars and exclude VAT/GST, read from SparkProxy's homepage and Scraping API page on 30 October 2026.

PieceWhat it costsWhat this job uses
Proxy plan, Starter$75/month, 100 threads, unlimited bandwidth4 threads; 10 URLs x 9 rows x 4 runs a day fits easily
Longer terms5% off for 3 months, 10% for 6, 15% for 12Optional
Scraping API, residential check15 credits per country plain, 30 renderedOnly when a block needs confirming
Scraping API, Starter$49/month for 250,000 credits, 50 concurrent requestsIllustrative: 10 URLs x 8 countries x 15 credits daily is 36,000 credits a month
Free trials24-hour proxy trial, no credit card; 1,000 free API creditsEnough to run the whole diagnosis once

Because the proxy plan is billed by threads rather than gigabytes, saving full HTML for every country on every run doesn't change the bill. Keep the bodies for blocked rows at least; they're what you'll show the CDN's support team.

Frequently asked questions

FAQ

Send the request through a proxy exit in each country and record the status, redirects, language, currency and CDN edge headers for each one. A script like the one in this guide does all countries in one run and prints a table, which a VPN that switches one country at a time can't do.

Either the site picks the locale from the visitor's IP, it reads the browser's Accept-Language header, or a CDN cached one country's page and served it to another. Running each country twice with different Accept-Language values, and checking the cache status, tells you which.

Fetch it from an exit in that country and look for HTTP 403 with Cloudflare error 1009, HTTP 451, or a 403 from CloudFront that other countries don't get. Then repeat from a residential IP in the same country to confirm the rule is about the country and not datacenter IPs.

Cloudflare documents 1009 as "Access Denied: Country or region banned": the site owner has blocked the country or region the visitor's IP is in. If you didn't mean to, check your IP Access rules and any custom rules matching on country.

Sticky Sessions. All redirect hops and the IP lookup should come from one exit, otherwise an IP change mid-chain can create a redirect pattern real users never see. Random Rotation suits jobs where every request is independent.

No. A country exit reproduces anything your CDN, WAF or application decides from the IP, such as country rules, geo-redirects and currency. It can't reproduce ISP or DNS-level blocking inside a country, browser or device problems, or payment providers refusing a local card.

Special Discount · 20% off

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

Claim Discount

About the author

Written by the SparkProxy Technical Team, the engineers behind SparkProxy's proxy network (1M+ premium IPs across 80+ countries, HTTP/HTTPS and SOCKS5, IP whitelist and user:pass auth) and the SparkProxy Scraping API. Error meanings and header formats come from Cloudflare's developer documentation (Ray ID, HTTP headers, challenge detection, errors 1009 and 1020, rules fields), AWS CloudFront's geographic restrictions and 403 troubleshooting pages, Fastly's header reference and RFC 7725, all read on 30 October 2026. The example table is illustrative; the edge readings described as measured come from our own requests on that date. Corrections: support@sparkproxy.io.

Keep reading

Related articles