whatsappbackend
The Brazilian ninth digit: how WhatsApp silently breaks your phone matching
· 5 min read
If your product matches users by phone number and you have Brazilian users, there’s a bug waiting for you in production. We know because we shipped it.
Lima has a WhatsApp bot. You message it, it logs your expenses, completes your habits, answers questions. To know who’s talking, we match the sender’s phone number against the number on the user’s account. Simple lookup, worked in every test. Then real users connected, and for some of them the bot answered like a stranger: “I don’t recognize this number.” Same person, same phone, registered minutes earlier.
A short history lesson
In the early 2010s Brazil was running out of mobile numbers, so ANATEL (the telecom regulator) added a ninth digit, a leading 9, to every mobile number, rolled out region by region. A São Paulo mobile that used to be +55 11 8765-4321 became +55 11 9 8765-4321.
Every number issued since then has nine digits. Users type nine digits. Carriers bill nine digits. As far as anyone dialing a phone in Brazil is concerned, the eight-digit form no longer exists.
WhatsApp, however, identifies accounts by the number they registered with. An account created before the migration, and there are tens of millions of them, still carries the eight-digit identity internally. When that user messages your business number, the webhook’s wa_id arrives as 551187654321 (12 digits), while your database has +5511987654321 (13 digits). String comparison fails. Your bot has never met this person.
The cruel part is the failure distribution. It only affects users with old WhatsApp accounts, which skews toward exactly the loyal, less technical users you least want to greet with “who are you?”.
What we shipped
Two changes, one on read and one on write.
On read (the webhook), we never trust the incoming form. For any Brazilian wa_id, we derive both variants and look up either:
// 55 + DDD (2) + subscriber: 8 digits => also try with the 9; 9 digits => also try without
static List<String> candidates(String waId) {
if (!waId.startsWith("55") || waId.length() < 12) return List.of(waId);
String ddd = waId.substring(2, 4), rest = waId.substring(4);
if (rest.length() == 8) return List.of(waId, "55" + ddd + "9" + rest);
if (rest.length() == 9 && rest.startsWith("9")) return List.of(waId, "55" + ddd + rest.substring(1));
return List.of(waId);
}
On write (the database), we normalized every stored Brazilian mobile to the canonical nine-digit E.164 form with a migration, so there’s exactly one shape at rest and the candidate expansion only ever happens at the boundary. We also validate new numbers with libphonenumber at registration. It knows the ninth-digit rules per region, so malformed numbers never enter the system in the first place.
One subtlety: do the candidate expansion only for country code 55. Argentina, for example, has its own prefix quirks (a 9 after the country code for mobiles), and a “helpful” generic digit-insertion heuristic will happily corrupt other countries’ numbers.
Takeaways
- E.164 is a format, not an identity. Two E.164-valid strings can be the same phone. Normalize to one canonical form at write time, and treat inbound identifiers as fuzzy.
- WhatsApp’s
wa_idreflects registration history, not the current numbering plan. Test with an old account, not just your own. - Put the tolerance at the boundary, canonical form at rest. Matching both variants everywhere in the codebase is how you end up with four copies of the same bug.
If you’re building OTP or bot flows for Brazil, assume the eight-digit ghosts are out there. They are, and they’re your best users.
Phone matching was one boundary bug; HTTP caching was another. If you’re curious how we fixed ETag validation after a response envelope timestamp was silently busting every cache entry, that’s covered in Lima’s deep ETag fix.