Auto-Detect Country Code and Time Zone on Signup Forms

Daud Ahmad
By Daud Ahmad Software Engineer
Posted on November 3, 2026 | 16 min read
Auto-Detect Country Code and Time Zone on Signup Forms
Back to All Tutorials

A signup form can auto-detect country code and time zone from the visitor's IP address. Your server looks up the IP with IPGeolocation.io and sends the page three defaults: the country, the phone dial code, and the time zone. The browser confirms the time zone, and anything the visitor picks wins. You'll build it in Node.js and plain HTML with the intl-tel-input phone field.


TL;DR

To auto-detect country code and time zone on a signup form, serve /api/signup-defaults from your server: one IPGeolocation.io lookup returns location.country_code2, country_metadata.calling_code, and time_zone.name. The page fetches it after loading, passes the country to intl-tel-input's initialCountryLookup, and prefers the browser's own time zone.

  • On localhost, the API answers HTTP 423 for 127.0.0.1; test with a public IP.
  • Pre-fill signup forms only. On profile or edit forms, the saved value wins.

What to pre-fill, and what to leave to the visitor

Pre-fill the fields every visitor fills and an IP predicts well at country level; leave anything more precise to the visitor:

Field First Choice Fallback Always Wins
Country IP lookup: location.country_code2 "Choose your country" The visitor's selection
Phone dial code IP lookup: the same country, set in intl-tel-input No country selected The visitor's selection
Time zone The browser's time zone IP lookup: time_zone.name The visitor's selection
City, state, postal code Leave empty on signup

Select the phone country by its two-letter code, never by the dial code alone. Several countries share a code: +1 covers the United States, Canada, and many Caribbean nations, and Russia and Kazakhstan share +7.

We treat every IP value as a suggestion, never as verified information, so the form must work the same way when detection fails; each step names its fallback.


Before you start

Create the project:

mkdir signup-defaults && cd signup-defaults
npm init -y
npm pkg set type=module
npm install ip-geolocation-api-javascript-sdk express libphonenumber-js

The browser loads intl-tel-input 29.5.10 from jsDelivr. Each step shows part of server.js or public/signup.html; both files are in full in the complete code.


Step 1: Find the visitor's IP behind your proxy

Sequence diagram: the signup page loads, then the browser asks your server for defaults, and your server looks up the IP with IPGeolocation.io

Express reports the visitor's IP as req.ip, but only once you tell it how many proxies sit in front of your app. Without that setting, req.ip is your load balancer's address:

const app = express();
app.set("trust proxy", 1); // one proxy (your load balancer) in front; match your setup

// Step 1: the visitor's IP; TEST_IP only replaces loopback addresses during local tests
function visitorIp(req) {
  const isLocal = ["::1", "127.0.0.1", "::ffff:127.0.0.1"].includes(req.ip);
  return isLocal && process.env.TEST_IP ? process.env.TEST_IP : req.ip;
}

With one trusted hop, Express takes the address your own proxy added to X-Forwarded-For and ignores anything a client wrote further left. For CDNs and longer chains, see our guide to reading the visitor's real IP behind a proxy.

On your laptop, req.ip is ::1 or 127.0.0.1, and the API answers with HTTP 423: "'127.0.0.1' is a bogon IP address." Set TEST_IP=91.128.103.196, a sample address from our documentation, while you develop. Two mistakes we see often start here: looking up the load balancer instead of the visitor, and moving the lookup into browser code, key and all.


Step 2: Detect the visitor's country, dial code, and time zone

To detect the visitor's country, the IPGeolocation.io IP Geolocation API needs one request, limited to three fields: the country, the dial code (calling_code in the API), and the time zone. The answer is cached per IP:

import {
  IpGeolocationClient,
  LockedError,
  RateLimitError,
  UnauthorizedError,
} from "ip-geolocation-api-javascript-sdk";

const ipgeo = new IpGeolocationClient({
  apiKey: process.env.IPGEO_API_KEY,
  connectTimeoutMs: 1000, // 1.5 seconds in total, the same time the form waits
  readTimeoutMs: 500,
});

