Developers

Phone number validation in JavaScript and Node.js

Validate phone numbers in JavaScript with libphonenumber-js: min vs max metadata, E.164, form input, and a live line-type check with runnable Node code.

By Published 8 min read

On this page

To validate a phone number in JavaScript, parse it with libphonenumber-js/max, call isValid(), and store number (the E.164 string). That checks the format and the country's numbering plan. It can't tell you whether the line is a mobile or whether it's in service. For that, send the valid E.164 number to a lookup API from your server. This guide has runnable Node code for both steps.

Why do so many JavaScript answers get phone validation wrong?

Most answers start with a regex. The Stack Overflow question "Validate phone number with JavaScript" was asked in 2010 and had over 806,000 views on 2026-09-25, and most of its answers are patterns. A pattern can check that a string looks like a number. It can't know that +1 123 456 7890 is impossible (no US area code starts with 1) or that a German number has one digit too many.

Phone numbers follow national numbering plans that change all the time. Google's libphonenumber published 25 metadata releases in 2025, and every one of them updated the phone metadata of at least one country. A regex you wrote last year can't keep up with that. A library that ships the metadata can, provided you update it.

We compare the patterns and their failures in E.164 regex: why a pattern is not enough. The short version: use a regex, if at all, as a cheap pre-filter, and let a numbering-plan library decide.

A terminal sends an API request and a JSON response returns true, false and null values.A terminal sends an API request and a JSON response returns true, false and null values.
One REST call. Every answer is true, false or null (unknown).

Which library should you use: libphonenumber-js or google-libphonenumber?

For most projects, libphonenumber-js. It's a JavaScript rewrite of Google's library, released under the MIT licence, with metadata you can pick by size. Version 1.13.14 was current on 2026-09-25. Google's own JavaScript build is tied to the Closure toolchain, and the library's README estimates it at about 550 kB when bundled.

libphonenumber-js ships three metadata sets. The file sizes below come from version 1.13.14:

ImportMetadata fileWhat isValid() checksgetType()
libphonenumber-js (default, "min")84 kBMostly length and leading digitsUsually undefined
libphonenumber-js/mobile99 kBExact ranges, mobile numbers onlyMobile only
libphonenumber-js/max157 kBExact ranges for every number typeMOBILE, FIXED_LINE, TOLL_FREE…

The difference is real. On 2026-09-25, +49 1112 3456789 returned true from isValidPhoneNumber() with the min metadata and false with max. On the server, always import from libphonenumber-js/max. In the browser, min is fine for live formatting, as long as the server has the final say.

Shell
npm install libphonenumber-js

How do you parse and validate a number in Node?

Here's a small function that returns either an E.164 number or a reason. We use the same logic in our own API.

JavaScript
// phone.mjs
import { parsePhoneNumberFromString, validatePhoneNumberLength } from "libphonenumber-js/max";

// Our reserved test range (+44 7700 9xxxxx). libphonenumber marks it invalid on purpose.
const TEST_RANGE = /^\+4477009\d{5}$/;

export function normalizePhone(input, defaultCountry, { allowTestRange = false } = {}) {
  const raw = String(input ?? "").trim();
  if (!raw || raw.length > 32) return { ok: false, reason: "unparseable" };
  const p = parsePhoneNumberFromString(raw, defaultCountry);
  if (!p) return { ok: false, reason: "unparseable" };
  if (allowTestRange && TEST_RANGE.test(p.number)) return { ok: true, e164: p.number, country: "GB", type: "TEST" };
  if (!p.isValid()) {
    const len = validatePhoneNumberLength(raw, defaultCountry); // "TOO_SHORT", "TOO_LONG", … or undefined
    return { ok: false, reason: len ? len.toLowerCase() : "invalid" };
  }
  return { ok: true, e164: p.number, country: p.country ?? null, type: p.getType() ?? null };
}

Real output from Node 24 with libphonenumber-js 1.13.14:

CallResult
normalizePhone("(202) 555-0143", "US"){ ok: true, e164: "+12025550143", country: "US", type: "FIXED_LINE_OR_MOBILE" }
normalizePhone("07911 123456", "GB"){ ok: true, e164: "+447911123456", country: "GG", type: "MOBILE" }
normalizePhone("+49 1512 3456789"){ ok: true, e164: "+4915123456789", country: "DE", type: "MOBILE" }
normalizePhone("+1 800 555 0199"){ ok: true, e164: "+18005550199", country: "US", type: "TOLL_FREE" }
normalizePhone("+44 1234"){ ok: false, reason: "too_short" }
normalizePhone("+1 123 456 7890"){ ok: false, reason: "invalid" }
normalizePhone("202-555-0143"){ ok: false, reason: "unparseable" }

