The first paint of a page has to make three decisions before the visitor has told you anything: whether to ask for consent before setting non-essential cookies, which currency to quote, and what clock to show. An IP lookup answers all three well enough for a default, and badly enough that you must let the visitor override every one of them. This guide covers what to read, what to build on it, and where the answer genuinely stops.
Consent: is_eu is a floor, not a border
The GDPR applies to the processing of personal data of people who are in the Union, whether or not they are citizens or residents, and to controllers established there regardless of where the processing happens (Article 3, Regulation (EU) 2016/679, https://eur-lex.europa.eu/eli/reg/2016/679/oj, checked 2026-09-04). The consent requirement for storing information on a visitor's device comes from Article 5(3) of Directive 2002/58/EC, the ePrivacy Directive (https://eur-lex.europa.eu/eli/dir/2002/58/oj), as transposed by each member state. Neither text says "show a banner to EU IP addresses". Both say the rules follow the person and the establishment.
What an IP lookup gives you is a reasonable proxy for "in the Union right now". The response carries country as an ISO code and is_eu as a boolean for the 27 member states, so the gate is one line:
if (data.is_eu) showConsentBanner();
Three consequences follow, and each is a design decision rather than a lookup detail.
First, is_eu is false for the United Kingdom, Switzerland, Norway and Iceland, and for the EEA members among those the GDPR applies through the EEA agreement while the UK applies its own GDPR. Your policy should probably treat the EEA and the UK like the Union, which means checking country against your own list rather than relying on is_eu alone. The boolean is the floor.
Second, an EU resident on holiday in the United States is still protected, and your lookup will say country: "US". If you serve EU customers at all, the cheapest safe policy is to ask for consent everywhere and use the lookup only to pick the default state of the toggles. Several jurisdictions outside the Union also require consent for tracking; a global banner sidesteps the question of keeping a country list current.
Third, is_eu is null when the country is unknown. Reserved addresses, the rare unlocated block, and lookups that could not resolve a country all answer null. Treat null as "ask".
Currency and locale: a default the visitor can change
currency is the ISO 4217 code for the located country, taken from ICU's region data, the same source your platform's number formatter uses. calling_code comes from the same libphonenumber data that validates phone numbers. Both are country-level facts and are exactly as reliable as the country, which on the database used here is dependable at country level and approximate below it.
Use them for the first quote and the phone field's default prefix, and let the visitor switch. A German visitor sees euros; a German visitor connecting through a company VPN that exits in Ireland also sees euros, because Ireland is in the euro area, but one whose VPN exits in the United States sees dollars until they change it. That is why the location answer carries location_source: when a VPN provider's own server list places the address, the value is operator and the country is the exit's country, not the person's. If is_vpn or is_relay is true, prefer the visitor's stored preference or their browser language over the lookup.
Language is not in the response on purpose. Belgium, Switzerland, Canada and India each have several official languages, and the browser already tells you which one the visitor reads in Accept-Language. Use that header for language and the lookup for currency.
Time zones: only where the answer is settled
timezone is an IANA identifier such as Europe/Berlin, from the tz database maintained by IANA (https://www.iana.org/time-zones). It is filled in only when the country has a single zone, or when the located region is known to lie entirely in one zone. Germany, Japan and France answer a zone; a visitor located in California answers America/Los_Angeles; a visitor located in Texas answers null, because the state straddles Central and Mountain time and the city-level location is not precise enough to pick one. A null here is not a failure. It is the API declining to guess between two clocks that differ by an hour.
Two lines cover both cases:
const zone = data.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
The browser knows its own zone precisely; the lookup is for the server side, where you have no browser: scheduling a digest email, rendering a receipt, choosing a support hours message. On the server, treat null as "use UTC and say so".
Where the location comes from
The base answer is the DB-IP Lite database, refreshed monthly. Country-level accuracy from this data is dependable; city-level is approximate and varies by region. Where the network operator publishes the location itself, that declaration replaces the database answer: DigitalOcean, Linode and Vultr publish RFC 8805 geofeeds (https://www.rfc-editor.org/rfc/rfc8805), Apple publishes the egress ranges of iCloud Private Relay with their cities, and Mullvad, NordVPN and Private Internet Access publish their server lists. The response marks these with location_source: "operator", and network tells you how wide the answer is: an answer for a /12 is a region, an answer for a /24 is a neighbourhood.
When the operator's country or city disagrees with the database, the region and the coordinates answer null rather than a contradicting point. Coordinates in the response are the centre of the located area, never a visitor's position; do not draw a pin with them.
Reading the lookup on first paint
From a server you already have the address; from a page you do not, so the browser endpoint looks up the visitor's own address when ip is omitted, authenticated by a publishable key locked to your domains:
const response = await fetch('https://spaw.co/api/v1/ip/public', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: 'pk_live_…' }),
});
const { data } = await response.json();
document.documentElement.dataset.consent = data.is_eu === false ? 'optional' : 'required';
if (data.currency) setDefaultCurrency(data.currency);
Cache the answer in session storage. The address does not change between pages, and a repeat of the same address inside seven days is free anyway. From a server, GET /api/v1/ip/me (reference) answers for the address the call came from, which is the quickest way to confirm what your own egress looks like to the outside world.
What is kept
An IP address is personal data. Nothing about an IP lookup is stored on the service: no history row, no log line with the address. The only trace is a keyed seven-day marker that makes a repeat free, and privacy: true skips even that. Every signal is a local database or a compiled feed, so the address never reaches a third-party processor unless you opt into abuse_contact, which queries the Internet registry's public RDAP service. The security page states the same facts with the rest of the data handling, and the IP intelligence guide documents every field used above.
What to do next
- Read the IP intelligence guide for the full field list and the operator-location rules.
- Put the browser endpoint behind a publishable key with a daily cap before it goes on a public page.
- Use the same lookup to keep scripts out of your forms with block datacenter and bot signups with one IP lookup.