const CACHE_MS = 30 * 60 * 1000; // 30 minutes covers a typical signup session
const cache = new Map();

// Step 2: country, dial code, and time zone for one IP, cached per IP
async function lookupDefaults(ip) {
  const hit = cache.get(ip);
  if (hit && hit.expires > Date.now()) return hit.value;

  let value = null;
  try {
    const { data } = await ipgeo.lookupIpGeolocation({
      ip,
      fields: ["location.country_code2", "country_metadata.calling_code", "time_zone.name"],
    });
    // The SDK returns camelCase: country_code2 arrives as countryCode2
    value = {
      country: data.location?.countryCode2 ?? null,
      callingCode: data.countryMetadata?.callingCode ?? null,
      timeZone: data.timeZone?.name ?? null,
    };
  } catch (err) {
    if (err instanceof LockedError) {
      // 423: a private or bogon IP, such as ::1 on localhost; nothing to detect
    } else if (err instanceof UnauthorizedError || err instanceof RateLimitError) {
      console.error(`IPGeolocation.io ${err.statusCode}: ${err.apiMessage ?? err.message}`);
    } else {
      console.error(`Defaults lookup failed for ${ip}: ${err.message}`);
    }
  }

  // Cache a failure for one minute only, so a single timeout doesn't stick for the session
  cache.set(ip, { value, expires: Date.now() + (value ? CACHE_MS : 60 * 1000) });
  if (cache.size > 50_000) cache.delete(cache.keys().next().value);
  return value;
}

The response field reference spells fields in snake_case, such as time_zone.name; the SDK object uses camelCase (timeZone.name), and the snake_case spelling gives you undefined.

In our run on November 2, 2026, lookupDefaults("91.128.103.196") returned:

{ country: 'SE', callingCode: '+46', timeZone: 'Europe/Stockholm' }
IPGeolocation.io response for 91.128.103.196: SE, +46, Europe/Stockholm mapped to country, dial code, and time zone fields

The page never reads callingCode, because intl-tel-input knows every country's dial code; it's there for phone fields built without a library, which can show it as a prefix.

A wrong key (401), a used-up allowance (429), a private address (423), or a lookup slower than 1.5 seconds returns null, and the form keeps its own defaults. We'd cache each IP for 15 to 60 minutes, long enough for one signup session. Several servers can share Redis instead of the Map, but skip long-lived cookies, because a visitor's IP changes with networks and VPNs.


Step 3: Serve the defaults from a small JSON endpoint

The signup page loads without waiting for the lookup, then asks your server for its defaults:

app.use(express.static("public")); // signup.html is the same file for every visitor

// Step 3: the form asks for its defaults here after the page has loaded
app.get("/api/signup-defaults", async (req, res) => {
  const defaults = await lookupDefaults(visitorIp(req));
  res.set("Cache-Control", "private, no-store"); // per visitor: never cache it in a CDN
  res.json(defaults ?? {}); // {} tells the form to keep its own defaults
});

Nothing about the visitor is in the HTML, so the page renders at once and caches like any static file; only the JSON varies, and no-store keeps a CDN from serving one visitor's country to the next.

With TEST_IP set, curl localhost:3000/api/signup-defaults prints:

{"country":"SE","callingCode":"+46","timeZone":"Europe/Stockholm"}

On localhost without TEST_IP, it prints {}, and the form falls back to its own defaults. Each new IP costs a lookup, so serve this endpoint to your signup page only, and rate-limit it per IP if bots hit your forms.

A page that must work without JavaScript can render the defaults into the HTML instead, at the cost of caching that page. An IP address can count as personal data under laws such as the GDPR, so mention the lookup in your privacy notice; see whether IPGeolocation.io stores the IPs you look up.


Step 4: Pre-select the phone dial code with intl-tel-input

In the page's <head>, start the request early, so the defaults usually arrive before the visitor reaches the phone field:

<script>
  // Start the lookup before the form is parsed; any failure resolves to {}
  window.signupDefaults = fetch("/api/signup-defaults")
    .then((res) => (res.ok ? res.json() : {}))
    .catch(() => ({}));
</script>

