DEV Community

Cover image for Phone Numbers Will Break Your WhatsApp Integration: E.164, libphonenumber and the Excel Problem
WAPGROWAI for WAPGROWAI

Posted on

Phone Numbers Will Break Your WhatsApp Integration: E.164, libphonenumber and the Excel Problem

Every WhatsApp Cloud API message is addressed to a phone number, and phone-number bugs don't throw errors. A number with the wrong country code goes to a stranger or to nobody, and the API still answers 200. These are the phone-number mistakes we made building WAPGROWAI, a WhatsApp Business platform, and the TypeScript we use now: one canonical format, libphonenumber with the right metadata, careful guessing rules, and what Excel does to a column of phone numbers.

All snippets use libphonenumber-js 1.13 and were run against the cases shown.

Start with Meta's own rule

Meta's phone number formats section says the to field accepts plus signs, hyphens, parentheses and spaces, and then:

If the plus sign is omitted, your business phone number's country calling code is prepended to the customer's phone number. This can result in undelivered or misdelivered messages.

Its own example, for a business with an Indian (+91) number:

Number in request Delivered to Outcome
+16315551234 +16315551234 Correct number
+1 (631) 555-1234 +16315551234 Correct number
(631) 555-1234 +916315551234 Potentially wrong number
1 (631) 555-1234 +9116315551234 Potentially wrong number

So the API is only as correct as the number you hand it. Send E.164 with the plus sign, every time, and do the hard work of getting there before the request.

Mistake 1: adding a "+" and hoping

Our first public API normalised numbers like this:

// naive.ts — the version that shipped first
import { parsePhoneNumberFromString } from "libphonenumber-js/max";

export const naiveNormalize = (raw: string) => {
  const withPlus = raw.startsWith("+") ? raw : `+${raw}`;
  return parsePhoneNumberFromString(withPlus)?.number;
};
Enter fullscreen mode Exit fullscreen mode

It looks harmless. A client integrating with us sent bare 10-digit Indian mobile numbers, and some of them came out Japanese: 8123456789 becomes +8123456789, and 81 is Japan's country code. Meta accepted the send, our dashboard showed it as sent, and nothing ever arrived. Numbers whose first digits didn't happen to form a country code went through fine, which made it look intermittent.

The parse even told us the number wasn't valid for Japan. We just never asked.

Parse with a region, validate, then guess carefully

A number without a + needs a region to mean anything. We now try two readings and keep one only if it's valid:

// phone.ts
import { parsePhoneNumberFromString, type CountryCode, type PhoneNumber } from "libphonenumber-js/max";

export type PhoneResult =
  | { ok: true; e164: string; waId: string; country?: CountryCode }
  | { ok: false; reason: "invalid_phone" | "spreadsheet_truncated" };

// "9.1982E+11": Excel already threw the real digits away. Never try to expand it.
const SCIENTIFIC = /^[+-]?\d(?:\.\d+)?[eE][+-]?\d+$/;

const parseValid = (input: string, region?: CountryCode): PhoneNumber | undefined => {
  const parsed = parsePhoneNumberFromString(input, region);
  return parsed?.isValid() ? parsed : undefined;
};

// A guessed reading must at least be a number that could be on WhatsApp.
const mobileCapable = (parsed?: PhoneNumber) => {
  const type = parsed?.getType();
  return type === "MOBILE" || type === "FIXED_LINE_OR_MOBILE" ? parsed : undefined;
};

export const resolvePhone = (raw: string, region: CountryCode): PhoneResult => {
  const trimmed = raw.trim();
  if (SCIENTIFIC.test(trimmed)) return { ok: false, reason: "spreadsheet_truncated" };

  const input = trimmed.startsWith("00") ? `+${trimmed.slice(2)}` : trimmed;
  const parsed = input.startsWith("+")
    ? parseValid(input)
    : // No "+": try a local number first, then an international one that lost its "+".
      mobileCapable(parseValid(input, region)) ?? mobileCapable(parseValid(`+${input.replace(/\D/g, "")}`));

  if (!parsed) return { ok: false, reason: "invalid_phone" };
  return { ok: true, e164: parsed.number, waId: parsed.number.slice(1), country: parsed.country };
};
Enter fullscreen mode Exit fullscreen mode

What each reading catches, with India as the default region:

