DEV Community

Nikita Iakovlev
Nikita Iakovlev

Posted on

Phone Number Validation Without a Paid API — What the NANPA File Actually Tells You

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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" }
]
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode
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"]}'
Enter fullscreen mode Exit fullscreen mode

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" }
Enter fullscreen mode Exit fullscreen mode

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)