Then, in a script at the end of <body>, after intl-tel-input's own <script> tag, give the lookup 1.5 seconds, hand the country to the phone field, and fill the country select:

const form = document.querySelector("#signup");
const countrySelect = document.querySelector("#country");
const countrySource = document.querySelector("#country-source");
const phoneInput = document.querySelector("#phone");
const zoneSelect = document.querySelector("#time-zone");
const zoneSource = document.querySelector("#time-zone-source");

// A field the visitor has changed is never overwritten by a late default
countrySelect.addEventListener("change", () => {
  countrySelect.dataset.touched = "true";
  countrySource.value = "visitor";
});
zoneSelect.addEventListener("change", () => {
  zoneSelect.dataset.touched = "true";
  zoneSource.value = "visitor";
});

// Wait at most 1.5 seconds for the defaults; after that the form keeps its own
const defaults = Promise.race([
  window.signupDefaults,
  new Promise((resolve) => setTimeout(() => resolve({}), 1500)),
]);

// The phone field asks your endpoint, never a third-party service
const iti = window.intlTelInput(phoneInput, {
  initialCountryLookup: async () => {
    const { country } = await defaults;
    if (!country) throw new Error("No country detected"); // stays in the empty globe state
    return country.toLowerCase();
  },
  hiddenInputs: () => ({ phone: "phone_full" }), // submits the number in E.164
  loadUtils: () => import("https://cdn.jsdelivr.net/npm/intl-tel-input@29.5.10/dist/js/utils.js"),
});
// iti.promise rejects when no country was detected; the form already handles that case
iti.promise.catch(() => {});

// Step 4: the country select uses the same country list as the phone field.
// Fill it after creating the phone field: intl-tel-input adds the country names then.
for (const { iso2, name } of window.intlTelInput.getAllCountries()) {
  countrySelect.add(new Option(name, iso2.toUpperCase()));
}
defaults.then(({ country }) => {
  if (!country || countrySelect.dataset.touched) return;
  countrySelect.value = country;
  countrySource.value = "ip";
});

intl-tel-input draws the country code dropdown next to the phone number field. Its initialCountryLookup option replaced the older geoIpLookup: it takes a function that returns a Promise of a two-letter country code (intl-tel-input options). Here it reads your own endpoint, so the key never reaches the browser. The library warns that a wrong initial country can store a number with the wrong dial code; its default layout shows the dial code next to the number, so the visitor can see a wrong guess. hiddenInputs submits the full number in E.164 format.

getAllCountries() (getCountryData() before v29) gives the country select the phone field's 244 countries, and country_source records whether the IP or the visitor chose the country. The two fields stay independent on purpose: plenty of people live in one country and carry a phone number from another.

With the Swedish test IP, the field shows +46, and typing the placeholder number "70 123 45 67" submits phone_full=+46701234567. If the endpoint fails or runs past 1.5 seconds, the function throws, and no country is selected. The library's iti.promise then rejects too, and the empty catch keeps that expected case out of the browser console as an uncaught error.


Step 5: Set the time zone, and let the visitor's choice win

The browser reports the time zone set on the visitor's device with Intl.DateTimeFormat().resolvedOptions().timeZone, as an IANA name such as Europe/Stockholm (MDN). We trust it first, because the visitor's own clock uses it; the IP's time_zone.name fills in when the browser reports none:

// Step 5: the browser's time zone first, the IP's only when the browser has none
// Intl.supportedValuesOf reached all major browsers in 2022; older ones get the detected zones only
const zones = typeof Intl.supportedValuesOf === "function" ? Intl.supportedValuesOf("timeZone") : [];
for (const zone of zones) zoneSelect.add(new Option(zone.replaceAll("_", " "), zone));

function setZone(zone, source) {
  if (!zone || zoneSelect.dataset.touched) return;
  if (!zones.includes(zone)) zoneSelect.add(new Option(zone, zone)); // keep aliases the list lacks
  zoneSelect.value = zone;
  zoneSource.value = source;
}

setZone(Intl.DateTimeFormat().resolvedOptions().timeZone, "browser");