Input Result Why
8123456789 +918123456789 Local reading, valid Indian mobile
09820098765 +919820098765 Trunk prefix 0 dropped by the parser
919820098765 +919820098765 Already carries the country code
971501234567 +971501234567 UAE number that lost its + in a spreadsheet
0044 7400 123456 +447400123456 00 international prefix
+1 (201) 555-0123 +12015550123 Explicit +, formatting stripped
(201) 555-0123 invalid_phone Not valid in India; the fallback reading is an Egyptian landline
12345 invalid_phone Not a number anywhere

Three rules are doing the work:

  • An explicit + is trusted as typed. libphonenumber ignores the default region for international input, which is what you want.
  • Guesses must be valid. isValid() on the full metadata rejects most accidental readings, including our Japanese ones.
  • Guesses must be mobile-capable. The fallback reading (+ plus the digits) is valid more often than you'd expect: +2015550123 is a valid Egyptian landline number. Customers on WhatsApp are almost always on mobiles, so a guess that lands on a landline is more likely wrong than right. A number typed with its + can still be a landline.

WAPGROWAI's importer and public API use the two readings. The mobile-only rule for guesses is a refinement we'd recommend to anyone building this fresh.

No parser can read a bare foreign number

Here's the limit of all of the above. A New York number typed without its +1 is (917) 555-0123, and under an Indian default region that's a perfectly valid Indian mobile:

resolvePhone("(917) 555-0123", "IN"); // { ok: true, e164: "+919175550123", country: "IN" }
resolvePhone("(917) 555-0123", "US"); // { ok: true, e164: "+19175550123", country: "US" }
Enter fullscreen mode Exit fullscreen mode

Valid is not the same as correct. The region is an assumption, so make it a deliberate one:

  • Forms: a country picker next to the number field, defaulting to the user's country, and store what they chose.
  • Imports: ask for a default country once per file, and tell the user it applies only to numbers without a country code.
  • APIs: document that numbers must include the country code, and reject what doesn't parse rather than "fixing" it. Our public API now returns invalid_phone with an example of the expected format.

Use the full metadata, and keep it fresh

libphonenumber-js ships three metadata sets. The default import uses min, and the README is clear about what that means: validation checks only the length, "effectively causing isValid() to behave same way as if it was isPossible()."

import { parsePhoneNumberFromString as minParse } from "libphonenumber-js";
import { parsePhoneNumberFromString as maxParse } from "libphonenumber-js/max";

minParse("+621234567")?.isValid(); // true  — right length for Indonesia
maxParse("+621234567")?.isValid(); // false — no operator issues that number
Enter fullscreen mode Exit fullscreen mode

On our own test set, min accepted 4 of 15 junk numbers and max accepted none, while both accepted all 29 real ones. The cost was about 20 KB gzipped in our frontend bundle. Import from /max on both the client and the server, so a number the form accepts is a number the API accepts.

The library's author adds a fair warning: by using isValid(), "you volunteer to keep it updated for the rest of its lifespan." Numbering plans change, and new releases carry Google's metadata updates. Let Dependabot or Renovate bump it, or one day a valid new number range gets rejected.

Don't cap input length with an example number

To stop users typing too many digits, we capped the field at the length of the country's example number. That's one sample mobile, and many countries have variable-length plans. Indonesia's example has 9 digits; real Indonesian numbers are longer. Our signup form was silently deleting the last digits of valid numbers as people typed.

The cap now comes from the numbering plan's possible lengths, clamped to E.164's 15-digit maximum:

// length-cap.ts
import { Metadata, getCountryCallingCode, type CountryCode } from "libphonenumber-js/core";
import metadata from "libphonenumber-js/metadata.max.json";

const E164_MAX_DIGITS = 15;

// Widest national number the plan allows, across every line type.
export const maxNationalDigits = (country: CountryCode): number => {
  const plan = new Metadata(metadata);
  plan.selectNumberingPlan(country);
  const lengths = plan.numberingPlan?.possibleLengths() ?? [];
  const budget = E164_MAX_DIGITS - getCountryCallingCode(country, metadata).length;
  return lengths.length ? Math.min(Math.max(...lengths), budget) : budget;
};

maxNationalDigits("ID"); // 13 (the example number has 9)
maxNationalDigits("GB"); // 10
Enter fullscreen mode Exit fullscreen mode

A length cap is an overflow guard, not a validator. It must never be tighter than what your validator accepts, because a field that eats digits gives the user no error to explain it. Validation is isValid()'s job, and that one shows a message.

Store one format, compare digits

Meta identifies users by wa_id, which in the responses and webhooks we see is digits without the +. Your database might hold +91 and 98200 98765 in two columns. Every comparison between the two has to normalise both sides.

