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

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.

S SparkProxy 3 23 min read
Share

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.

ExitListed priceCurrencyUSD at ECB 2026-10-29vs cheapest
TR3,999TRY80.240%
JPÂ¥14,800JPY93.64+16.7%
IN₹8,999INR94.46+17.7%
US$99.99USD99.99+24.6%
BRR$ 549,90BRL102.75+28.1%
DE99,00 €EUR110.19+37.3%
GB£89.99GBP119.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.

InputWhat it isCan a proxy change it?How to hold it constant
IP addressThe exit's geolocation, usually at country levelYes, this is the whole point of a country exitPin `country_code` per request
Account regionThe country stored on a logged-in account, often tied to billing detailsNoStay logged out, and say so in the results
CookiesStore, currency or region choices saved from an earlier visitIndirectly: a fresh session drops them, injection sets themStart every request with an empty cookie jar
Accept-LanguageThe 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 setSend 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.

Free trial

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:

  1. Inputs. A CSV of product URLs and a dictionary mapping each exit country to the currency its storefront should show.
  2. Fetch. One Scraping API call per URL and country, with country_code set and json_response=true so you get the status code and credit count back.
  3. Extract. Price and currency from JSON-LD, then microdata, then a visible-text fallback. Each result records which method found it.
  4. Validate. If the declared currency doesn't match the country's expected currency, the row is marked wrong_region, not treated as a price.
  5. 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.

SettingUseWhy
RotationAPI default, or Random Rotation on port 11000Each country check is independent; a new IP per check spreads requests out
Sticky SessionsOnly when your own browser must keep one IP across stepsFor example, setting a region cookie on page one, then reading the cart on page two
CountryOne `country_code` per requestA request should carry exactly one location signal
Concurrency4 to 8 parallel requestsEnough for 8 countries in one burst, well under the 50 concurrent requests of the API's Starter plan
RenderingTry `render_js=false` firstIf 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:

StatusBody you'll seeWhat 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 reasonTries the rendered fetch; credits are refunded on failure
422`"error": "js_scenario requires render_js=true"` and similarA 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:

ProblemFixable with a proxy?
Store shows the wrong market for an exitSometimes: 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 regionNo
Leftover region cookieNo, use a fresh session (the API does this per request)
Ambiguous number formatNo, it's a parsing problem
Currency missing from the ECB fileNo, 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.

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. SparkProxy runs a datacenter proxy network of 1M+ IPs across 80+ countries, with Random Rotation, Sticky Sessions and SOCKS5 on gateway.sparkproxy.io, plus a Scraping API with country targeting, rendering and structured extraction. Credit costs and plan prices in this guide were read from our own docs and pricing pages on 30 October 2026. Questions or corrections: support@sparkproxy.io.

Keep reading

Related articles