Phone numbers fail differently from email addresses. A bad email costs you a bounce. A bad phone number costs you an SMS fee, and when someone scripts your "send code" button against premium-rate ranges, it costs you a lot of SMS fees very quickly. The fraud pattern has a name, SMS pumping, and the defence starts with the same step as every other verification: refuse to act on input you have not understood.
Understanding a phone number means four things: which country it belongs to, whether it is a real number in that country's plan, what kind of line it is, and what its one canonical spelling is. Phone Number Validator answers all four in one call at 10 credits. Disposable Phone Number Checker adds one more answer, at 6 credits: has this number been published on a site that receives SMS for anyone who asks?
Give the validator enough to work with
People type phone numbers in every format: (816) 555-1017, 816.555.1017, +1 816 555 1017, 0044 7911 123456. The validator accepts any of them, but it needs to know which numbering plan to read them in. That comes from one of two places: a leading + with the country calling code, or the country parameter as a two-letter code.
curl "https://api.apiverve.com/v1/phonenumbervalidator?number=%28816%29+555-1017&country=us" \
-H "x-api-key: YOUR_API_KEY"isValid true, type fixed_line_or_mobile, formatted.e164 +18165551017.
Send a national-format number with no country and the call fails with a clear message: country is required unless the number is in international format (starting with +, URL-encoded as %2B in a query string). That last clause is the bug to watch for. In a query string, a raw + decodes as a space. If you build the URL by string concatenation, +18165551017 arrives as 18165551017 and the number is no longer international. URLSearchParams in Node and the params argument in Python requests both encode it correctly as %2B; hand-built URLs do not.
Where does country come from? In order of reliability: a country selector next to the phone field, the country from the billing or shipping address on the same form, and last, the country of the visitor's IP. Do not default silently to your own country. A UK visitor who types 07911 123456 into a form that assumes us gets a confident wrong answer, as you are about to see.
isValid versus isValidNumber, and what country really means
The response has two validity flags and three country fields, and the differences are the most useful part of it.
countryis what you sent, uppercased, ornullif you sent an international number.detectedCountryis the region the number actually belongs to, as an ISO code.isValidmeans valid for the country you supplied.isValidNumbermeans valid in its own detected country, whatever you supplied.
Validate the Italian-format number 06 6982 1234 with country: "it" and the result is isValid: false, isValidNumber: true, detectedCountry: "VA", countryName: "Holy See (Vatican City)". The number is real. Vatican City shares Italy's +39 calling code, and that range belongs to it, not to Italy. Shared calling codes are common: +1 covers the US, Canada and much of the Caribbean, and +44 covers the UK and the Crown Dependencies. +44 7911 123456 comes back with detectedCountry: "GG", Guernsey, and four time zones.
curl "https://api.apiverve.com/v1/phonenumbervalidator?number=%2B44+7911+123456" \
-H "x-api-key: YOUR_API_KEY"No country sent, so country is null. detectedCountry is GG, a real +44 region that is not GB.
So which flag gates? For a pre-SMS check, isValidNumber. You want to know if the number can exist, and a Vatican or Guernsey number is a perfectly good recipient. Use isValid when the country is a business rule, such as a service you may only offer to residents of one country. Then the mismatch is the signal, and the number goes to review rather than reject, because a Jersey resident with a UK mobile is not a fraudster.
Never store a formatted number from an invalid result
Here is the failure that ends up in databases. Validate the UK mobile 07911 123456 with country: "us":
{
"country": "US",
"detectedCountry": null,
"isValid": false,
"isValidNumber": false,
"type": "unknown",
"formatted": {
"e164": "+107911123456",
"international": "+1 07911123456"
}
}
The formatted block is still populated. It is the input mechanically prefixed with the supplied country code, and +107911123456 is not a phone number anywhere. Code that does user.phone = data.formatted.e164 without checking validity first will write it, and every later system will trust it because it looks canonical. Only read formatted after isValidNumber is true.
A signup form assumes country=us. A visitor types 07911 123456. The response has isValid false and formatted.e164 +107911123456. What should your code do?
Answer the check above to continue.
Disposable numbers, and why E.164 comes first
A disposable number is one published on a public receive-SMS website, where anyone can read the codes sent to it. A verification code sent there verifies nothing. The validator's isDisposable flag already reflects this: +12085813945 comes back isDisposable: true. The dedicated checker answers the same question for a number you already hold in E.164 and adds the date the number was first seen on those lists.
curl "https://api.apiverve.com/v1/disposablephone?phone=%2B12085813945" \
-H "x-api-key: YOUR_API_KEY"isDisposable true, firstSeen 2024-01-30.
The phone parameter must be E.164. That requirement is not cosmetic. Send the same number as 208-581-3945 and the checker returns isDisposable: false and firstSeen: null. Nothing in the response says the format was wrong; it simply finds no match. That is a false negative that looks exactly like a clean result, and it is the most likely bug in any integration that passes form input straight through.
The order that avoids it: validate first, take formatted.e164 from a valid result, and only then ask the disposable question. In a signup flow you can skip the second call because the validator already carries isDisposable. The checker earns its place in batch jobs over numbers you already store in E.164, such as re-screening your user table every month, where 6 credits per number beats 10 and the numbers are already canonical. firstSeen helps there too: a number that appeared on a list last week, attached to an account created two years ago, is more likely recycled than abusive, and deserves a re-verification prompt rather than a ban.
The pre-SMS gate
Put it together as a function that runs before your SMS provider is ever called. It returns the E.164 number to send to, or a reason not to send.
const BASE = 'https://api.apiverve.com/v1';
const SEND = new Set(['mobile', 'fixed_line_or_mobile']);
const VOICE = new Set(['fixed_line', 'voip', 'personal_number']);
export async function smsGate(number, country) {
const params = new URLSearchParams({ number });
if (country) params.set('country', country);
const res = await fetch(`${BASE}/phonenumbervalidator?${params}`, {
headers: { 'x-api-key': process.env.APIVERVE_API_KEY },
signal: AbortSignal.timeout(3000),
});
const body = await res.json().catch(() => null);
const callerProblem = res.status >= 400 && res.status < 500 && ![401, 403, 429].includes(res.status);
if (callerProblem) return { send: false, reason: 'unparsed', message: body?.error };
if (!res.ok || body?.status !== 'ok') throw new Error(body?.error || `HTTP ${res.status}`);
const d = body.data;
if (!d.isValidNumber) return { send: false, reason: 'invalid' };
const e164 = d.formatted.e164;
if (d.isDisposable) return { send: false, reason: 'disposable', e164 };
if (d.extension) return { send: false, reason: 'has_extension', e164 };
if (VOICE.has(d.type)) return { send: false, reason: 'use_voice', e164 };
if (!SEND.has(d.type)) return { send: false, reason: `refused_${d.type}`, e164 };
return { send: true, e164, countryMismatch: Boolean(country) && !d.isValid };
}import os
from typing import Optional
import requests
SEND = {"mobile", "fixed_line_or_mobile"}
VOICE = {"fixed_line", "voip", "personal_number"}
def sms_gate(number: str, country: Optional[str] = None) -> dict:
params = {"number": number}
if country:
params["country"] = country
r = requests.get(
"https://api.apiverve.com/v1/phonenumbervalidator",
params=params,
headers={"x-api-key": os.environ["APIVERVE_API_KEY"]},
timeout=3,
)
if 400 <= r.status_code < 500 and r.status_code not in (401, 403, 429):
return {"send": False, "reason": "unparsed", "message": r.json().get("error")}
r.raise_for_status()
d = r.json()["data"]
if not d["isValidNumber"]:
return {"send": False, "reason": "invalid"}
e164 = d["formatted"]["e164"]
if d["isDisposable"]:
return {"send": False, "reason": "disposable", "e164": e164}
if d["extension"]:
return {"send": False, "reason": "has_extension", "e164": e164}
if d["type"] in VOICE:
return {"send": False, "reason": "use_voice", "e164": e164}
if d["type"] not in SEND:
return {"send": False, "reason": f"refused_{d['type']}", "e164": e164}
return {"send": True, "e164": e164, "countryMismatch": bool(country) and not d["isValid"]}The first branch separates two kinds of 4xx. A 401, 403 or 429 is about your key, its permissions or your quota, and the visitor can do nothing about it. Any other client error, such as the "country is required" refusal, is about the input, and the right response is to ask the visitor to fix it. Neither is an outage to retry, and lumping them together is how a missing country selector ends up paging someone at 3am as an "SMS provider failure".
A gate is not the whole defence. Rate-limit the send-code route per IP and per destination number, cap daily spend per country prefix in your SMS provider, and alert when the ratio of codes sent to codes entered drops. A validated number that nobody ever types a code back from, repeated a thousand times, is still pumping.
Store e164 as the one canonical value and derive every display format from it at render time. That gives you a reliable unique key for "one trial per phone number" and clean joins against your SMS provider's delivery logs, which always speak E.164.
Next
Phones and emails are single tokens. Addresses are free text with structure buried inside, and getting them wrong costs you returned parcels and duplicate customers. Continue to Parse and normalise street addresses.