← Blog

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: ParseIsValidNumberFormat(…, PhoneNumberFormat.E164)GetNumberType. It's free and offline, and often all you need.
  • Always wrap Parse in a try/catch for NumberParseException, 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_MOBILE on 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.

Frequently asked questions

How do I validate a phone number in C# using libphonenumber-csharp?

Use the PhoneNumberUtil class: parse the number with Parse (passing a default region for national-format numbers), then call IsValidNumber to check that it is a plausible, correctly structured number for its country. You can also read GetNumberType for the line type and Format with E164 to normalize it. This runs entirely offline against Google's numbering metadata, so it validates format and region without any network call or lookup.

Does libphonenumber-csharp tell me if a phone number is real and active?

No. libphonenumber-csharp checks that a number's format, length, and prefix are valid for its region, but it cannot confirm the line is connected, in service, or reachable, and it does not do a carrier or HLR lookup. A number can pass IsValidNumber and still be unassigned or disconnected. Confirming an active line requires a live carrier or HLR query, which is a separate service beyond what the library performs locally.

What does Boundstone's phone verify API add on top of libphonenumber?

Boundstone's phone endpoint returns format validity, region, and line-type metadata, and every response lists checks.performed (format, region, line_type_metadata) alongside checks.not_performed, so you always know the limits of the result. It honestly reports that carrier_lookup, ported_status, and hlr_liveness are not performed by default (a live HLR dip is available as a paid opt-in with hlr:true, 5 credits), rather than implying a valid number is a live one. The free tier gives 250 credits per month with no card and credits that never expire, so you can test the same libphonenumber-grade validation through a hosted API before wiring in anything paid.

Thomas Tsui

Founder of Boundstone — building phone, email and IP validation you can actually verify.

One honest API for email, phone and IP — every response lists what it checked and what it didn't claim to. Free tier: 250 credits/month, no card, credits never expire.

More from Boundstone — API documentation · Benchmark methodology · The benchmark series · Buyer's checklist