David Lewin David Lewin
Proxies & Web Scraping

Geo-targeted price checks: currency, VAT and geo-redirect pitfalls with residential proxies

Disclosure: some links in this guide are affiliate links. If you sign up through them, I may earn a commission at no extra cost to you. How this site makes money.

Short answer

To see the price a shopper in another country sees, send the request from a residential IP in that country, keep the same IP for the whole redirect chain with a sticky session, keep the cookies the store sets on the way, and send a matching Accept-Language header. Then read the price and currency from the page's schema.org Product data, convert it with a daily reference rate such as the ECB's, and strip VAT before you compare, because EU and UK prices include VAT and US prices don't include sales tax. Most failed geo checks are redirect loops caused by a rotating IP, a missing cookie jar or a language header that contradicts the IP.

The same product page can show a different price, a different currency and a different stock level depending on where the visitor appears to be. Stores do this on purpose: regional price lists, prices that include VAT in Europe and exclude sales tax in the US, separate warehouses per market, and redirects that send each visitor to their country's storefront. If you monitor competitor prices across markets, you have to see each market the way a local shopper does. This guide covers what changes by location, why geo checks so often end in redirect loops, and a tested script that checks one SKU from several countries through residential proxies.

How this guide was made

Proxy prices and session syntax come from Infatica's and IPRoyal's pricing pages and documentation, checked in October 2026. The Python script below was tested against a mock store that redirects by country, sets a market cookie mid-chain and contains a deliberate redirect loop, with live ECB exchange rates from the Frankfurter API. It wasn't tested against real stores. Bandwidth per check and the cost examples are our estimates.

1. What changes with the visitor's location

  • Storefront and URL. Many stores redirect by IP to a country subfolder or subdomain, such as /de/, /en-gb/ or uk.example.com. The product URL you requested may never return a page directly.
  • Currency. Usually set by the storefront, sometimes by a currency cookie or selector that overrides the IP. Always read the currency from the page instead of assuming it from the country.
  • Price list. Regional prices are often set per market, not converted from one base price. A $24 mug can be €24.99 in Germany and £21.99 in the UK, and neither is $24 at today's rate.
  • Tax display. EU and UK consumer prices include VAT (19% in Germany, 20% in France and the UK). US prices are shown before sales tax, which is added at checkout. Comparing gross prices across these markets overstates the European price.
  • Stock and shipping. Stock is often per warehouse or per market, so a product can be in stock in one country and sold out in another.

2. Why geo checks end in redirect loops

A redirect loop is the most common failure in geo checks: the store bounces the request between two URLs until the client gives up. The usual causes are on the scraper's side.

Geo-redirect loop: rotating IP, no cookies
Geo-redirect loop: rotating IP, no cookiesGET /products/mug → 302 to /fr/products/mug → Next hop: a new IP and no cookie → 302 back to /products/mug → LoopGET /products/mug: Rotating residential IP, exit in FranceGET /products/mugRotating residential IP, exit in France302 to /fr/products/mug: The store sets a market=fr cookie302 to /fr/products/mugThe store sets a market=fr cookieNext hop: a new IP and no cookie: The client dropped the cookie, or the pool rotated to an IP in another countryNext hop: a new IP and no cookieThe client dropped the cookie, or the pool rotated to an IP inanother country302 back to /products/mug: The /fr/ storefront can't confirm the market, so it sends you back302 back to /products/mugThe /fr/ storefront can't confirm the market, so it sends youbackLoop: The client stops after its redirect limit. No price, but the bandwidth is billed.LoopThe client stops after its redirect limit. No price, but thebandwidth is billed.
Each hop is a new decision by the store. If the IP or the cookies change between hops, it can disagree with itself. Hover or tab through the boxes for details.
Clean geo check: sticky local IP, cookie jar, matching language
Clean geo check: sticky local IP, cookie jar, matching languageGET /products/mug → 302 to /fr/products/mug → Same IP, cookie sent back → 200 with the French priceGET /products/mug: Sticky session in France, Accept-Language fr-FRGET /products/mugSticky session in France, Accept-Language fr-FR302 to /fr/products/mug: market=fr cookie kept in the cookie jar302 to /fr/products/mugmarket=fr cookie kept in the cookie jarSame IP, cookie sent back: The storefront confirms the marketSame IP, cookie sent backThe storefront confirms the market200 with the French price: EUR price and stock from the Product JSON-LD200 with the French priceEUR price and stock from the Product JSON-LD
The same request with one IP for the whole chain, cookies kept and a language that matches the country.
  • Rotating IPs mid-chain. If each request gets a new exit IP, each redirect hop can land in a different city or even a different country. Use a sticky session for the length of one check. On IPRoyal you add _session- with an 8-character ID and _lifetime- to the password. On Infatica you set the rotation time on the proxy list in the dashboard.
  • No cookie jar. Many stores set a market or locale cookie on the first redirect and expect it back on the next. Without it, the localized page sends you to the default storefront, which sends you back.
  • Language header that contradicts the IP. Some stores also redirect on Accept-Language. A German IP with en-US can be sent to an English storefront that then redirects back by IP.
  • Datacenter IPs. Geo databases often map datacenter ranges to the hosting company's country, and many stores block them. Residential IPs are mapped to where the household is.

