A visitor from Sweden sees prices in dollars and has to scroll past 200 countries to find theirs at checkout. Both problems have the same fix: estimate the visitor's country from their IP address, then use it as a default.
The important word is default. IP geolocation is usually right about the country, but travellers, VPN users, cross-border shoppers and people on corporate networks are real. This guide builds a currency and shipping-country default that is easy to override, handles failed lookups and stays consistent through checkout.
The precedence rule
Decide once which signal wins, and apply it everywhere:
- An explicit choice by the visitor (a currency switcher, a saved preference). Always wins.
- Account data for signed-in customers: a saved address or billing country.
- A locale in the URL if you run
/de/,/fr/stores. The URL is what was shared or indexed. - The IP country. A good guess for first-time visitors.
- Your base currency when nothing else is known.
IP geolocation sits at step 4 on purpose. It improves the first visit without overriding anything the visitor said.
From country to currency
The API gives you the country. Mapping it to a currency is your job, and it's mostly a lookup table based on ISO 4217. In practice you don't need all ~180 currencies. You need the ones you actually price in:
// Currencies you have prices (or a pricing policy) for.
const SUPPORTED = new Set(['EUR', 'USD', 'GBP', 'SEK', 'CHF', 'PLN']);
// Euro area members as of 2026 (Bulgaria joined on 1 January 2026; ISO code for Greece is GR).
const EURO = ['AT','BE','BG','HR','CY','EE','FI','FR','DE','GR','IE','IT','LV','LT','LU','MT','NL','PT','SK','SI','ES'];
const COUNTRY_CURRENCY = {
...Object.fromEntries(EURO.map((c) => [c, 'EUR'])),
US: 'USD', GB: 'GBP', SE: 'SEK', CH: 'CHF', LI: 'CHF', PL: 'PLN',
// Countries with their own currency that you don't price in fall back below.
};
export function currencyFor(countryCode, base = 'USD') {
const cur = countryCode && COUNTRY_CURRENCY[countryCode];
return cur && SUPPORTED.has(cur) ? cur : base;
}Some things to watch:
- Euro area membership changes. Croatia joined in 2023 and Bulgaria on 1 January 2026. Review the list when the EU announces a change.
- Several countries use a currency they don't issue. Montenegro and Kosovo use the euro, Ecuador and El Salvador use the US dollar, Liechtenstein uses the Swiss franc. Map them to what's used, if you price in it.
- Exchange rates are a pricing decision, not a lookup. Live conversion produces prices like 9,37 that change daily. Many shops set a rounded price list per currency and review it periodically instead.
Formatting prices
Formatting depends on the locale as well as the currency. The same euro amount is 1.234,50 € for a reader in Germany and €1,234.50 for one in Ireland. Use the browser's language or your site's locale for formatting, and the currency from the rule above:
const fmt = (amount, currency, locale) =>
new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount);
fmt(1234.5, 'EUR', 'de-DE'); // "1.234,50 €"
fmt(1234.5, 'EUR', 'en-IE'); // "€1,234.50"
fmt(1234.5, 'SEK', 'sv-SE'); // "1 234,50 kr"Python has babel.numbers.format_currency, PHP has NumberFormatter in the intl extension, and Go has golang.org/x/text/currency.
Server-side: Express
Detect on the server, so the API key stays private and the first render already has the right currency. If the currency only switches after the page loads, prices jump in front of the visitor.
import express from 'express';
import cookieParser from 'cookie-parser';
import { currencyFor } from './currency.js';
const known = (v) => (v === undefined || v === null || v === '' || v === 'No Data' ? null : v); // missing fields are "No Data"
async function countryOf(ip) {
try {
const res = await fetch(`https://ipapi.gr/api/lookup/${encodeURIComponent(ip)}?key=${process.env.IPAPI_KEY}`,
{ signal: AbortSignal.timeout(1500) });
if (!res.ok) return null; // 429 etc.: unknown, use the base currency
return known((await res.json()).country_code);
} catch {
return null;
}
}
const app = express();
app.set('trust proxy', 1);
app.use(cookieParser());
app.use(async (req, res, next) => {
// 1. explicit choice 2. account 3. URL locale (not shown) 4. IP 5. base
let currency = req.cookies.currency || req.user?.currency;
let country = req.cookies.geo_cc; // cached IP country for this visitor
if (!currency || !country) {
if (country === undefined) {
country = (await countryOf(req.ip.replace(/^::ffff:/, ''))) ?? '';
res.cookie('geo_cc', country, { maxAge: 24 * 3600 * 1000, sameSite: 'lax', httpOnly: true });
}
currency ??= currencyFor(country || null);
}
res.locals.currency = currency;
res.locals.ipCountry = country || null; // for the checkout default, see below
next();
});
app.post('/currency', express.urlencoded({ extended: false }), (req, res) => {
const cur = String(req.body.currency || '');
if (['EUR', 'USD', 'GBP', 'SEK', 'CHF', 'PLN'].includes(cur)) {
res.cookie('currency', cur, { maxAge: 365 * 24 * 3600 * 1000, sameSite: 'lax', httpOnly: true });
}
res.redirect(303, req.get('referer') || '/');
});The geo_cc cookie caches the country for a day, so there's one lookup per visitor, not per page view. It only holds a two-letter code. A visitor could change it, but all they'd get is a different default currency, which they could pick anyway.
If you cache pages in a CDN, the HTML now varies by visitor. Either keep prices out of cached HTML (render them client-side from a small API), or vary the cache on the currency cookie.
Python (FastAPI)
import os
import httpx
from fastapi import FastAPI, Request
from .currency import currency_for
app = FastAPI()
client = httpx.AsyncClient(timeout=1.5)
def known(v):
return None if v in (None, "", "No Data") else v # missing fields are "No Data"
async def country_of(ip: str) -> str | None:
try:
r = await client.get(f"https://ipapi.gr/api/lookup/{ip}",
params={"key": os.environ["IPAPI_KEY"]})
except httpx.HTTPError:
return None
return known(r.json().get("country_code")) if r.status_code == 200 else None
@app.middleware("http")
async def currency_middleware(request: Request, call_next):
currency = request.cookies.get("currency")
country = request.cookies.get("geo_cc")
fresh = False
if country is None:
country = await country_of(request.client.host) or ""
fresh = True
request.state.ip_country = country or None
request.state.currency = currency or currency_for(country or None)
response = await call_next(request)
if fresh:
response.set_cookie("geo_cc", country, max_age=86400, samesite="lax", httponly=True)
return responseRun Uvicorn with --proxy-headers and --forwarded-allow-ips set to your proxy's address, so request.client.host is the visitor and not the proxy.
PHP
<?php
function known($v) {
return ($v === null || $v === '' || $v === 'No Data') ? null : $v; // missing fields are "No Data"
}
function ip_country(string $ip): ?string {
if (isset($_COOKIE['geo_cc'])) return $_COOKIE['geo_cc'] ?: null;
$ch = curl_init('https://ipapi.gr/api/lookup/' . rawurlencode($ip) . '?key=' . urlencode(getenv('IPAPI_KEY')));
curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 2, CURLOPT_CONNECTTIMEOUT => 1]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$code = ($body !== false && $status === 200) ? known(json_decode($body, true)['country_code'] ?? null) : null;
setcookie('geo_cc', $code ?? '', ['expires' => time() + 86400, 'path' => '/', 'samesite' => 'Lax', 'httponly' => true]);
return $code;
}
$country = ip_country($_SERVER['REMOTE_ADDR']);
$currency = $_COOKIE['currency'] ?? currency_for($country); // currency_for(): your country => currency map
$fmt = new NumberFormatter('de_DE', NumberFormatter::CURRENCY);
echo $fmt->formatCurrency(1234.5, $currency);setcookie has to run before any output.
Go
package shop
import (
"encoding/json"
"net/http"
"net/url"
"os"
"time"
)
var client = &http.Client{Timeout: 1500 * time.Millisecond}
func known(s string) string {
if s == "No Data" { // missing fields are "No Data"
return ""
}
return s
}
// IPCountry returns "" when the country is unknown or the lookup fails.
func IPCountry(ip string) string {
resp, err := client.Get("https://ipapi.gr/api/lookup/" + url.PathEscape(ip) + "?key=" + url.QueryEscape(os.Getenv("IPAPI_KEY")))
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ""
}
var g struct {
CountryCode string `json:"country_code"`
}
if json.NewDecoder(resp.Body).Decode(&g) != nil {
return ""
}
return known(g.CountryCode)
}
func CurrencyFor(r *http.Request, ip string) string {
if c, err := r.Cookie("currency"); err == nil && supported[c.Value] {
return c.Value
}
if cur, ok := countryCurrency[IPCountry(ip)]; ok && supported[cur] {
return cur
}
return "USD"
}supported and countryCurrency are maps built like the JS tables above. Cache IPCountry per IP or in a cookie, as in the other examples.
At the edge
On a CDN worker you can decide the currency before the page is even fetched from your origin, and pass it on as a request header:
// Cloudflare Worker: adds X-Currency for the origin to use. Key stored with: wrangler secret put IPAPI_KEY
import { currencyFor } from './currency.js';
const known = (v) => (v === undefined || v === null || v === '' || v === 'No Data' ? null : v); // missing fields are "No Data"
export default {
async fetch(request, env) {
const cookie = request.headers.get('Cookie') || '';
let currency = /(?:^|;\s*)currency=([A-Z]{3})/.exec(cookie)?.[1];
if (!currency) {
let code = null;
try {
const ip = request.headers.get('CF-Connecting-IP');
const res = await fetch(`https://ipapi.gr/api/lookup/${encodeURIComponent(ip)}?key=${env.IPAPI_KEY}`,
{ signal: AbortSignal.timeout(1000) });
if (res.ok) code = known((await res.json()).country_code);
} catch { /* unknown: base currency */ }
currency = currencyFor(code);
}
const upstream = new Request(request);
upstream.headers.set('X-Currency', currency);
return fetch(upstream);
}
};Your origin must only trust X-Currency from the worker. Strip it from requests that arrive any other way.
Pre-filling the shipping country
The same country makes a good default for the address form. A few rules keep it helpful rather than annoying:
- Pre-select, don't lock. The dropdown stays editable, and the order uses what the customer submits, never the IP.
- Respect autofill. Mark the field with
autocomplete="shipping country"(orbilling country) so the browser's saved address can overwrite your default. - Only pre-select countries you ship to. If the IP country isn't on your list, leave the field empty rather than choosing something else.
- Don't let the IP country decide tax or duties. Those depend on the delivery address, which you'll have by then.
<select name="country" autocomplete="shipping country" required>
<% for (const c of shippingCountries) { %>
<option value="<%= c.code %>" <%= c.code === ipCountry ? 'selected' : '' %>><%= c.name %></option>
<% } %>
</select>ipCountry is the value the middleware above left in res.locals, which is null when unknown. A null matches nothing, so nothing is pre-selected.
The EU angle: different prices are fine, unequal access isn't
If you sell to customers in the EU, the Geo-blocking Regulation (EU) 2018/302 applies to how you use the visitor's location:
- You may run different stores with different prices for different countries. Article 4(2) says the Regulation doesn't stop you from offering general conditions of access, "including net sale prices", that differ between member states or groups of customers on a non-discriminatory basis.
- You may not block or limit access to a version of your store, or redirect a customer to another one without their explicit consent, because of their nationality, residence or location (Article 3). A visitor from Austria who wants to buy from your German store must be able to.
- In the situations listed in Article 4(1), you may not refuse to sell, or apply different conditions, on the same grounds. That doesn't oblige you to deliver everywhere. The customer may have to collect the goods, or arrange delivery from a member state you do deliver to.
- Article 5 covers payments: within the payment methods you accept, you may not reject a card because of where it was issued or where the account is located, in the circumstances that article sets out.
Defaulting the currency by IP and offering a switcher fits these rules. Forcing a visitor into one country's store because of their IP doesn't. This is a summary, not legal advice. Check how the rules apply to your shop.
Testing
- Unit-test
currencyForwith a euro country, a non-euro country you support, one you don't support,nulland"No Data". - Load the site with no cookies through VPN exits in two countries and check the first render's currency.
- Pick a currency, then load from another country. The choice must win.
- Make sure a checkout started in one currency stays in that currency if the visitor's IP changes mid-session. Mobile users switch networks all the time.
FAQ
Can I get the currency directly from the API?
The response has country_code but no currency field. Mapping the country to a currency yourself is a small table, and you need your own table anyway to limit it to the currencies you price in.
Should I convert prices live or set them per currency?
Either works technically. Fixed price lists per currency give you round, stable prices and fewer support questions. Live conversion covers more currencies with less work. Whichever you choose, charge the price that was displayed.
What if the visitor uses a VPN?
They'll see the default for the VPN's country, then use the switcher. That's why the explicit choice has to be easy to find and must persist. IPAPI.GR doesn't detect VPNs, and for currency defaults you don't need it to.
Related: get a visitor's country from their IP address · geo-blocking by country and EU law.