Country and currency from a visitor's IP
Pick a default country, currency and calling code from a visitor's IP address: the lookup fields, how far to trust them, and code that falls back safely.
A visitor's IP address tells you, most of the time, which country they are connecting from. That is enough to start a shop in the right currency, preselect a country in a form, or suggest a language, as long as you treat the answer as a first guess. This guide explains the country fields of a host.sh lookup, where country-level geolocation goes wrong, and how to use it as a default the visitor can change. The JavaScript, Python and PHP examples set a timeout, check the answer, and fall back to a fixed default when anything fails.
The fields
A lookup answers with a geo.country object. Ask for only the parts you need with fields= (dotted paths, comma-separated):
{"geo":{"country":{"code":"PT","is_eu":true,"calling_code":"+351","currency":{"code":"EUR"}}},"connection":{"security":{"is_vpn":false}}}
| Field | What it is |
|---|---|
geo.country.code | The country as an ISO 3166-1 alpha-2 code, such as PT or US; null when unknown |
geo.country.name | The country's name, in English unless you ask for another language with lang= |
geo.country.currency.code | The country's currency as an ISO 4217 code, such as EUR; currency also has name and symbol, and is null when unknown |
geo.country.calling_code | The international calling code of the country, from the ITU-T E.164 numbering plan, with a leading +, such as +351 |
geo.country.is_eu | Whether the country is a member of the European Union |
connection.security.is_vpn | Whether the address is a known VPN exit; the response fields page has the other flags |
Two things to keep in mind about the codes. A calling code is not unique to one country: +1 is shared by the United States, Canada and many Caribbean countries, so use it to prefill a phone field, not to work out the country. And some countries use more than one currency, while the field names one, so let the visitor pick another.
For the visitor's own address, a lookup needs no token, so a web page can ask from the browser. To look up an address on your server, for example the address of a request, you need a token.
How accurate country detection is
Country is the most dependable part of IP geolocation: addresses are handed out to networks, and most networks serve customers in one country. But an IP address shows where a connection comes out onto the internet, not where the person is or where they live, and a few common cases break the link:
- VPNs and proxies. The address is the VPN's exit, in whatever country the user picked.
connection.security.is_vpnflags known VPN exits; when it istrue, the country says where the VPN is, not the visitor.is_proxyandis_torwork the same way. - Mobile networks. A phone's traffic can leave the carrier's network through a gateway that serves a whole region, and a phone roaming abroad often still comes out at its home carrier. The country can then be the carrier's, not the one the phone is in.
- Satellite internet. The address can belong to a ground station in a neighbouring country.
- Corporate networks and cloud hosts. A company may send all its offices' traffic out through one country, and a server in a data centre has the data centre's country.
- Registration is not location. The registry records of a network name the organisation that holds it, which can be in another country than the people using it. Geolocation data looks at where an address is used, but a newly moved network can be wrong for a while.
None of these are rare, so design for a wrong answer: it should cost the visitor one click, not a blocked page.
Use it as a default, never as a verdict
- Pricing. Show prices in the detected currency as a starting point, with a visible currency selector, and remember the visitor's choice so the lookup doesn't overrule it on the next visit. Charge in the currency the visitor saw and confirmed.
- Language. The country is a weak hint for language: many countries have several languages, and people travel and move. The browser's
Accept-Languageheader says what the visitor reads; use the country only when that is missing. - Forms. Preselect the country and the calling code, and leave both editable.
- Rules and compliance. Tax, export, licensing and age rules need what the law asks for, which is seldom an IP address alone. IP country can be one signal among several, for example next to a billing address, but it is not proof of where someone lives, and VPNs move it at will.
- Blocking. Don't refuse service on IP country alone without a way through for people it misjudges, such as a support contact or another check.
In JavaScript, in the browser
The visitor's own country, with no token, straight from the page (the API allows cross-origin requests). It asks once per browser session and keeps the answer:
const FALLBACK = { country: 'US', currency: 'USD' };
const KEY = 'hostsh-locale';
const COUNTRY = /^[A-Z]{2}$/, CURRENCY = /^[A-Z]{3}$/;
const isCode = (value, re) => typeof value === 'string' && re.test(value);
async function visitorLocale() {
try {
const saved = JSON.parse(sessionStorage.getItem(KEY));
if (isCode(saved?.country, COUNTRY) && isCode(saved?.currency, CURRENCY)) return saved;
} catch {
// no storage here (or not a browser), or not what this code stored: ask the API
}
try {
const res = await fetch('https://api.host.sh/v2/lookup/?fields=geo.country.code,geo.country.currency.code,connection.security.is_vpn',
{ signal: AbortSignal.timeout(3000) });
if (!res.ok) return FALLBACK;
const data = await res.json();
const country = data.geo?.country?.code;
const currency = data.geo?.country?.currency?.code;
if (!isCode(country, COUNTRY)) return FALLBACK;
const locale = {
country,
currency: isCode(currency, CURRENCY) ? currency : FALLBACK.currency,
// the country of a VPN exit is a weaker guess: still a default, never a rule
vpn: data.connection?.security?.is_vpn === true,
};
try { sessionStorage.setItem(KEY, JSON.stringify(locale)); } catch {}
return locale;
} catch {
return FALLBACK; // network error, timeout or a body that isn't JSON
}
}
console.log(await visitorLocale());
Tokenless lookups are limited to 10 a minute per address (rate limits). One request per session stays well under that for most visitors, but people behind one shared address (an office, a school, a mobile carrier's NAT) share the limit too; over it the API answers 429, and the code above then uses the fallback.
On your server, in Python
On the server you look up the address of the request, which needs a token. Take the client address the way the client IP guide explains when you run behind a proxy or a CDN. Only the standard library:
import json
import re
import urllib.parse
import urllib.request
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args):
return None # a redirect would take the token along: fail instead
OPENER = urllib.request.build_opener(NoRedirect)
FALLBACK = {"country": "US", "currency": "USD"}
def visitor_locale(ip, token):
"""A default country and currency for this visitor; let them change it."""
try:
query = urllib.parse.urlencode({"ip": ip, "fields": "geo.country.code,geo.country.currency.code"})
req = urllib.request.Request("https://api.host.sh/v2/lookup/?" + query, headers={"Authorization": "Bearer " + token})
with OPENER.open(req, timeout=3) as res: # an HTTP error or a redirect raises
country = json.load(res)["geo"]["country"]
except Exception:
return FALLBACK
if not isinstance(country, dict):
return FALLBACK
code = country.get("code")
currency = country.get("currency")
currency = currency.get("code") if isinstance(currency, dict) else None
if not isinstance(code, str) or not re.fullmatch(r"[A-Z]{2}", code):
return FALLBACK
if not isinstance(currency, str) or not re.fullmatch(r"[A-Z]{3}", currency):
currency = FALLBACK["currency"]
return {"country": code, "currency": currency}
print(visitor_locale("8.8.8.8", "YOUR_TOKEN")) # the request's client address in a real app
On your server, in PHP
<?php
const FALLBACK = ['country' => 'US', 'currency' => 'USD'];
// a default country and currency for this visitor; let them change it
function visitor_locale(string $ip, string $token): array
{
$url = 'https://api.host.sh/v2/lookup/?' . http_build_query(['ip' => $ip, 'fields' => 'geo.country.code,geo.country.currency.code']);
$ctx = stream_context_create(['http' => ['timeout' => 3, 'header' => 'Authorization: Bearer ' . $token, 'follow_location' => 0]]);
$fp = @fopen($url, 'r', false, $ctx); // false on a network or HTTP error
if ($fp === false) {
return FALLBACK;
}
$body = stream_get_contents($fp);
$meta = stream_get_meta_data($fp);
fclose($fp);
// only a 200 (redirects aren't followed: they would take the token along), and a timeout or a
// connection that closed early leaves a short body
$headers = $meta['wrapper_data'] ?? [];
if (!preg_match('#^HTTP/\S+ 200\b#', $headers[0] ?? '')) {
return FALLBACK;
}
$length = null;
foreach ($headers as $header) {
if (preg_match('/^Content-Length:\s*(\d+)\s*\z/i', $header, $m)) {
$length = (int) $m[1];
}
}
if ($body === false || $meta['timed_out'] || ($length !== null && strlen($body) !== $length)) {
return FALLBACK;
}
$data = json_decode($body, true);
$country = is_array($data) ? ($data['geo']['country'] ?? null) : null;
$code = is_array($country) ? ($country['code'] ?? null) : null;
if (!is_string($code) || !preg_match('/^[A-Z]{2}\z/', $code)) {
return FALLBACK;
}
$currency = is_array($country['currency'] ?? null) ? ($country['currency']['code'] ?? null) : null;
if (!is_string($currency) || !preg_match('/^[A-Z]{3}\z/', $currency)) {
$currency = FALLBACK['currency'];
}
return ['country' => $code, 'currency' => $currency];
}
// REMOTE_ADDR is the client in a web request (behind a proxy, see the client IP guide)
$locale = visitor_locale($_SERVER['REMOTE_ADDR'] ?? '8.8.8.8', 'YOUR_TOKEN');
echo $locale['country'], ' ', $locale['currency'], "\n";
From a terminal
curl host.sh answers single fields of your own address as plain text, with no token:
country=$(curl -sS --fail-with-body --max-time 5 https://host.sh/country_code) || country='' case "$country" in [A-Z][A-Z]) ;; *) country=US;; esac echo "Country: $country"
host.sh/currency and host.sh/calling_code work the same way; the curl page lists every field.
FAQ
How do I get a visitor's country from their IP address?
Look the address up and read geo.country.code, an ISO 3166-1 alpha-2 code. In the browser the visitor's own address needs no token; on a server, send the request's client address with a token (Lookup).
How accurate is IP-based country detection?
It is right for most visitors, but it shows where a connection comes out onto the internet. VPNs, mobile carriers routing through another country, satellite links and corporate networks all move it, so use it as a default the visitor can change. connection.security.is_vpn flags known VPN exits (response fields).
Which currency code does the lookup give?
geo.country.currency.code is the country's currency as an ISO 4217 code, such as EUR or USD. Where a country uses more than one currency the field names one, so offer a currency selector.
Can I use the calling code to find the country?
No. Calling codes from the ITU-T E.164 plan are shared: +1 covers the United States, Canada and many Caribbean countries. Use geo.country.calling_code to prefill a phone field and geo.country.code for the country.
Should I block countries by IP address?
Not on the IP country alone. It is wrong for VPN users, travellers and some mobile and corporate networks, so a hard block needs a fallback such as a support contact or another check. For rules that the law sets, use the evidence the law asks for.
This page as Markdown: /guides/country-currency-from-ip/index.md