We learned this from a segment filter: "phone starts with 91" matched nobody, because the stored value started with +. "Ends with" worked fine, which hid the bug for a while.

// match.ts
// Stored as "+91" / "98200 98765"; Meta's wa_id is "919820098765". Compare digits, always.
export const digitsOnly = (value: string) => value.replace(/\D/g, "");

export const matchKey = (countryCode: string, nationalNumber: string) =>
  digitsOnly(`${countryCode}${nationalNumber}`);

// MySQL < 8 has no REGEXP_REPLACE, so strip the usual punctuation with nested REPLACE.
const PUNCTUATION = ["+", " ", "-", "(", ")", "."];
export const sqlDigits = (expr: string) =>
  PUNCTUATION.reduce((acc, ch) => `REPLACE(${acc}, '${ch}', '')`, expr);
Enter fullscreen mode Exit fullscreen mode

If you're starting fresh, skip the clean-up entirely: store the E.164 string in one indexed column, and keep display formatting out of the database. The same canonical value is your de-duplication key on import, so 098200 98765 and +91 98200 98765 collapse into one contact instead of two.

The number Meta reports may not be the one you sent

Two things can make the identifier in a webhook differ from your stored number.

Brazil and Mexico. Meta's phone-number docs say: "For Brazil and Mexico, the extra added prefix of the phone number may be modified by the Cloud API." The send response returns both your input and the wa_id, and Meta's reference notes that one may not match the other. Store the wa_id when it's present, and match incoming messages against it as well as against the number you have on file.

Usernames and business-scoped user IDs. WhatsApp is rolling out usernames during 2026, along with a business-scoped user ID (BSUID) for every user. For someone who uses a username, Meta says "their phone number will not be included in webhooks, unless you have interacted with the user before", and that wa_id "will be omitted if the phone number cannot be included". If your contact table is keyed only on phone number, those messages have nowhere to go. Store the user ID alongside the number, and don't assume a phone number or wa_id is always present.

The Excel problem

Sooner or later a customer uploads a spreadsheet of contacts. In Excel's General format, a 12-digit number like 919820098765 is shown as 9.1982E+11. Microsoft's own documentation notes that General "does use exponential notation for large numbers (12 or more digits)".

What happens next depends on the file type:

  • The .xlsx is fine. The cell still holds the exact number; only the display is abbreviated. Read it with a real xlsx parser and String(cell) gives you all 12 digits.
  • A CSV saved from that sheet is not. In the CSVs our users uploaded, the phone column literally contained the text 9.1982E+11: five significant digits. Every number from 919815000000 to 919824999999 collapses to the same string. Nothing can recover them.
  • Re-saving that CSV as .xlsx makes it worse. Excel turns 9.1982E+11 back into 919820000000, which is a valid Indian number belonging to someone else. It passes every check, and your scientific-notation detector can no longer see it.

So reject scientific-notation values instead of trying to expand them (that's the SCIENTIFIC check in resolvePhone), and report it as a problem with the file, not with the row: "This file was saved from Excel and the phone numbers were shortened. Upload the original .xlsx." Our importer flags the whole column and asks for the original spreadsheet.

WAPGROWAI contact import: a warning that the phone column reached us as 9.1982E+11 and asking for the original .xlsx, and a default country picker set to India
The importer's warning when a CSV's phone column arrives in scientific notation, above the default-country choice for numbers without a country code.

Excel also drops leading 0s and + signs from numbers. Those are recoverable: the parser handles a missing trunk prefix (7400123456 with region GB is +447400123456), and the second reading in resolvePhone puts the + back.

Checklist

  • Send E.164 with the +. Without it, Meta prepends your business number's country code.
  • Never add a + to a bare number and trust the result. Parse with a region, and keep only valid readings.
  • Only accept guessed readings that could be mobiles.
  • Treat the default region as an explicit choice the user makes, not a constant buried in code.
  • Import libphonenumber-js/max on both client and server, and keep the package current.
  • Cap input length from the numbering plan's possible lengths, never from an example number.
  • Store one canonical format; compare digits on both sides; de-duplicate on the canonical value.
  • Store wa_id and, as usernames roll out, the user ID.
  • Reject 9.1982E+11; ask for the original .xlsx.

Phone numbers look like strings. They're closer to addresses in a system you don't control, and every shortcut here sends a message to the wrong door.


I work on WAPGROWAI, a WhatsApp Business platform with a team inbox, campaigns and contact imports built on the patterns above. Other posts in this series: webhooks, the 24-hour window, templates and campaigns and coexistence.

Top comments (0)