Phone number validation in JavaScript: regex, libphonenumber, and E.164
The regex you found on Stack Overflow works until the first international number. Here's the approach that actually holds up — and a straight answer on where an API earns its keep.
Contents
Phone validation looks easy until the second country shows up. A US number is ten digits; a UK mobile is eleven with a leading zero you drop for international; an Australian mobile starts +61 4. No single regex survives contact with the real world's numbering plans. Here's how to validate phone numbers in JavaScript in a way that does — and a candid note on when you actually need an API for it.
The regex, and why it breaks
Here's the kind of pattern people reach for:
const US_PHONE = /^\+?1?\s*\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}$/;
US_PHONE.test("+1 650 447 2983"); // true
That's fine if you only ever take US numbers and you're mostly guarding against typos. The moment you accept international input it falls apart: it doesn't know that +44 7911 123456 is a valid UK mobile, that +61 491 570 156 is a valid Australian one, or that +1 555 555 5555 is shaped right but sits in an unassigned range. Numbering plans encode length rules, prefix rules, and mobile-vs-fixed rules that differ per country and change over time. A regex can't carry that, and the "full international phone regex" you'll find is both enormous and wrong.
libphonenumber-js: the approach that holds up
Google maintains libphonenumber, the library that ships inside Android to parse and validate numbers using the actual per-country rules. For JavaScript, libphonenumber-js is a compact port:
npm install libphonenumber-js
import { isValidPhoneNumber, parsePhoneNumber } from "libphonenumber-js";
isValidPhoneNumber("+16504472983"); // true
isValidPhoneNumber("+1 555 555 5555"); // false — right shape, unassigned
const phone = parsePhoneNumber("+16504472983");
phone.country; // "US"
phone.number; // "+16504472983" (E.164 — store this)
phone.getType(); // "FIXED_LINE_OR_MOBILE"
phone.formatNational(); // "(650) 447-2983"
phone.formatInternational(); // "+1 650 447 2983"
If your input doesn't include a country code, pass a default country so the national number can be interpreted:
isValidPhoneNumber("07911 123456", "GB"); // true
Two things worth internalizing. First, always store phone.number — the E.164 form. It's the unambiguous international format every downstream API (SMS, voice, CRM) expects. Second, getType() is honest about ambiguity: for many numbers it returns FIXED_LINE_OR_MOBILE, because the numbering plan genuinely can't distinguish the two from the digits alone. That's not a bug — it's the library refusing to guess, which is exactly what you want.
What metadata can't tell you
libphonenumber works on metadata — the published structure of numbering plans. It can tell you a number is valid, which country it belongs to, and how to format it. It cannot tell you:
- which carrier currently serves the number (numbers get ported between carriers, so the prefix no longer settles it),
- whether it's still assigned to a live handset, or
- whether that handset is switched on right now (a live "HLR" lookup).
Those need a network query, not a lookup table. This is the honest dividing line, and it's worth being clear-eyed about: for validation and formatting, libphonenumber-js in the browser or in Node is genuinely all you need, and you don't need us for it.
So where does an API come in?
Three honest reasons, and we'd rather name them plainly than pretend the metadata layer is a moat:
- One consistent contract across languages and clients. If you validate phone numbers in a browser, a Node service, and a Python worker, you're maintaining three copies of the metadata and three definitions of "valid." One API call gives every surface the same answer.
- Phone, email, and IP in one system. Signup screening usually wants all three. Doing them through one credit system with one honesty contract beats stitching three libraries together.
- The layer past metadata, when you want it. Carrier, ported-status and live-reachability (HLR) checks are the things libphonenumber cannot do. On Boundstone they are marked
not_performedby default and run as a paid opt-in — sendhlr:truefor a real network dip at 5 credits, refunded when the network cannot answer. Never faked either way.
Here's the call, and note what comes back:
const res = await fetch("https://api.boundstone.io/v1/verify/phone", {
method: "POST",
headers: { authorization: "Bearer bs_live_YOUR_KEY", "content-type": "application/json" },
body: JSON.stringify({ phone: "+16504472983" }),
});
const data = await res.json();
console.log(data.valid, data.country, data.line_type, data.e164);
console.log("checked: ", data.checks.performed);
// ['format', 'region', 'line_type_metadata']
console.log("not checked:", data.checks.not_performed);
// ['carrier_lookup', 'ported_status', 'hlr_liveness']
That not_performed list is the whole point. It says, in the response itself, that today's answer is metadata-grade — the same class of data libphonenumber gives you — and that carrier and liveness were not consulted. A phone API that returns a bare valid: true and won't tell you whether a live network was ever queried is asking you to trust a word. Prefer the one that shows its work. You can try it with no key on the phone validator.
The short version
Use a regex only to catch typos in a single-country field. For anything real, use libphonenumber-js — it's the correct, honest answer for validation and formatting, and it runs client-side for free. Reach for an API when you want one contract across many clients, phone alongside email and IP, or the carrier/liveness layer that metadata can't reach — and only ever trust a "valid" that tells you what it checked.