Three rows deserve a comment. The UK mobile comes back as GG (Guernsey), because +44 7911 ranges are shared, so don't map country codes to countries yourself. US numbers are FIXED_LINE_OR_MOBILE, because the North American plan doesn't separate the two. And a national number with no default country can't be parsed at all. Ask for the country on the form.

What's the difference between isPossible() and isValid()?

isPossible() checks only the length for the country. isValid() also checks that the digits fall inside a range the country has allocated. +1 123 456 7890 has the right length for the US, so it's possible, but it isn't valid, because US area codes never start with 1.

Use isPossible() while the user is still typing, if you want to show "keep going" feedback. Use isValid() before you save the number or pay for anything downstream. The same distinction exists in every port of libphonenumber: is_possible_number in Python, isPossibleNumber in PHP and Java.

How do you handle phone input in a form?

Format as the user types, validate on the server. AsYouType gives live formatting and a parsed number when the input is complete:

JavaScript
import { AsYouType } from "libphonenumber-js/min";

const typer = new AsYouType("US");
typer.input("2025550143");      // "(202) 555-0143"
typer.getNumber()?.number;      // "+12025550143"

In React, Vue or Svelte, call this in the input's change handler and keep two values in state: what the user sees and the E.164 value you submit. Put a country picker next to the field and pass its value as the default country. Without it, a UK user typing 07911 123456 gives you digits you can't place.

Then run normalizePhone() again on the server. Client-side checks are for the user's convenience. Anyone can bypass them.

How do you check whether the number is a mobile and in service?

Validation stops at the numbering plan. It can't see that a number was ported to a VoIP provider, or that a US number is a landline. That's what a carrier lookup is for. It returns the current line_type and carrier.

Here's a plain fetch client for the MobileValidate API (Node 18 or later). It sends numbers in the POST body, waits for slow answers, and retries only errors the API marks as retryable:

