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.
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.
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/oruk.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.
- 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 withen-UScan 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
VATtable 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.jsonendpoint 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 plan | Price per GB | Per 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.
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
| Tool | Role | Why it fits |
|---|---|---|
| Residential IPs by country | Country, 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. | |
| Residential IPs by country, pay as you go | Country 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
priceCurrencyfrom 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.
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.