How to Check Prices in Different Countries
Check prices in different countries with one script: fetch a product page from 8 exits, extract price and currency, convert at ECB rates, output a price matrix.
To check prices in different countries properly, you need more than a VPN and a browser tab: you need the same product page fetched from several countries in the same few minutes, the price and currency read from the page itself, every amount converted at one dated rate, and a way to tell whether the IP, the language header or a store cookie actually moved the number. This guide builds that as a small Python job using the SparkProxy Scraping API, and ends with a price matrix you can rerun daily.
It's written for the analyst who has to defend the numbers and for the shopper who suspects a subscription or game costs less elsewhere. The code is the same for both.
What you'll have at the end
A script, price_matrix.py, that takes a list of product URLs and a list of countries and writes two things: a long CSV with one row per URL and country (raw price, currency, how it was found, status, credits spent), and a pivoted matrix with every country's price converted into one target currency.
Here's the shape of the matrix. The listed prices below are hypothetical inputs chosen to show the arithmetic, not observations of a real store. The conversions use the European Central Bank reference rates dated 29 October 2026.
| Exit | Listed price | Currency | USD at ECB 2026-10-29 | vs cheapest |
|---|---|---|---|---|
| TR | 3,999 | TRY | 80.24 | 0% |
| JP | ¥14,800 | JPY | 93.64 | +16.7% |
| IN | ₹8,999 | INR | 94.46 | +17.7% |
| US | $99.99 | USD | 99.99 | +24.6% |
| BR | R$ 549,90 | BRL | 102.75 | +28.1% |
| DE | 99,00 € | EUR | 110.19 | +37.3% |
| GB | £89.99 | GBP | 119.84 | +49.4% |
Two columns matter more than they look. "Currency" is what the page declared, which is how you know the store believed your exit. And "USD at ECB" carries a date, so anyone rerunning your numbers next month knows why they differ.
Price as displayed still mixes tax regimes: EU and UK consumer prices usually include VAT, US prices usually don't. This post keeps displayed prices and flags that. If you need net-of-tax comparisons and a formal method, the geo pricing audit guide covers tax removal and evidence capture.
The four inputs that decide the price you see
A store picks your price from a handful of signals. Four come up again and again, and they behave very differently under a proxy.
| Input | What it is | Can a proxy change it? | How to hold it constant |
|---|---|---|---|
| IP address | The exit's geolocation, usually at country level | Yes, this is the whole point of a country exit | Pin `country_code` per request |
| Account region | The country stored on a logged-in account, often tied to billing details | No | Stay logged out, and say so in the results |
| Cookies | Store, currency or region choices saved from an earlier visit | Indirectly: a fresh session drops them, injection sets them | Start every request with an empty cookie jar |
| Accept-Language | The browser's language preference header ([RFC 9110, section 12.5.4](https://www.rfc-editor.org/rfc/rfc9110.html#name-accept-language)) | No, it's a header you set | Send the same value to every country |
Account region is the one people forget. On account-bound storefronts, a game store or app store for example, the price list belongs to the account's country, and a logged-out fetch only shows what the store serves an anonymous visitor from that IP. Changing a real account's region to see another price list is a terms-of-service question, not a scraping one, and it's outside what this script does.
Steam's own partner documentation shows how deliberate regional prices can be: publishers set prices in 37 currencies across 4 region groups, and Steam's built-in conversion helpers include a purchasing-power method alongside plain exchange rates (Steamworks pricing docs, read 30 October 2026). A gap you find there isn't necessarily a bug or a currency artefact. It may be policy.
For the background on how exits get their location, see what geo-targeting means in proxies. Here we only need country-level accuracy.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
How the job flows, end to end
The pipeline has five stages, and each one writes something you can inspect:
- Inputs. A CSV of product URLs and a dictionary mapping each exit country to the currency its storefront should show.
- Fetch. One Scraping API call per URL and country, with
country_codeset andjson_response=trueso you get the status code and credit count back. - Extract. Price and currency from JSON-LD, then microdata, then a visible-text fallback. Each result records which method found it.
- Validate. If the declared currency doesn't match the country's expected currency, the row is marked
wrong_region, not treated as a price. - Convert and pivot. Amounts are converted at one ECB rate set, written to SQLite, and pivoted into the matrix.
Validation is the stage most homemade checkers skip. A store that doesn't trust an exit often falls back to its home market and returns HTTP 200 with a perfectly parseable price. Without a currency check, that silent fallback looks like "same price everywhere".
Exit settings: rotation, Sticky Sessions, country and concurrency
SparkProxy gives you two ways to route the fetch, and they suit different parts of this job.
The Scraping API (https://scrape.sparkproxy.io/api/v1, key in the X-API-Key header) takes country_code as an ISO 3166-1 alpha-2 code and picks a fresh exit in that country for each request. That's the main path here, because the job needs a named country per fetch.
The proxy gateway (gateway.sparkproxy.io) is for teams driving their own browser. Port 11000 is Random Rotation, a new IP per request. Port 11002 is Sticky Sessions, which keeps one exit for 5 minutes. Port 13000 is SOCKS5. The plan pages describe a USA pool and a Global pool covering 80+ countries; for a specific country on every request, country_code on the API is the direct route.
| Setting | Use | Why |
|---|---|---|
| Rotation | API default, or Random Rotation on port 11000 | Each country check is independent; a new IP per check spreads requests out |
| Sticky Sessions | Only when your own browser must keep one IP across steps | For example, setting a region cookie on page one, then reading the cart on page two |
| Country | One `country_code` per request | A request should carry exactly one location signal |
| Concurrency | 4 to 8 parallel requests | Enough for 8 countries in one burst, well under the 50 concurrent requests of the API's Starter plan |
| Rendering | Try `render_js=false` first | If the page ships JSON-LD in its initial HTML, a plain fetch is enough; render only when that fails |
The rendering order matters for cost. A plain fetch with a country costs 1 + 5 = 6 credits, a rendered one 5 + 5 = 10, according to the Scraping API docs. Falling back to rendering only for pages that need it keeps most rows at 6.
Fetching one URL from eight countries
Start with settings. The country-to-currency map is the backbone of validation, so keep it explicit:
# price_matrix.py: settings
import os
API = "https://scrape.sparkproxy.io/api/v1"
API_KEY = os.environ["SPARKPROXY_API_KEY"]
# exit country -> currency the storefront should display if it believes the exit
COUNTRIES = {
"US": "USD", "GB": "GBP", "DE": "EUR", "FR": "EUR",
"JP": "JPY", "BR": "BRL", "IN": "INR", "TR": "TRY",
}
TARGET_CURRENCY = "USD"
MAX_WORKERS = 6
The fetch function turns on the JSON envelope, decodes the Base64 body, and handles the API's own throttling responses:
import base64
import json
import time
import requests
class FetchError(Exception):
pass
def fetch(url, country, render=False, headers=None, cookies=None, tag=None):
params = {
"url": url,
"country_code": country,
"render_js": "true" if (render or cookies) else "false",
"json_response": "true",
}
if headers:
params["forward_headers"] = json.dumps(headers)
if cookies:
params["cookies"] = json.dumps(cookies) # cookies need render_js=true
if tag:
params["tag"] = tag
for attempt in range(4):
r = requests.get(API, params=params,
headers={"X-API-Key": API_KEY}, timeout=240)
if r.status_code == 429:
time.sleep(int(r.json().get("retry_after_seconds", 5)))
continue
if r.status_code in (500, 503):
time.sleep(5 * 2 ** attempt)
continue
if r.status_code != 200:
raise FetchError(f"{r.status_code} {r.text[:300]}")
env = r.json()
html = base64.b64decode(env["body"]).decode("utf-8", errors="replace")
return {"status": env.get("status_code"),
"credits": env.get("credits_used") or 0,
"html": html}
raise FetchError("gave up after 4 attempts")
Three details here come straight from the docs. The page arrives Base64-encoded in body, next to job_id, status_code, duration_ms and credits_used, so decode it before searching it for anything. Don't reach for format=json to get that envelope: format only converts the content (html, md, mdx, json, screenshot, pdf), and the envelope comes only from json_response=true. And cookies requires render_js=true, which is why the function forces rendering when cookies are passed.
A quick manual check from the shell, before any Python, looks like this:
curl -sG "https://scrape.sparkproxy.io/api/v1" \
-H "X-API-Key: $SPARKPROXY_API_KEY" \
--data-urlencode "url=https://store.sparkproxy.io/product/annual-plan" \
--data-urlencode "country_code=JP" \
--data-urlencode "render_js=false" \
--data-urlencode "json_response=true" \
-o jp.json
python3 -c "import json,base64;print(base64.b64decode(json.load(open('jp.json'))['body'])[:400])"
Reading price and currency out of the page
Many retail and subscription pages publish a schema.org Offer in JSON-LD for search engines. Schema.org's own guidance for the price property says to put the currency in priceCurrency as an ISO 4217 code and to use a full stop as the decimal mark (schema.org/price). That makes structured data far easier to parse than the visible price, so try it first:
import re
LD_RE = re.compile(
r'<script[^>]+application/ld\+json[^>]*>(.*?)</script>', re.S | re.I)
META_PRICE = re.compile(
r'itemprop=["\']price["\'][^>]*content=["\']([^"\']+)', re.I)
META_CUR = re.compile(
r'itemprop=["\']priceCurrency["\'][^>]*content=["\']([A-Za-z]{3})', re.I)
def walk(node):
if isinstance(node, dict):
yield node
for v in node.values():
yield from walk(v)
elif isinstance(node, list):
for v in node:
yield from walk(v)
def extract_offer(html):
for block in LD_RE.findall(html):
try:
data = json.loads(block.strip())
except ValueError:
continue
for node in walk(data):
t = node.get("@type")
types = t if isinstance(t, list) else [t]
if "Offer" in types or "AggregateOffer" in types:
price = node.get("price", node.get("lowPrice"))
cur = node.get("priceCurrency")
if price is not None and cur:
return str(price), cur.upper(), "jsonld"
p, c = META_PRICE.search(html), META_CUR.search(html)
if p and c:
return p.group(1), c.group(1).upper(), "microdata"
return None
When a page has no structured data, you're left with visible text like 1.299,00 € or ¥14,800, and this is where cross-country scripts quietly break. A dot is a thousands separator in Germany and a decimal mark in the US. The fix is to let the currency's ISO 4217 minor units decide. The current ISO 4217 list (SIX, published 17 September 2026) gives JPY, KRW, ISK and CLP zero minor units, and KWD and BHD three:
MINOR_UNITS = {"JPY": 0, "KRW": 0, "ISK": 0, "CLP": 0, "KWD": 3, "BHD": 3}
def parse_amount(text, currency):
digits = re.sub(r"[^\d.,']", "", text).replace("'", "")
minor = MINOR_UNITS.get(currency, 2)
seps = digits.count(".") + digits.count(",")
last = max(digits.rfind("."), digits.rfind(","))
if last == -1:
return float(digits)
head = re.sub(r"[.,]", "", digits[:last])
tail = digits[last + 1:]
if len(tail) == 3 and minor == 3 and seps == 1:
raise ValueError(f"ambiguous amount {text!r} for {currency}")
if len(tail) == 3 and minor != 3:
return float(head + tail) # the separator was a thousands mark
if len(tail) <= max(minor, 2):
return float(f"{head}.{tail}")
raise ValueError(f"cannot parse {text!r} for {currency}")
We ran this against 13 formats on 30 October 2026. It read 1.299,00 € as 1299.00, CHF 1'299.00 as 1299.00, Indian lakh grouping ₹1,29,999 as 129999, ¥14,800 as 14800 and R$ 1.299,90 as 1299.90. It refused 1,299 in KWD, because with three minor units that string could be 1.299 dinars or 1,299 dinars. Refusing is the right behaviour: a wrong number in the matrix costs more than an empty cell.
If you'd rather not maintain regexes per store, the API's extract_rules parameter returns CSS-selected text in an extracted object. It replaces the normal response, so use it instead of json_response, never alongside it, and still run the text through parse_amount.
Converting everything with ECB reference rates
You need one rate set per run, from a source with a date on it. The ECB publishes euro reference rates as a small XML file, usually updated around 16:00 CET every working day, and says they are "published for information purposes only" (ECB reference rates page). That's exactly right for analysis and wrong for invoicing.
import xml.etree.ElementTree as ET
ECB_URL = "https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml"
def load_ecb_rates():
root = ET.fromstring(requests.get(ECB_URL, timeout=30).content)
day, rates = None, {"EUR": 1.0}
for el in root.iter():
if el.get("time"):
day = el.get("time")
if el.get("currency"):
rates[el.get("currency")] = float(el.get("rate"))
return day, rates
def convert(amount, currency, target, rates):
if currency not in rates or target not in rates:
return None # outside the ECB set: leave the cell empty, don't guess
return round(amount / rates[currency] * rates[target], 2)
Rates are quoted per euro, so any pair goes through EUR: divide by the source rate, multiply by the target rate. On the 29 October 2026 file, £89.99 becomes 89.99 / 0.83578 × 1.1130 = $119.84.
Check coverage before you choose countries. That file listed 29 currencies plus the euro itself. Turkish lira, Brazilian real, Indian rupee and Japanese yen are in it. Argentine peso isn't, and neither is the Russian rouble, two markets that show up often in "where is it cheapest" searches. For those, convert returns None on purpose. Add a second rate source if you need them, and record which source each cell used.
Turning observations into a price matrix
Each observation tries a plain fetch first and renders only if no price turns up. Then it checks the declared currency:
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
PLAIN_NUMBER = re.compile(r"\d+(\.\d+)?")
def observe(url, country, expected):
row = {"url": url, "country": country, "expected": expected,
"fetched_at": datetime.now(timezone.utc).isoformat(),
"status": "no_price", "credits": 0}
for render in (False, True):
try:
res = fetch(url, country, render=render, tag=f"geo-price/{country}")
except FetchError as e:
row.update(status="fetch_error", note=str(e)[:200])
continue
row["credits"] += res["credits"]
offer = extract_offer(res["html"])
if not offer:
continue
raw, cur, how = offer
try:
amount = float(raw) if PLAIN_NUMBER.fullmatch(raw) else parse_amount(raw, cur)
except ValueError as e:
row.update(status="unparsed", note=str(e))
break
row.update(price=amount, currency=cur, source=how, rendered=render,
status="ok" if cur == expected else "wrong_region")
break
return row
The pivot converts only ok rows and leaves every other status visible, because a column of wrong_region is itself a finding:
import csv
def build_matrix(urls, out_csv="observations.csv"):
day, rates = load_ecb_rates()
jobs = [(u, c, cur) for u in urls for c, cur in COUNTRIES.items()]
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
rows = list(pool.map(lambda j: observe(*j), jobs))
for r in rows:
if r["status"] == "ok":
r["converted"] = convert(r["price"], r["currency"], TARGET_CURRENCY, rates)
r["fx_date"] = day
fields = ["url", "country", "expected", "currency", "price", "converted",
"fx_date", "status", "source", "rendered", "credits",
"fetched_at", "note"]
with open(out_csv, "w", newline="", encoding="utf-8") as fh:
w = csv.DictWriter(fh, fieldnames=fields, extrasaction="ignore")
w.writeheader()
w.writerows(rows)
for url in urls:
cells = {r["country"]: r for r in rows if r["url"] == url}
good = [r["converted"] for r in cells.values() if r.get("converted")]
low = min(good) if good else None
print(url)
for c in COUNTRIES:
r = cells[c]
if r.get("converted") and low:
pct = (r["converted"] / low - 1) * 100
print(f" {c} {r['price']:>12,.2f} {r['currency']} "
f"{r['converted']:>9.2f} {TARGET_CURRENCY} +{pct:.1f}%")
else:
print(f" {c} {r['status']}")
return rows
if __name__ == "__main__":
with open("products.txt", encoding="utf-8") as fh:
build_matrix([line.strip() for line in fh if line.strip()])
All the elapsed time sits in the fetches, so the eight countries for one URL finish in roughly the time of the slowest rendered page. That keeps every country inside the same short window, which matters on sites that change prices during the day.
Finding out which input moved the price
A gap in the matrix tells you the price differs. It doesn't tell you why. To find out, pick one URL and one country where the gap appeared, and rerun it with one input changed at a time:
VARIANTS = {
"baseline": {},
"lang_en_us": {"headers": {"Accept-Language": "en-US,en;q=0.9"}},
"lang_local": {"headers": {"Accept-Language": "de-DE,de;q=0.9"}},
"store_cookie_us": {"cookies": [{"name": "store_country", "value": "US",
"domain": "store.sparkproxy.io", "path": "/"}]},
}
def isolate(url, country, repeats=3):
results = []
for name, opts in VARIANTS.items():
for i in range(repeats):
res = fetch(url, country, render=True, tag=f"isolate/{name}/{i}", **opts)
offer = extract_offer(res["html"])
results.append((name, i, offer[1] if offer else None,
offer[0] if offer else None))
for row in results:
print(*row)
return results
The cookie name is store-specific. Find it by switching the store's region selector in a normal browser and watching which cookie changes in the developer tools. Everything in VARIANTS goes through the API's documented forward_headers and cookies parameters.
Why repeats=3? With the API, every request gets a fresh exit in the chosen country and, per the docs, a fresh browser profile; session_id only labels logs and does not carry cookies between requests. So the IP changes between variants while the country stays fixed. Repeating each variant three times lets you see whether a change follows the variant or just the IP. If you need one IP held for the whole comparison, run the variants in your own browser through Sticky Sessions on port 11002 and finish inside the 5-minute window.
Reading the results is simple. If only the baseline country changes the price, the store prices on IP. If store_cookie_us brings the US price to a German exit, any buyer can pick their market, which is a very different story to report. If language changes the price, write that down twice, because it's rare and people won't believe it.
Scheduling, storage and failure handling
Once the matrix looks right by hand, run it on a schedule at the same time each day, so the time of day isn't a hidden variable. A crontab line for 06:15 UTC:
15 6 * * * cd /opt/price-matrix && SPARKPROXY_API_KEY=$(cat .key) /usr/bin/python3 price_matrix.py >> run.log 2>&1
CSV is fine for one run. For history, append observations to SQLite so you can chart a country's price over weeks:
CREATE TABLE IF NOT EXISTS observation (
fetched_at TEXT NOT NULL,
url TEXT NOT NULL,
country TEXT NOT NULL,
currency TEXT,
price REAL,
converted REAL,
fx_date TEXT,
status TEXT NOT NULL,
source TEXT,
credits INTEGER,
PRIMARY KEY (fetched_at, url, country)
);
CREATE INDEX IF NOT EXISTS obs_url_country ON observation (url, country, fetched_at);
Failures fall into two groups: ones the API reports, and ones that look like success. The API's own errors, verbatim from its docs:
| Status | Body you'll see | What the script does |
|---|---|---|
| 429 | `"error": "Concurrency limit reached"` with `retry_after_seconds` | Sleeps that long and retries |
| 429 | `"error": "Rate limit exceeded"`, `retry_after_seconds: 60` | Same; lower `MAX_WORKERS` if it repeats |
| 402 | `"error": "Insufficient credits"` with `credits_required` | Stops; nothing will succeed until you top up |
| 530 | `"reason": "captcha_blocked"` or a timeout reason | Tries the rendered fetch; credits are refunded on failure |
| 422 | `"error": "js_scenario requires render_js=true"` and similar | A bug in your parameters; fix, don't retry |
The second group costs you credits, because the API did its job and the store returned a page. wrong_region means the store didn't accept the exit and served a different market. no_price usually means a consent wall or a layout without structured data. Don't loop on these. Log them, look at a few by hand, and decide per store whether to add an extract_rules selector or accept that the store isn't measurable from that country.
Here's which of those problems a proxy can actually fix:
| Problem | Fixable with a proxy? |
|---|---|
| Store shows the wrong market for an exit | Sometimes: retry later or use `proxy_type=premium`, which the docs price at 10 credits without rendering or 25 with |
| Price set by logged-in account region | No |
| Leftover region cookie | No, use a fresh session (the API does this per request) |
| Ambiguous number format | No, it's a parsing problem |
| Currency missing from the ECB file | No, it's a data source problem |
What a daily run costs
The arithmetic is countries × URLs × credits per fetch. Take a working example: 40 product URLs, 8 countries, once a day, with a quarter of pages needing the rendered fallback.
- Plain fetches that succeed: 240 per day at 6 credits = 1,440.
- Pages that need rendering: 80 per day, each costing a failed plain attempt plus a rendered one, 6 + 10 = 16 credits = 1,280.
- Daily total: 2,720 credits. Over 30 days: 81,600.
That fits inside the Scraping API's Starter plan, listed on 30 October 2026 at $49 (USD) per month for 250,000 credits and 50 concurrent requests, with room for weekly isolation runs. The 1,000 free credits cover roughly 60 to 160 country checks, depending on how many need rendering, which is enough to test a few products before you pay anything. Credit maths in more detail is in Scraping API credit pricing explained.
If you'd rather drive your own browsers through the gateway, proxy plans are sold by thread count rather than traffic: "Starting from $75/month" for 100 threads, with unlimited bandwidth, so a heavy rendered page costs the same as a light one. Longer terms take 5%, 10% or 15% off for 3, 6 or 12 months, and there's a 24-hour free trial with no credit card.
The cheapest saving isn't in the plan. It's in the rendering fallback: every page that yields JSON-LD on a plain fetch costs 6 credits instead of 16. Check which of your stores need rendering in the first week and set render=True from the start only for those, so they stop paying for a failed plain attempt every day.
For subscription and SaaS plans specifically, where paywalls and sample sizes add their own problems, see proxies for SaaS pricing research.
Frequently asked questions
FAQ
Fetch the same product URL through country-targeted exits, one country per request, in the same few minutes. With the SparkProxy Scraping API that means setting country_code on each call, reading the price and priceCurrency from the page, and confirming the currency matches the country before you trust the number.
Either the store really doesn't price by region, or it didn't accept your exit and served its home market each time. Check the declared currency per country: if a Japanese exit returns USD, that row is a fallback, not a price.
Occasionally, but much less often than IP location or a store cookie. Test it by keeping the country fixed and sending two different Accept-Language values several times each; if the price follows the header rather than the IP, you've found a language-priced store.
No. Prices tied to a logged-in account's region or billing country aren't visible from a logged-out fetch, whatever the IP. A proxy changes the IP signal only, so record account-region pricing as out of scope.
Use one dated rate set for the whole run. The ECB's daily euro reference rates are free and citable, but they cover only around 30 currencies, so currencies like the Argentine peso need a second source, and every converted cell should record which source and date it used.
Daily at a fixed time is enough for most products, since it removes time of day as a variable. Run more often only on stores that change prices during the day, and rerun any gap you plan to report at least once more on a different day before acting on it.
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
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.
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.