3. The script: one SKU, several countries

Standard library only. It checks a product URL from each country in a proxy map, follows redirects with a cookie jar, stops on a loop, reads price, currency and stock from the page's schema.org Product JSON-LD, and converts everything to US dollars with the day's ECB reference rates.

#!/usr/bin/env python3
"""Check one product's price and stock from several countries through residential proxies.
Python 3.9+, standard library only.

  python3 geo_price.py proxies.json https://example-store.com/products/blue-mug

proxies.json maps a country code to a proxy URL with a sticky session, for example:
  {"US": "http://USER:[email protected]:12321",
   "DE": "http://login:password@HOST:PORT"}
"""
import json
import os
import re
import sys
import urllib.error
import urllib.request
from http.cookiejar import CookieJar

# ECB reference rates, free and without a key
FX_API = os.environ.get('FX_API', 'https://api.frankfurter.dev/v1/latest?base=USD')
LANG = {'US': 'en-US', 'GB': 'en-GB', 'DE': 'de-DE', 'FR': 'fr-FR', 'IT': 'it-IT', 'ES': 'es-ES',
        'NL': 'nl-NL', 'JP': 'ja-JP', 'CA': 'en-CA', 'AU': 'en-AU', 'BR': 'pt-BR'}
# Standard rates. US and Canadian prices are shown before tax, which is added at checkout.
VAT = {'GB': 20, 'DE': 19, 'FR': 20, 'IT': 22, 'ES': 21, 'NL': 21, 'JP': 10, 'AU': 10}
MAX_HOPS = 8


class Tracker(urllib.request.HTTPRedirectHandler):
    """Follow redirects ourselves so we can record the chain and stop on a loop."""
    def __init__(self):
        self.chain = []

    def redirect_request(self, req, fp, code, msg, headers, newurl):
        self.chain.append((code, newurl))
        if [u for _, u in self.chain].count(newurl) > 1 or len(self.chain) > MAX_HOPS:
            raise urllib.error.HTTPError(newurl, code, 'redirect loop', headers, fp)
        return super().redirect_request(req, fp, code, msg, headers, newurl)


def fetch(url, country, proxy):
    tracker = Tracker()
    opener = urllib.request.build_opener(
        urllib.request.ProxyHandler({'http': proxy, 'https': proxy}),
        # Cookies set mid-chain must come back on the next hop, or many stores loop
        urllib.request.HTTPCookieProcessor(CookieJar()),
        tracker)
    opener.addheaders = [
        ('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/141.0 Safari/537.36'),
        ('Accept-Language', LANG.get(country, 'en') + ',en;q=0.5')]
    try:
        with opener.open(url, timeout=60) as r:
            return r.geturl(), tracker.chain, r.read().decode('utf-8', 'replace'), None
    except urllib.error.HTTPError as e:
        return None, tracker.chain, '', 'redirect loop' if e.msg == 'redirect loop' else 'HTTP %d' % e.code
    except OSError as e:
        return None, tracker.chain, '', 'network: %s' % e


