Phone number validation in JavaScript works at three levels: a regex checks the shape of the input, libphonenumber-js checks the number against each country’s numbering plan in the browser or in Node.js, and a phone validation API on your server adds the carrier and line type and gives you a result users cannot bypass. This guide shows all three with code we ran: the regex and library snippets with Node.js 26 and libphonenumber-js 1.13.15, the API calls against a local stub of the numlookupapi API that returns the documented responses.
Which check do you need?
| Check | Catches | Runs | Cost |
|---|---|---|---|
| Regex | Letters, missing +, too many digits | Browser, server | Free |
| libphonenumber-js | Wrong length or prefix for the country, impossible numbers; gives the E.164 form and, with full metadata, the number type | Browser, server | Free, 80 to 145 kB of metadata |
| Validation API | All of the above, plus carrier, line type, location and time zones, in a check users cannot skip | Server | One request per number |
Most sign-up forms use the library while the user types and the API once on submit.
1. Regex: the shape only
A regex is fine as a first filter, for example to reject letters before anything else runs:
const E164 = /^\+[1-9]\d{1,14}$/;
E164.test("+442079460958"); // true
E164.test("+15555555555"); // true, but not a real number
E164.test("+44 20 7946 0958"); // false: spaces are not allowed in E.164
The pattern checks the E.164 format: a plus, a first digit that is not 0, at most 15 digits. It cannot know that UK mobile numbers start with 7 after the country code, that German numbers have different lengths, or that the US area code 555 is not in service. Don’t write a regex per country either: numbering plans change several times a year.
2. libphonenumber-js: numbering-plan validation
libphonenumber-js is a JavaScript port of Google’s libphonenumber with the official metadata for every country.
npm install libphonenumber-js
import { parsePhoneNumberFromString } from "libphonenumber-js/max";
const phone = parsePhoneNumberFromString("07400 123456", "GB");
if (phone?.isValid()) {
console.log(phone.number); // "+447400123456" (E.164: store this)
console.log(phone.formatInternational()); // "+44 7400 123456"
console.log(phone.formatNational()); // "07400 123456"
console.log(phone.getType()); // "MOBILE"
console.log(phone.country); // "GB"
}
const fake = parsePhoneNumberFromString("+15555555555");
console.log(fake.isPossible(), fake.isValid()); // true false
isValid()checks the length and the prefix against the country’s numbering plan. Use it to accept or reject.isPossible()only checks the length. It suits live feedback while the user types, not the final decision.getType()returnsMOBILE,FIXED_LINE,FIXED_LINE_OR_MOBILE(US and Canada, where both share the same ranges),VOIP,TOLL_FREEand others. It needs themaxmetadata: the README says that with the defaultminmetadata it returnsundefinedfor most countries, and in our run it did so for Germany, India, Nigeria and Brazil.- The second argument is the default country for numbers without
+. Numbers starting with+ignore it.
Metadata size. The README gives about 80 kB for min (the default import), about 145 kB for max and about 95 kB for mobile. Load the library only on pages with a phone field, for example with a dynamic import() when the field gets focus.
Format while typing. AsYouType formats a partial number the way people write it in that country:
import { AsYouType } from "libphonenumber-js/max";
new AsYouType("US").input("2133734"); // "(213) 373-4"
This is the same metadata the free phone number validator and formatter on this site use in your browser.
3. Server side: a phone validation API
Client-side checks are easy to skip (anyone can call your endpoint without your JavaScript), and the bundled metadata says nothing reliable about carriers. On the server, the numlookupapi API checks the number and returns carrier, line type, location, time zones and every format in one request. Get a free API key on the pricing page (250 requests a month for testing and personal projects, no credit card) and put it in an environment variable:
export NUMLOOKUPAPI_KEY="your-api-key"
// numlookup.mjs – Node.js 18 or later (built-in fetch)
const API = "https://api.numlookupapi.com/v1/validate";
export async function validatePhone(number, countryCode) {
const url = new URL(`${API}/${encodeURIComponent(number)}`);
if (countryCode) url.searchParams.set("country_code", countryCode);
const res = await fetch(url, {
headers: { apikey: process.env.NUMLOOKUPAPI_KEY },
signal: AbortSignal.timeout(5000),
});
const body = await res.json();
if (res.status === 422) return { valid: false, error: body.message }; // invalid_number, invalid_country_code
if (!res.ok) throw new Error(`numlookupapi ${res.status}: ${body.error?.code ?? body.message}`);
return body; // an unknown number is 200 with valid: false
}
validatePhone("+14158586273") returns the documented answer:
{
"valid": true,
"number": "14158586273",
"local_format": "4158586273",
"international_format": "+14158586273",
"country_prefix": "+1",
"country_code": "US",
"country_name": "United States of America",
"location": "Novato",
"carrier": "AT&T Mobility LLC",
"line_type": "mobile",
"e164_format": "+14158586273",
"national_format": "(415) 858-6273",
"rfc3966_format": "tel:+1-415-858-6273",
"possible": true,
"number_type": "landline_or_mobile",
"timezones": ["America/Los_Angeles"]
}
Three things to know about the answers:
- Check
valid, not the status code. A well-formed number that is not in use returns HTTP 200 with"valid": false. A 422 means malformed input, such as an empty number (invalid_number) or an unknowncountry_code(invalid_country_code). - Numbers without a country code need
country_code:validatePhone("4158586273", "US"). Numbers starting with+or00are looked up as sent. number_type: "voip"marks internet-based numbers, which matters if you send one-time codes by SMS.line_typeismobile,landline,toll_free,premium_rateand so on; the validate endpoint documentation lists all values.
Errors: 401 means a missing or wrong key, 429 a used-up monthly quota or, on the free plan, more than 10 requests a minute. Only successful requests count against your quota.
4. Validate a list in batches
For CRM imports and contact lists, send up to 100 numbers per request. Each number counts as one request, identical entries in the same batch are validated and billed once, and the X-Cost response header shows what a request cost. A number that cannot be read at all gets "error": "invalid_number" in its own entry instead of failing the batch.
export async function validateMany(numbers, countryCode) {
const unique = [...new Set(numbers)];
const results = {};
for (let i = 0; i < unique.length; i += 100) { // up to 100 numbers per request
const res = await fetch(API, {
method: "POST",
headers: { apikey: process.env.NUMLOOKUPAPI_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ numbers: unique.slice(i, i + 100), ...(countryCode && { country_code: countryCode }) }),
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) throw new Error(`numlookupapi ${res.status}`);
Object.assign(results, (await res.json()).data); // keyed by the number as sent
}
return results;
}
The same number written two ways (+14158586273 and +1 415 858 6273) counts twice, so normalize to E.164 with libphonenumber-js first. Without code, the free bulk phone validator checks a list in your browser, and the batch upload in the numlookupapi dashboard validates CSV, TXT and Excel files with the same fields as the API.
5. Put it together: a sign-up endpoint
The library rejects obvious mistakes for free; the API runs only for numbers that can exist. If the API cannot be reached, the endpoint keeps the format check instead of blocking the sign-up:
// phone-route.mjs – Express 5: check the format for free, then ask the API
import express from "express";
import { parsePhoneNumberFromString } from "libphonenumber-js/max";
import { validatePhone } from "./numlookup.mjs";
const app = express();
app.use(express.json());
app.post("/api/phone", async (req, res) => {
const parsed = parsePhoneNumberFromString(String(req.body.phone ?? ""), req.body.country);
if (!parsed?.isValid()) {
return res.status(422).json({ error: "Please enter a valid phone number." }); // no API request spent
}
try {
const result = await validatePhone(parsed.number);
if (!result.valid) return res.status(422).json({ error: "This number is not in use." });
// store result.e164_format; decide what a VoIP number may do in your product
res.json({ phone: result.e164_format, lineType: result.line_type, voip: result.number_type === "voip" });
} catch (err) {
console.error(err.message);
res.json({ phone: parsed.number, lineType: null, voip: null }); // API unreachable: keep the format check
}
});
app.listen(3000);
In our run, (415) 858-6273 with country US came back as +14158586273 with line type mobile, while +1 555 555 5555 and hello got a 422 without an API request. In the form itself, <input type="tel" autocomplete="tel"> brings up the phone keypad on mobile devices and lets browsers fill in the number; it does not validate anything, so keep the checks above.
What validation cannot tell you
A valid number exists in the numbering plan; it may still be switched off, disconnected or moved to another network. The carrier field names the network the number range was assigned to, which stays the same after a number is ported. For the live status, an HLR lookup asks the mobile network itself: add hlr=1 to a request on a paid plan to get reachable, ported and current_carrier (see the HLR lookup API). For one-time codes, the only proof that a number belongs to the user is a code they type back.
More languages: validate phone numbers in Python and in PHP.