defaults.then(({ timeZone }) => {
  if (timeZone) document.querySelector("#ip-time-zone").value = timeZone; // kept for support
  if (!zoneSource.value) setZone(timeZone, "ip");
});

Intl.supportedValuesOf("timeZone") fills the select with the zones the browser knows. It has worked across major browsers since March 2022; an older browser gets only the detected zones, and the form still submits. When the browser and the IP disagree, the form keeps the browser's zone and sends the IP's in a hidden field: with the browser set to America/New_York and the Swedish test IP, our run submitted America/New_York plus ip_time_zone=Europe/Stockholm. A mismatch often means a VPN or a trip, which is useful context for support.

For every field, the first value that exists wins: a saved value, the visitor's choice, the browser (time zone only), the IP lookup, then your site default. The touched flags from Step 4 stop a late default from overwriting a field the visitor has changed, and on profile or edit forms you render the saved values and leave the defaults script out.

Precedence for form defaults: saved value, visitor's choice, browser time zone, IP lookup default, then site default

The complete code

Save the two files, run IPGEO_API_KEY=your_api_key TEST_IP=91.128.103.196 node server.js, and open localhost:3000/signup.html. We ran both on November 2, 2026; submitting the form with the placeholder number returned:

{"email":"ana@example.com","country":"SE","countrySource":"ip","phone":"+46701234567","timeZone":"Europe/Stockholm","timeZoneSource":"browser","ipTimeZone":"Europe/Stockholm"}

server.js:

import express from "express";
import {
  IpGeolocationClient,
  LockedError,
  RateLimitError,
  UnauthorizedError,
} from "ip-geolocation-api-javascript-sdk";
import parsePhoneNumber from "libphonenumber-js/max";

const ipgeo = new IpGeolocationClient({
  apiKey: process.env.IPGEO_API_KEY,
  connectTimeoutMs: 1000, // 1.5 seconds in total, the same time the form waits
  readTimeoutMs: 500,
});

const CACHE_MS = 30 * 60 * 1000; // 30 minutes covers a typical signup session
const cache = new Map();

// Step 2: country, dial code, and time zone for one IP, cached per IP
async function lookupDefaults(ip) {
  const hit = cache.get(ip);
  if (hit && hit.expires > Date.now()) return hit.value;

  let value = null;
  try {
    const { data } = await ipgeo.lookupIpGeolocation({
      ip,
      fields: ["location.country_code2", "country_metadata.calling_code", "time_zone.name"],
    });
    // The SDK returns camelCase: country_code2 arrives as countryCode2
    value = {
      country: data.location?.countryCode2 ?? null,
      callingCode: data.countryMetadata?.callingCode ?? null,
      timeZone: data.timeZone?.name ?? null,
    };
  } catch (err) {
    if (err instanceof LockedError) {
      // 423: a private or bogon IP, such as ::1 on localhost; nothing to detect
    } else if (err instanceof UnauthorizedError || err instanceof RateLimitError) {
      console.error(`IPGeolocation.io ${err.statusCode}: ${err.apiMessage ?? err.message}`);
    } else {
      console.error(`Defaults lookup failed for ${ip}: ${err.message}`);
    }
  }

  // Cache a failure for one minute only, so a single timeout doesn't stick for the session
  cache.set(ip, { value, expires: Date.now() + (value ? CACHE_MS : 60 * 1000) });
  if (cache.size > 50_000) cache.delete(cache.keys().next().value);
  return value;
}

const app = express();
app.set("trust proxy", 1); // one proxy (your load balancer) in front; match your setup
app.use(express.static("public")); // signup.html is the same file for every visitor

// Step 1: the visitor's IP; TEST_IP only replaces loopback addresses during local tests
function visitorIp(req) {
  const isLocal = ["::1", "127.0.0.1", "::ffff:127.0.0.1"].includes(req.ip);
  return isLocal && process.env.TEST_IP ? process.env.TEST_IP : req.ip;
}

// Step 3: the form asks for its defaults here after the page has loaded
app.get("/api/signup-defaults", async (req, res) => {
  const defaults = await lookupDefaults(visitorIp(req));
  res.set("Cache-Control", "private, no-store"); // per visitor: never cache it in a CDN
  res.json(defaults ?? {}); // {} tells the form to keep its own defaults
});