def offer(html):
    """Price, currency and availability from the page's schema.org Product JSON-LD."""
    for block in re.findall(r'<script[^>]+application/ld\+json[^>]*>(.*?)</script>', html, re.S | re.I):
        try:
            data = json.loads(block)
        except ValueError:
            continue
        items = data if isinstance(data, list) else data.get('@graph', [data])
        for item in items:
            if 'Product' not in str(item.get('@type')):
                continue
            o = item.get('offers') or {}
            o = o[0] if isinstance(o, list) else o
            price = o.get('price', o.get('lowPrice'))
            if price is not None:
                stock = str(o.get('availability', '')).rsplit('/', 1)[-1]
                return float(price), o.get('priceCurrency'), stock
    return None, None, None


def main():
    proxies = json.load(open(sys.argv[1]))
    url = sys.argv[2]
    # Frankfurter returns 403 to Python's default User-Agent
    fx = urllib.request.Request(FX_API, headers={'User-Agent': 'geo-price-check/1.0'})
    rates = json.load(urllib.request.urlopen(fx, timeout=30))['rates']  # 1 USD = rates[cur]
    rates['USD'] = 1.0
    row = '%-3s %-9s %10s %9s %9s  %-12s %s'
    print(row % ('', 'currency', 'price', 'USD', 'ex-VAT', 'stock', 'final URL / problem'))
    for country, proxy in proxies.items():
        final, chain, html, err = fetch(url, country, proxy)
        price, cur, stock = offer(html) if html else (None, None, None)
        if err or price is None:
            hops = ' -> '.join('%d %s' % c for c in chain)
            problem = err or 'no Product price found at ' + final
            print('%-3s %s%s' % (country, problem, '  [%s]' % hops if hops else ''))
            continue
        usd = price / rates[cur] if cur in rates else None
        net = usd / (1 + VAT.get(country, 0) / 100) if usd is not None else None
        fmt = lambda v: '%.2f' % v if v is not None else 'n/a'
        print(row % (country, cur, '%.2f' % price, fmt(usd), fmt(net), stock, '%s (%d redirects)' % (final, len(chain))))


if __name__ == '__main__':
    main()

The proxy map is a JSON file with one sticky residential proxy per country:

{
  "US": "http://USER:[email protected]:12321",
  "DE": "http://USER:[email protected]:12321",
  "GB": "http://LOGIN:PASSWORD@HOST:PORT"
}

The first two lines use IPRoyal's syntax: the country, session ID and lifetime go in the password. The third is an entry copied from an Infatica proxy list, where you choose the country and rotation time when you create the list in the dashboard.

Output from our test run against the mock store:

    currency       price       USD    ex-VAT  stock        final URL / problem
