Validate a phone number in C# with libphonenumber-csharp
libphonenumber-csharp validates and formats numbers to E.164 on your own machine for free — here's how to wire it up, and the honest line where a verification API earns its keep.
Contents
You want to validate a phone number in C#, and you want more than a regex that accepts anything with a + and a run of digits. Good news: you can do this locally, for free, with a library that already knows the numbering plan of every country. Better news: if your stack is all .NET, you probably don't need a paid API to start. Here's the honest version — what the library does, what it can't, and the one place an API earns a line in your budget.
Start local: libphonenumber-csharp
libphonenumber-csharp is a direct port of Google's libphonenumber — the same metadata that powers Android's dialer, packaged for .NET under the PhoneNumbers namespace. It runs offline. No network call, no per-lookup cost.
dotnet add package libphonenumber-csharp
The entry point is PhoneNumberUtil.GetInstance(). It loads bundled metadata, so grab it once and reuse it — don't call it per request.
using PhoneNumbers;
var util = PhoneNumberUtil.GetInstance();
try
{
PhoneNumber number = util.Parse("650-447-2983", "US");
bool isValid = util.IsValidNumber(number);
string e164 = util.Format(number, PhoneNumberFormat.E164);
PhoneNumberType type = util.GetNumberType(number);
Console.WriteLine($"Valid: {isValid}"); // True
Console.WriteLine($"E.164: {e164}"); // +16504472983
Console.WriteLine($"Line type: {type}"); // FIXED_LINE_OR_MOBILE
}
catch (NumberParseException ex)
{
Console.WriteLine($"Could not parse: {ex.Message}");
}
Parse throws NumberParseException on input it can't make sense of, so it belongs in a try/catch — a bare regex would just silently return false and lose the reason. IsValidNumber then checks the parsed number against the assigned ranges for its region. There's also a looser, cheaper IsPossibleNumber if you only care whether the length could plausibly work.
Region matters: Parse needs to know where a number is from
Parse takes a default region as its second argument. For a number already in E.164 form (+16504472983), the + carries the country code and you can pass null. For a national-format string like (650) 447-2983, the library has no way to guess the country — you must tell it.
util.Parse("+61 2 8123 4567", null); // country code is in the number
util.Parse("02 8123 4567", "AU"); // needs the region hint
Once parsed, formatting is a menu: PhoneNumberFormat.E164 for storage, INTERNATIONAL and NATIONAL for display, RFC3966 for tel: links. Store E.164, show whatever your UI wants. (If E.164 itself is new to you, we wrote up what E.164 is and why you should store it.)
Read the line type — and what it can't promise
GetNumberType returns a PhoneNumberType: MOBILE, FIXED_LINE, TOLL_FREE, VOIP, and friends. It's useful for routing an SMS versus a voice call — but read the fine print. In North America many ranges come back as FIXED_LINE_OR_MOBILE, because the metadata genuinely can't tell the two apart from the number alone. That's not the library hedging; that's the honest answer.
The deeper limit is the important one: valid does not mean reachable. libphonenumber checks format and assigned range, offline, against its bundled tables. It does not dial the number, does not ask the carrier, and does not know whether the line is disconnected or has been ported to a different network. IsValidNumber returns True for a well-formed number in a live range even if nobody has answered it in years. That's a different question — and worth being clear about before you treat a green check as "this person can receive my text."
A small reusable check
using PhoneNumbers;
public record PhoneCheck(bool Valid, string? E164, PhoneNumberType Type);
public static class PhoneValidation
{
private static readonly PhoneNumberUtil Util = PhoneNumberUtil.GetInstance();
public static PhoneCheck Check(string input, string defaultRegion)
{
try
{
PhoneNumber number = Util.Parse(input, defaultRegion);
if (!Util.IsValidNumber(number))
return new PhoneCheck(false, null, PhoneNumberType.UNKNOWN);
return new PhoneCheck(
Valid: true,
E164: Util.Format(number, PhoneNumberFormat.E164),
Type: Util.GetNumberType(number));
}
catch (NumberParseException)
{
return new PhoneCheck(false, null, PhoneNumberType.UNKNOWN);
}
}
}
Drop that into a form handler or a CSV import and you've covered the honest, unglamorous majority of the work: reject malformed input, normalize the rest to E.164, keep the line type around.
Where an API fits — and where it doesn't yet
If your whole stack is C#, libphonenumber-csharp is genuinely most of what you need today, and it's free. Say that plainly to yourself before you reach for a subscription. There are two honest reasons to reach anyway:
One contract across languages. If your Node, Python, and Go services each bundle their own libphonenumber version, they drift — a number that's valid in one service is stale in another because the metadata versions differ. Boundstone's POST /v1/verify/phone gives every client the same answer over HTTP, no per-language upgrade dance.
The layer past metadata. Carrier lookup, ported status, and HLR liveness are exactly what a local library cannot do. Boundstone does not run them by default either — they are a paid opt-in, hlr:true at 5 credits, refunded when the network cannot answer. Left off, they are reported as not_performed rather than faked.
curl -s https://api.boundstone.io/v1/verify/phone \
-H "Authorization: Bearer bs_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"phone":"+16504472983"}'
{
"valid": true,
"e164": "+16504472983",
"country": "US",
"line_type": "fixed_line_or_mobile",
"national_format": "(650) 447-2983",
"checks": {
"performed": ["format", "region", "line_type_metadata"],
"not_performed": ["carrier_lookup", "ported_status", "hlr_liveness"]
}
}
That checks block is the whole point. A "valid": true from Boundstone today means the same class of thing IsValidNumber means — format and range, not liveness — and we print not_performed so you never mistake the two. The list you can't yet rely on is precisely what makes the valid you can rely on trustworthy. Want to poke at it without a key? Try the free phone validator tool. Writing the JVM side of this too? Here's the Java version — same contract, different language.
The short version
- For C#, use libphonenumber-csharp locally:
Parse→IsValidNumber→Format(…, PhoneNumberFormat.E164)→GetNumberType. It's free and offline, and often all you need. - Always wrap
Parsein atry/catchforNumberParseException, and pass a default region for national-format input. - Valid is not reachable. The library checks format and range, never the live network. Expect
FIXED_LINE_OR_MOBILEon many ranges. - Reach for the API when you need one consistent contract across languages, or the carrier/HLR liveness layer — which isn't shipped yet, and we say so.
- Boundstone's free tier is 250 credits/month, no card, credits never expire — enough to wire the contract in and see the honesty block for yourself.