---
title: "Auto-Detect Country Code and Time Zone on Signup Forms"
slug: "/tutorials/auto-detect-country-code-signup-form"
description: "Auto-detect a visitor's country, phone dial code, and time zone with one server-side IP lookup, then let the browser and the user correct it. Node.js code."
---

# Auto-Detect Country Code and Time Zone on Signup Forms

By [Daud Ahmad](https://www.linkedin.com/in/daudahmad0303) Software Engineer

Posted on November 3, 2026 | 16 min read

![Auto-Detect Country Code and Time Zone on Signup Forms](https://blogs.ipgeolocation.io/blogs-site/content/images/2026/11/auto-detect-country-code-signup-form.png)

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

*   `node -v` prints 18 or later, which the [IPGeolocation.io JavaScript SDK](https://ipgeolocation.io/documentation/ip-geolocation-api-javascript-sdk.html) needs.
*   Your IPGeolocation.io API key sits in the `IPGEO_API_KEY` environment variable, never in browser code. Our guide to [keeping your API key off the client](https://ipgeolocation.io/tutorials/secure-api-key-before-production) covers the options.

Create the project:

```bash
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](https://ipgeolocation.io/tutorials/auto-detect-country-code-signup-form#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](https://blogs.ipgeolocation.io/blogs-site/content/images/2026/11/signup-defaults-ip-lookup-flow.webp)

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:

```js
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](https://ipgeolocation.io/guides/get-real-client-ip-address).

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](https://ipgeolocation.io/ip-location-api.html) 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:

```js
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](https://ipgeolocation.io/documentation/ip-location-api.html) 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:

```text
{ 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](https://blogs.ipgeolocation.io/blogs-site/content/images/2026/11/ip-lookup-country-calling-code-time-zone.webp)

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:

```js
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:

```json
{"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](https://ipgeolocation.io/help/does-ipgeolocation-io-store-ip-addresses).

* * *

## 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:

```html
<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:

```js
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](https://intl-tel-input.com/docs/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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/resolvedOptions)). 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:

```js
// 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")`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/supportedValuesOf) 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](https://blogs.ipgeolocation.io/blogs-site/content/images/2026/11/signup-defaults-precedence-order.webp)

* * *

## 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:

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

`server.js`:

```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`:

```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:

```bash
# 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"
```

```json
{"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](https://blogs.ipgeolocation.io/blogs-site/content/images/2026/11/vpn-exit-ip-vs-browser-time-zone.webp)

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`:

```js
// 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](https://ipgeolocation.io/tutorials/check-ip-for-vpn-proxy-tor).

* * *

## 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](https://ipgeolocation.io/ip-location-api.html) 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](https://ipgeolocation.io/pricing.html) for the rest. On paid plans, [Request Origin authentication](https://ipgeolocation.io/documentation/api-authentication.html) 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

### How do I auto-detect the country code for a phone number input?

**Answer:** 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.

### How do I get a user's time zone without asking them?

**Answer:** 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.

### Is it free to look up the country, calling code, and time zone from an IP?

**Answer:** 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.

### Why does my form show the wrong country for some visitors?

**Answer:** 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](https://blogs.ipgeolocation.io/blogs-site/content/images/2026/10/check-if-ip-is-vpn-proxy-or-tor-1.png)](https://ipgeolocation.io/tutorials/check-ip-for-vpn-proxy-tor)

[Check if an IP Is a VPN, Proxy, or Tor Exit Node](https://ipgeolocation.io/tutorials/check-ip-for-vpn-proxy-tor)

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](https://ipgeolocation.io/tutorials/check-ip-for-vpn-proxy-tor)

[![Show Prices in Local Currency Based on IP](https://blogs.ipgeolocation.io/blogs-site/content/images/2026/10/show-prices-in-local-currency-by-ip-1.png)](https://ipgeolocation.io/tutorials/show-prices-in-local-currency-based-on-ip)

[Show Prices in Local Currency Based on IP](https://ipgeolocation.io/tutorials/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

By [Daud Ahmad](https://www.linkedin.com/in/daudahmad0303)

[Read More: Show Prices in Local Currency Based on IP](https://ipgeolocation.io/tutorials/show-prices-in-local-currency-based-on-ip)

[![What Is My IP API? Get the Caller's Public IP with getip](https://blogs.ipgeolocation.io/blogs-site/content/images/2026/10/what-is-my-ip-api-feature.png)](https://ipgeolocation.io/tutorials/what-is-my-ip-api)

[What Is My IP API? Get the Caller's Public IP with getip](https://ipgeolocation.io/tutorials/what-is-my-ip-api)

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

By [Daud Ahmad](https://www.linkedin.com/in/daudahmad0303)

[Read More: What Is My IP API? Get the Caller's Public IP with getip](https://ipgeolocation.io/tutorials/what-is-my-ip-api)

[![How to Add Geolocation to Your Shopify Store](https://blogs.ipgeolocation.io/blogs-site/content/images/2026/07/How-to-Add-Geolocation-to-Your-Shopify-Store.svg)](https://ipgeolocation.io/tutorials/add-geolocation-to-shopify)

[How to Add Geolocation to Your Shopify Store](https://ipgeolocation.io/tutorials/add-geolocation-to-shopify)

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

By [Anwar Ahmed](https://www.linkedin.com/in/anwar-ce/)

[Read More: How to Add Geolocation to Your Shopify Store](https://ipgeolocation.io/tutorials/add-geolocation-to-shopify)

[![How to Get IP Geolocation in JavaScript (Node + Browser)](https://blogs.ipgeolocation.io/blogs-site/content/images/2026/06/ip-geolocation-in-javascript.png)](https://ipgeolocation.io/tutorials/ip-geolocation-in-javascript)

[How to Get IP Geolocation in JavaScript (Node + Browser)](https://ipgeolocation.io/tutorials/ip-geolocation-in-javascript)

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)](https://ipgeolocation.io/tutorials/ip-geolocation-in-javascript)

[![How to Get IP Geolocation in PHP (API Tutorial)](https://blogs.ipgeolocation.io/blogs-site/content/images/2026/06/ip-geolocation-in-php-1.png)](https://ipgeolocation.io/tutorials/ip-geolocation-in-php)

[How to Get IP Geolocation in PHP (API Tutorial)](https://ipgeolocation.io/tutorials/ip-geolocation-in-php)

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)](https://ipgeolocation.io/tutorials/ip-geolocation-in-php)