US  USD            24.00     24.00     24.00  InStock      http://store.test/products/mug (0 redirects)
DE  EUR            24.99     28.05     23.57  InStock      http://store.test/de/products/mug (1 redirects)
FR  EUR            25.99     29.17     24.31  InStock      http://store.test/fr/products/mug (1 redirects)
GB  GBP            21.99     29.31     24.43  OutOfStock   http://store.test/uk/products/mug (1 redirects)
JP  JPY          3800.00     24.01     21.83  InStock      http://store.test/jp/products/mug (1 redirects)
BR  redirect loop  [302 http://store.test/intl/products/mug -> 302 http://store.test/products/mug -> 302 http://store.test/intl/products/mug]

The gross prices make Germany, France and the UK look 17% to 22% more expensive than the US. Ex-VAT, the gap is 2% or less, and the UK is the one market where the mug is out of stock. When we ran the same mock without a cookie jar, the French check looped between /fr/ and the default storefront, as in the first diagram.

  • Currency conversion. The Frankfurter API serves the European Central Bank's reference rates for free without a key. The ECB publishes them once a working day, for about 30 currencies. They're fine for comparing price levels, but a store's own conversion uses its own rates and rounding. Currencies the ECB doesn't publish show as n/a.
  • VAT. The VAT table holds standard rates. Reduced rates apply to some product categories, such as books and food. Edit it for your products. US and Canadian prices are left as they are, because sales tax is added at checkout.
  • Pages without JSON-LD. Most large stores publish Product JSON-LD for search engines. If a store doesn't, or if the price loads with JavaScript, the script reports "no Product price found". For Shopify stores, our Shopify competitor price guide reads prices from the products.json endpoint instead. For JavaScript-rendered stores, see ZenRows vs self-hosted Playwright.

4. What geo checks cost in proxy bandwidth

Residential proxies are billed per GB. A check without browser rendering downloads the HTML of each redirect hop and the final page. We assume about 0.3 MB per check. That's an estimate: heavy product pages can be 1 MB of HTML or more.

Provider and planPrice per GBPer 1,000 checks at 0.3 MB (our estimate)
Infatica, pay as you go$4.00$1.20
Infatica, 25 GB for $96$3.84$1.15
Infatica, 241 GB for $700$2.90$0.87
IPRoyal, 1 GB pay as you go$7.35$2.21
IPRoyal, 10 GB pay as you go$5.51$1.65

Prices from Infatica's and IPRoyal's pricing pages, October 2026. IPRoyal's subscriptions are 5% cheaper, and its traffic doesn't expire. Infatica offers a 7-day trial for $4. Larger volumes on both are cheaper per GB.

Example: 500 SKUs in 6 countries, checked daily

500 × 6 × 30 = 90,000 checks a month. At 0.3 MB per check, that's about 27 GB.

Monthly proxy cost: 90,000 geo checks (about 27 GB)
Monthly proxy cost: 90,000 geo checks (about 27 GB)Infatica, 25 GB plan plus 2 GB pay as you go: 104 $/month; IPRoyal, 27 GB at the 10 GB pay-as-you-go rate: 149 $/monthInfaticaIPRoyalInfatica, 25 GB plan plus 2 GB pay as you go: 104 $/monthInfatica, 25 GB plan plus 2 GB pay as you go$104IPRoyal, 27 GB at the 10 GB pay-as-you-go rate: 149 $/monthIPRoyal, 27 GB at the 10 GB pay-as-you-go rate$149
Our estimate at 0.3 MB per check, using each provider's list prices in October 2026. IPRoyal's price per GB falls with larger purchases, so a single 27 GB purchase can cost less than shown.

The proxy bill scales with page weight, not request count, so cutting bytes matters most. Request only the product page, not the category page. Don't render the page in a browser when JSON-LD already holds the price. Check high-value SKUs daily and the long tail weekly. Our proxy health monitoring guide shows how to track cost per successful check over time.

5. Tools

Top picks

ToolRoleWhy it fits
Infatica Residential ProxiesResidential IPs by countryCountry, city and ISP targeting with a configurable rotation time per proxy list, which is what a sticky geo check needs. From $4.00 per GB pay as you go down to $2.60 per GB on the largest plan, with a 7-day trial for $4.
IPRoyal Residential ProxiesResidential IPs by country, pay as you goCountry and state targeting plus sticky sessions of up to 7 days, all set in the proxy password, so each country check in the script is one line in the proxy map. Traffic doesn't expire, which suits irregular or weekly checks.

Alternatives: free tools

We don't earn anything from these links.

  • Frankfurter: free, open-source API for the ECB's daily reference exchange rates, used in the script.
  • Schema Markup Validator: shows the Product JSON-LD a page publishes, so you can check where the price and currency fields are before writing any parser.

6. Pitfalls

  • Comparing gross prices across tax regimes. Compare ex-VAT, or you'll overstate European prices by about 20%.
  • Assuming currency from country. A currency cookie or a store-wide default can override the IP. Read priceCurrency from the page.
  • Rotating IPs inside one check. Use a sticky session per check, with a lifetime of a few minutes, and a new session ID for the next check.
  • Following redirects blindly. Cap the number of hops and log the chain. A loop costs bandwidth and returns nothing.
  • Treating converted prices as what the store charges. Reference rates are for comparison. The store's own rates, rounding and price lists decide what shoppers pay.
  • Ignoring a site's terms. Check public pages at a polite rate and follow the site's terms of service.
Bottom line

A reliable geo price check needs a residential IP in the target country that stays the same for the whole redirect chain, a cookie jar, and a language header that matches the IP. Read price, currency and stock from the page's Product JSON-LD, convert with a daily reference rate, and compare ex-VAT. At about 0.3 MB per check, by our estimate, 1,000 checks cost roughly $0.90 to $2.20 in residential bandwidth on Infatica or IPRoyal.

David Lewin
David Lewin

Founder of StealthScale.io and e-commerce infrastructure engineer writing field-tested playbooks on proxies, cloud VPS, anti-detect browsers and SEO.