// Step 5: store what the visitor submitted and where each default came from
app.post("/signup", express.urlencoded({ extended: false }), (req, res) => {
  const {
    email, country, country_source, phone, phone_full, phone_country,
    time_zone, time_zone_source, ip_time_zone,
  } = req.body;

  // Browser checks can be skipped, so check the number again here. phone_full is empty when
  // utils.js didn't load; then read the typed number with the phone field's country.
  let e164 = null;
  if (phone_full || phone) {
    const parsed = parsePhoneNumber(phone_full || phone, phone_country?.toUpperCase() || undefined);
    if (!parsed?.isValid()) {
      return res.status(400).json({ error: "Check the phone number and its country code." });
    }
    e164 = parsed.number; // E.164, such as +46701234567
  }

  // Save these to your user store. A country from "ip" is a suggestion the visitor kept,
  // not a verified country of residence.
  res.json({
    email,
    country,
    countrySource: country_source || "visitor",
    phone: e164,
    timeZone: time_zone,
    timeZoneSource: time_zone_source,
    ipTimeZone: ip_time_zone || null,
  });
});

app.listen(3000, (err) => {
  if (err) throw err; // Express 5 passes startup errors here, such as port 3000 already in use
  console.log("Signup form on localhost:3000/signup.html");
});

The /signup handler checks the phone number again: a browser check can be skipped, and it never runs when utils.js fails to load. libphonenumber-js reads phone_full, or the typed number with the field's country from phone_country; its max metadata checks the digits, not only the length. With utils.js blocked in our run, "70 123 45 67" was still stored as +46701234567, and "12" got HTTP 400.

public/signup.html:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Create your account</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/intl-tel-input@29.5.10/dist/css/intlTelInput.css">
  <script>
    // Start the lookup before the form is parsed; any failure resolves to {}
    window.signupDefaults = fetch("/api/signup-defaults")
      .then((res) => (res.ok ? res.json() : {}))
      .catch(() => ({}));
  </script>
