Most phone validation in the wild is a regex that counts digits. It passes +1 999 123 4567, which can never ring, and it rejects nothing that matters.
There is a real specification underneath, and the assignment data is public. This is what it says.
The file
The North American Numbering Plan Administrator publishes the current assignment of every area code as a CSV:
https://reports.nanpa.com/public/npa_report.csv
No key, no signup. It has 800 rows — one per possible area code — with 32 columns. The ones that matter:
| Column | What it holds |
|---|---|
NPA_ID |
the three-digit area code |
IN_SERVICE |
Y or N
|
USE |
G geographic, N non-geographic (toll-free and similar) |
EXPLANATION |
e.g. Expansion Code
|
LOCATION |
state, province or territory |
COUNTRY |
US, CANADA, BAHAMAS, … |
TIME_ZONE |
E, C, M, P, … |
Counting the file as of 4 September 2026: 378 geographic area codes in service in the United States, and 454 across the whole plan once Canada and the Caribbean members are included. If a tool tells you "378 area codes" while also promising Canadian coverage, it counted the wrong column.
The rules a regex misses
The area code has to exist. 999 does not. It is one of 79 codes with a 9 in the middle position that the file marks Expansion Code — held back for the day the plan runs out of numbers. Every one of them fails, and every one of them passes a length check.
Area code and exchange both start with 2–9. Never 0 or 1. This one most people do get right.
The N11 codes are service codes. 911, 411, 211, 311, 511, 611, 711, 811 — never assignable as an exchange.
And then there is 555. Here is where almost everyone overcorrects. The block reserved for fiction is narrow: 555-0100 through 555-0199, and only that. 555-1212 is live directory assistance across the continent. So:
# wrong — rejects a working number
if exchange == "555":
return "invalid"
# right — rejects only the reserved block
if exchange == "555" and 100 <= int(line) <= 199:
return "invalid"
A validator that rejects the whole 555 exchange will quietly throw away real numbers from a real list, and you will never see it happen.
What a validated number is worth
Once a number passes, the same file hands you two fields that turn a list into a call plan:
-
LOCATION— the state or province of the area code -
TIME_ZONE— which is what you actually wanted
Sorting a call list by the recipient's local time is the difference between a 9 a.m. call and a 6 a.m. one. One gotcha to hard-code: Arizona does not observe daylight saving. All five Arizona area codes are marked Mountain, but for eight months of the year they are an hour off Denver. Use America/Phoenix, not America/Denver, or half your calls land at the wrong hour. (The Navajo Nation inside Arizona does observe DST, which is the kind of edge case worth knowing exists and not worth solving.)
Area codes that straddle two or three time zones exist too — a couple of dozen of them carry combinations like CM or EC in the file. Treat those as "confirm before dialling", not as a single zone.
Real output
An actual run, four numbers, September 2026:
[
{ "phone": "+1 415 555 0132",
"phoneStatus": "invalid", "phoneReason": "reserved_exchange" },
{ "phone": "+1 999 123 4567",
"phoneStatus": "invalid", "phoneReason": "nanp_rule" },
{ "phone": "+1 646 555 0100",
"phoneStatus": "invalid", "phoneReason": "reserved_exchange" },
{ "phone": "+62 361 123456",
"phoneStatus": "valid", "phoneType": "international",
"phoneReason": "structure_only", "phoneE164": "+62361123456" }
]
The last one is the honest part. 0361 is genuinely the Denpasar area code, so the number is correctly formed for Indonesia — but outside the NANP there is no equivalent public assignment file to check against. structure_only says exactly that. Any tool that claims to "validate" an arbitrary international number without saying which check it ran is telling you it checked something it did not.
What this does not give you
Line type. Whether a number is mobile, landline or VoIP is not in the assignment file. Carrier and line type come from HLR lookups, which are a paid product with per-number pricing, and the answer changes when someone ports their number.
Whether it rings. Nothing short of dialling tells you that, and dialling to find out is its own problem.
Ownership. Reverse lookup is a separate, and in most places regulated, product.
What the file does give you is the cheap half: everything that can never be a working number, removed before you pay anyone per lookup. On scraped and bought lists that is a meaningful slice, and it costs a DNS-free, API-free file read.
Running it
The Email Verifier & Phone Number Validator actor applies every rule above, plus the same treatment for email addresses in the same pass:
{
"phones": ["+1 415 555 0132", "+1 646 212 3344", "+62 361 123456"],
"defaultCountry": "US"
}
curl -X POST "https://api.apify.com/v2/acts/lergassy~email-phone-verifier/runs" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_APIFY_TOKEN" \
-d '{"phones":["+1 415 555 0132","+1 646 212 3344"]}'
It also reads another actor's dataset directly, so a scraped lead list can be validated where it already sits:
{ "inputDatasetId": "abc123def456", "phoneField": "phone", "keep": "valid_phone" }
If you build your own, the file is the whole trick: download it, index by NPA_ID, and check IN_SERVICE before anything else. It updates as codes come into service, so re-fetch it monthly rather than freezing a copy in your repo.
Actor: Email Verifier & Phone Number Validator on Apify.
Top comments (0)