JavaScript
// lookup.mjs
const API = process.env.MOBILEVALIDATE_BASE_URL ?? "https://api.mobilevalidate.com";
const headers = {
  Authorization: `Bearer ${process.env.MOBILEVALIDATE_API_KEY}`,
  "Content-Type": "application/json",
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function call(path, init = {}, attempt = 0) {
  const res = await fetch(`${API}${path}`, { ...init, headers, signal: AbortSignal.timeout(45_000) });
  const body = await res.json().catch(() => ({})); // e.g. an HTML error page from a proxy
  if (res.ok) return body;
  const err = body.error ?? { code: "http_" + res.status, retryable: res.status >= 500 };
  if (err.retryable && attempt < 3) {
    const retryAfter = Number(res.headers.get("retry-after")) || 2 ** attempt;
    await sleep(retryAfter * 1000);
    return call(path, init, attempt + 1);
  }
  throw Object.assign(new Error(err.message ?? err.code), { code: err.code, status: res.status });
}

// Checks up to 100 E.164 numbers. Numbers go in the POST body, never in the URL.
export async function lookup(numbers, checks = ["carrier"]) {
  let lookup = await call("/v1/lookup", {
    method: "POST",
    body: JSON.stringify({ numbers, checks, wait: 10 }),
  });
  while (lookup.status === "pending") {
    await sleep(lookup.next?.poll_after_ms ?? 2000);
    lookup = await call(`/v1/lookups/${lookup.id}?wait=10`);
  }
  return lookup.results.map((r) => {
    const c = r.checks?.["network.carrier"];
    return {
      e164: r.e164,
      number_status: r.number_status,              // valid | invalid_number | duplicate | suppressed
      status: c?.status ?? null,                   // completed | unknown | pending | unsupported_country
      line_type: c?.attributes?.line_type ?? null, // null = unknown, never "not mobile"
      carrier: c?.attributes?.carrier ?? null,
      reason: c?.reason ?? null,
      checked_at: c?.checked_at ?? null,
    };
  });
}

What does the whole flow look like with a test key?

Test keys (mv_test_…) are free and return fixed answers for the test numbers. This script validates locally, skips what's invalid and checks the rest:

JavaScript
// demo.mjs
import { normalizePhone } from "./phone.mjs";
import { lookup } from "./lookup.mjs";

const testKey = process.env.MOBILEVALIDATE_API_KEY?.startsWith("mv_test_");
const input = ["+44 7700 900001", "07700 900002", "+447700900003", "+447700900004", "+44 1234"];
const ok = [], rejected = [];
for (const n of input) {
  const r = normalizePhone(n, "GB", { allowTestRange: testKey });
  (r.ok ? ok : rejected).push(r.ok ? r.e164 : { input: n, reason: r.reason });
}
console.log("rejected locally:", JSON.stringify(rejected));
console.table(await lookup(ok));

Real output (MOBILEVALIDATE_API_KEY=mv_test_… node demo.mjs, trimmed):

Text
rejected locally: [{"input":"+44 1234","reason":"too_short"}]
e164            number_status  status     line_type  carrier       reason
+447700900001   valid          completed  mobile     Test Carrier  null
+447700900002   valid          unknown    null       null          NO_DATA
+447700900003   valid          unknown    null       null          UPSTREAM_TIMEOUT
+447700900004   valid          completed  mobile     Test Carrier  null

…004 was pending for about five seconds, so the loop polled once. The allowTestRange flag exists because libphonenumber treats +44 7700 900xxx as invalid: the UK regulator Ofcom reserves that range for drama and it's never assigned. Our API uses it as the test range, so let those numbers through only when you're using a test key.

With a test key, +447700900429 makes the API answer 429 rate_limited with Retry-After: 1. In our run the client retried three times, about one second apart, and then threw rate_limited, which is the behaviour you want from a batch script.

How should your code act on each answer?

AnswerSign-up formStored record
ok: false locallyAsk the user to fix the number; don't call the APIDon't store it as a phone number
line_type: "mobile"Continue; SMS is a sensible channelStore line_type and checked_at
line_type is fixed_line, toll_free or voipOffer voice or e-mail, or add a step, depending on your risk rulesStore it; don't text it
status: "unknown"Continue with your default; never block on itKeep the previous value; retry later
number_status: "duplicate"n/aDeduplicate on the E.164 value

Unknown answers aren't billed, and they're missing data, not a "no". Don't turn null into false. The same applies to channel checks such as WhatsApp registration.

Can you use the SDK instead of fetch?

Yes. The mobilevalidate TypeScript SDK (npm install mobilevalidate) adds automatic waiting, typed errors and safe retries with idempotency keys. The same lookup looks like this:

TypeScript
import { MobileValidate } from "mobilevalidate";

const mv = new MobileValidate(); // reads MOBILEVALIDATE_API_KEY
const { data, error } = await mv.lookup(["+447700900001", "+447700900003"], { checks: ["carrier"] });
if (error) console.error(error.code, error.message);
else for (const r of data.results) {
  const c = r.checks?.["network.carrier"];
  console.log(r.e164, c?.status, c?.attributes?.line_type ?? null, c?.reason);
}
// +447700900001 completed mobile null
// +447700900003 unknown null UPSTREAM_TIMEOUT

The SDK docs cover jobs, e-mail checks and webhooks. Until the package is published, the fetch client above does the same job.

What should you test?

Keep two kinds of tests. Unit tests for normalizePhone() need no network: one valid number per country you serve, one impossible number (+1 123 456 7890), one too short, one without a country. Integration tests run against the API with a test key and the test numbers: …001 gives data, …002 and …003 give unknown, …004 exercises polling, and …429 exercises your retry path. Nothing is billed and no real network is queried.

Don't use real customer numbers in fixtures. Besides being personal data, they change owners.

What are the key takeaways?

  • Import from libphonenumber-js/max on the server. The default min metadata checks little more than lengths.
  • Use isValid() before storing or paying for anything; isPossible() only for live typing feedback.
  • Store p.number (E.164) and keep the country from the parser, not from the country code.
  • Validation can't tell you the line type or whether the number is in service. A carrier lookup can, from your server, with numbers in the POST body.
  • Treat unknown as missing data, retry only when the API says retryable, and build every branch with free test numbers.
  • Update libphonenumber-js regularly. Numbering plans change every few weeks.

Sources

  1. libphonenumber-js — GitHub (catamphetamine), 2026
  2. libphonenumber — Google, 2026
  3. Validate phone number with JavaScript (question 4338267) — Stack Overflow, 2026
  4. ITU-T Recommendation E.164 — International Telecommunication Union, 2026

Frequently asked questions

Which library should I use to validate phone numbers in JavaScript?

libphonenumber-js is the most widely used option. It is a JavaScript rewrite of Google's libphonenumber with smaller metadata files. Import it from libphonenumber-js/max on the server if you need full validation and number types.

Why does isValid() accept numbers that don't exist?

With the default min metadata, isValid() checks mostly lengths and leading digits, not the exact number ranges. Import from libphonenumber-js/max to check ranges. Even then, a valid number can be unassigned or switched off. Only a network lookup tells you that.

Can I validate phone numbers with a regular expression instead?

Only as a rough pre-filter. A pattern such as ^+[1-9]\d{1,14}$ accepts numbers with impossible area codes and unassigned country codes. A numbering-plan library knows each country's ranges and lengths.

Should I call a phone lookup API from the browser?

No. Keep API keys on your server. Validate the format in the browser for quick feedback, then call the lookup API from your backend.

All articles

Know before you send.

Tell us about your use case. We review every request and set you up with test and live keys.