</head>
<body>
  <form id="signup" method="post" action="/signup">
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required>

    <label for="country">Country</label>
    <select id="country" name="country" autocomplete="country" required>
      <option value="">Choose your country</option>
    </select>
    <input type="hidden" id="country-source" name="country_source">

    <label for="phone">Phone</label>
    <input id="phone" name="phone" type="tel" autocomplete="tel">
    <input type="hidden" id="phone-country" name="phone_country">
    <p id="phone-error" role="alert" hidden>Check the phone number and its country code.</p>

    <label for="time-zone">Time zone</label>
    <select id="time-zone" name="time_zone" required>
      <option value="">Choose your time zone</option>
    </select>
    <input type="hidden" id="time-zone-source" name="time_zone_source">
    <input type="hidden" id="ip-time-zone" name="ip_time_zone">

    <button type="submit">Create account</button>
  </form>

  <script src="https://cdn.jsdelivr.net/npm/intl-tel-input@29.5.10/dist/js/intlTelInput.min.js"></script>
  <script>
    const form = document.querySelector("#signup");
    const countrySelect = document.querySelector("#country");
    const countrySource = document.querySelector("#country-source");
    const phoneInput = document.querySelector("#phone");
    const zoneSelect = document.querySelector("#time-zone");
    const zoneSource = document.querySelector("#time-zone-source");

    // A field the visitor has changed is never overwritten by a late default
    countrySelect.addEventListener("change", () => {
      countrySelect.dataset.touched = "true";
      countrySource.value = "visitor";
    });
    zoneSelect.addEventListener("change", () => {
      zoneSelect.dataset.touched = "true";
      zoneSource.value = "visitor";
    });

    // Wait at most 1.5 seconds for the defaults; after that the form keeps its own
    const defaults = Promise.race([
      window.signupDefaults,
      new Promise((resolve) => setTimeout(() => resolve({}), 1500)),
    ]);

    // The phone field asks your endpoint, never a third-party service
    const iti = window.intlTelInput(phoneInput, {
      initialCountryLookup: async () => {
        const { country } = await defaults;
        if (!country) throw new Error("No country detected"); // stays in the empty globe state
        return country.toLowerCase();
      },
      hiddenInputs: () => ({ phone: "phone_full" }), // submits the number in E.164
      loadUtils: () => import("https://cdn.jsdelivr.net/npm/intl-tel-input@29.5.10/dist/js/utils.js"),
    });
    // iti.promise rejects when no country was detected; the form already handles that case
    iti.promise.catch(() => {});

    // Step 4: the country select uses the same country list as the phone field.
    // Fill it after creating the phone field: intl-tel-input adds the country names then.
    for (const { iso2, name } of window.intlTelInput.getAllCountries()) {
      countrySelect.add(new Option(name, iso2.toUpperCase()));
    }
    defaults.then(({ country }) => {
      if (!country || countrySelect.dataset.touched) return;
      countrySelect.value = country;
      countrySource.value = "ip";
    });

    // Step 5: the browser's time zone first, the IP's only when the browser has none
    // Intl.supportedValuesOf reached all major browsers in 2022; older ones get the detected zones only
    const zones = typeof Intl.supportedValuesOf === "function" ? Intl.supportedValuesOf("timeZone") : [];
    for (const zone of zones) zoneSelect.add(new Option(zone.replaceAll("_", " "), zone));

    function setZone(zone, source) {
      if (!zone || zoneSelect.dataset.touched) return;
      if (!zones.includes(zone)) zoneSelect.add(new Option(zone, zone)); // keep aliases the list lacks
      zoneSelect.value = zone;
      zoneSource.value = source;
    }

    setZone(Intl.DateTimeFormat().resolvedOptions().timeZone, "browser");

    defaults.then(({ timeZone }) => {
      if (timeZone) document.querySelector("#ip-time-zone").value = timeZone; // kept for support
      if (!zoneSource.value) setZone(timeZone, "ip");
    });

    // Block the submit only when a phone number is present and invalid
    form.addEventListener("submit", (event) => {
      // The phone field's country lets the server read the number if utils.js never loaded
      document.querySelector("#phone-country").value = iti.getSelectedCountry()?.iso2 ?? "";
      const error = document.querySelector("#phone-error");
      let valid = true;
      try {
        valid = !phoneInput.value.trim() || iti.isValidNumber();
      } catch {
        // utils.js not loaded yet: validate the number on the server instead
      }
      error.hidden = valid;
      if (!valid) event.preventDefault();
    });
  </script>
</body>
</html>

When the IP suggests the wrong country

Every IP data provider meets the same limits: VPN and proxy exits, iCloud Private Relay, corporate gateways, mobile carrier routing, and travel can place the IP away from the person filling in the form. IPGeolocation.io's security data flags many of these cases, as this documented sample shows:

# include=security needs a paid plan; a free key gets HTTP 401
curl "https://api.ipgeolocation.io/v3/ipgeo?apiKey=$IPGEO_API_KEY&ip=2.56.188.34&include=security&fields=location.country_code2,location.city,time_zone.name,security.is_vpn,security.is_anonymous"
{"ip":"2.56.188.34","location":{"country_code2":"US","city":"Dallas"},"security":{"is_vpn":true,"is_anonymous":true},"time_zone":{"name":"America/Chicago"}}
VPN exit 2.56.188.34 reports Dallas and America/Chicago with is_vpn true, while the browser reports a different time zone

We still pre-fill here, as a visible suggestion. The flags change how much you rely on the value, so request the anonymizer flag in lookupDefaults too, return it with the defaults, and store it next to country_source:

// Needs a paid key: include adds the security object (a free key gets HTTP 401)
const { data } = await ipgeo.lookupIpGeolocation({
  ip,
  include: ["security"],
  fields: ["location.country_code2", "country_metadata.calling_code", "time_zone.name", "security.is_anonymous"],
});
const isAnonymous = data.security?.isAnonymous ?? false;

Never block a signup because the IP and the browser disagree. For other uses of these signals, check whether an IP is a VPN, proxy, or Tor exit.


Beyond the three defaults

IPGeolocation.io gives you the most complete IP data in a single call: one request can return geolocation, security data (VPN, proxy, Tor, and relay detection with a threat score), ASN, company, abuse contact, time zone, and user agent data. At country level, the level these defaults rely on, our stated accuracy is 99%+, with lower accuracy on mobile networks.

The fields in this tutorial are part of the free plan, and you can compare free and paid plans for the rest. On paid plans, Request Origin authentication lets a site with no server of its own call the API from the browser with no key at all: the API accepts requests from the domains you allow.

FAQ

Look up the visitor's IP on your server, return the two-letter country code from your own endpoint, and pass it to the phone field's initialCountryLookup in intl-tel-input. The dial code, such as +46 for Sweden, then shows next to the number, and the visitor can change it.

Read Intl.DateTimeFormat().resolvedOptions().timeZone in the browser; it returns the IANA name of the device's zone, such as Europe/Stockholm. When the browser reports nothing, use time_zone.name from an IP lookup on your server, and let the user change it.

Yes, on IPGeolocation.io's free plan: location, country metadata (which holds the calling code), and time zone are all included, and each lookup costs 1 credit. The free Developer plan gives you 1,000 credits a day for life, with no credit card. Paid plans add more data, such as accuracy radius, company, network, and security data.

The visitor's IP belongs to a network somewhere else, such as a VPN or proxy exit, iCloud Private Relay, a corporate gateway, or a mobile carrier, or the visitor is traveling. Security data flags many of these cases; a visible, editable default handles the rest.

Related Tutorials

Check if an IP Is a VPN, Proxy, or Tor Exit Node
Check if an IP Is a VPN, Proxy, or Tor Exit Node

Check any IP for VPN, proxy, residential proxy, Tor, and relay flags with a browser lookup, one IPGeolocation.io API call, or the Tor Project's own exit list, then decide what each kind of match deserves.

Posted on October 27, 2026
By Abdul Mateen
Read More: Check if an IP Is a VPN, Proxy, or Tor Exit Node
Show Prices in Local Currency Based on IP
Show Prices in Local Currency Based on IP

Detect each visitor's currency with one IPGeolocation.io lookup, convert your base price with daily exchange rates, and format it for their locale. Runnable Node.js and Express code, with a switcher that always wins.

Posted on October 22, 2026
Read More: Show Prices in Local Currency Based on IP
What Is My IP API? Get the Caller's Public IP with getip
What Is My IP API? Get the Caller's Public IP with getip

Get your public IP from any script, server, or browser with IPGeolocation.io's getip endpoint. No API key or credits, and tested code for cURL, Python, Node.js, Go, and PHP.

Posted on October 21, 2026
Read More: What Is My IP API? Get the Caller's Public IP with getip
How to Add Geolocation to Your Shopify Store
How to Add Geolocation to Your Shopify Store

How to route Shopify visitors to the right market by country, and how to decide whether you need a geolocation app or Shopify's built-in redirect.

Posted on July 14, 2026
Read More: How to Add Geolocation to Your Shopify Store
How to Get IP Geolocation in JavaScript (Node + Browser)
How to Get IP Geolocation in JavaScript (Node + Browser)

Get IP geolocation in JavaScript the right way: the official SDK in Node.js, the correct browser pattern without exposing your key, the visitor's real IP via Express and Next.js, and the location, time zone, currency, ASN, VPN, proxy and threat data the API returns.

Posted on June 24, 2026
By Mudassar Tariq
Read More: How to Get IP Geolocation in JavaScript (Node + Browser)
How to Get IP Geolocation in PHP (API Tutorial)
How to Get IP Geolocation in PHP (API Tutorial)

Get IP geolocation in PHP with the official IPGeolocation.io library: install with Composer, run your first lookup, and read the real visitor IP in vanilla PHP and Laravel. Links to optional VPN/proxy, abuse contact, and bulk lookups on paid plans.

Posted on June 22, 2026
By Mudassar Tariq
Read More: How to Get IP Geolocation in PHP (API Tutorial)

Subscribe to Our Newsletter

Get the latest in geolocation tech, straight to your inbox.