Skip to content

[core] Resolve Brazilian 9th-digit ambiguity on send (opt-in) - #2180

Open
bergpinheiro wants to merge 1 commit into
devlikeapro:devfrom
bergpinheiro:feat/br-phone-resolution
Open

[core] Resolve Brazilian 9th-digit ambiguity on send (opt-in)#2180
bergpinheiro wants to merge 1 commit into
devlikeapro:devfrom
bergpinheiro:feat/br-phone-resolution

Conversation

@bergpinheiro

@bergpinheiro bergpinheiro commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Problem

Brazilian mobile numbers went through a 9th-digit migration. For DDD 11–30
every mobile carries the extra 9, so a static rule is enough. For DDD 31–99
the form is ambiguous
: some accounts are registered with the 9, some
without, and the number alone does not tell which.

Integrations send whatever their CRM holds — usually the carrier form, with
the 9 — and the message fails or goes nowhere depending on how the account is
actually registered.

What this adds

resolveOutboundChatId() on WhatsappSession, applied to the send paths of
all four engines. It resolves the number to the chat id the account is
actually registered under, tiered cheapest-first:

  1. in-memory cache (24h), keyed by both forms of the number
  2. static 9th-digit rule for DDD < 31 — no network
  3. local contact / LID map — no network
  4. WhatsApp lookup (usync), single-flight so concurrent sends to the same new
    number share one query

Read and presence operations resolve locally only ({ validate: false }):
they never reach the network and never throw.

A number confirmed not to exist is soft by default — warn and send the best
guess — so a usync false negative never blocks a valid send.
WAHA_BR_PHONE_STRICT=true rejects it with 422 instead.

Toll-free (0800) numbers

Brazilian toll-free numbers are non-geographic: dialed as 0800 + 7
subscriber digits, but stored on WhatsApp under country code 55 with the
leading 0 dropped — 08000464636 is stored as 558000464636. A caller
sending the dialed form (08000464636 or 5508000464636) hits a send that
cannot resolve it to a LID and fails.

This rewrites the dialed form to the stored one with a deterministic rule —
no lookup, since unlike the 9th digit there is no ambiguity. The already-stored
form routes through untouched. Only 0800 is handled (0300/0500/0900 share
the shape but are not covered).

Opt-in

Off unless WAHA_BR_PHONE_NORMALIZE=true. Non-Brazilian numbers exit on the
first check (isBrazilCountryCode): no cache, no lookup, no cost.

Variable Default Effect
WAHA_BR_PHONE_NORMALIZE false enables the resolution
WAHA_BR_PHONE_STRICT false 422 on a confirmed-nonexistent number instead of best-guess

Relationship to PhoneJidNormalizer

PhoneJidNormalizer converts a JID into an E.164 string for the Chatwoot
contact record — one direction, static rule. This resolves the opposite
direction: an inbound phone number into the chat id to address, with a server
lookup for the range where a static rule cannot decide.

They do not overlap, and its rule stays correct for DDD 11–30 — that is
exactly what tier 2 does here.

checkNumberStatus answering with a LID

GOWS checkNumberStatus returned whatever jid the usync answer carried.
Since whatsmeow queries contacts with addressing_mode=lid, that can be a
LID — a contract break for an endpoint documented as returning the "Chat id
for the phone number", and it also breaks tier 4 of this feature.

Fixed by recovering the phone number from the LID map (findPNByLid), with a
fallback to the LID when no mapping exists. WANumberExistResult gains an
optional lid so no identifier is lost. Happy to split this out if you would
rather handle it separately.

Also

isBrazilMobile now accepts any digit after the leading 9 on a 9-digit local.
Brazil has no 9-digit landline, so 9 + 8 digits is unambiguously mobile;
requiring 6–9 right after the 9 rejected real numbers.

Validation

Against a live GOWS session:

  • 5585991203123 (extra 9) and 558591203123 (correct form) resolve to the
    same chat id, with a single usync shared between them through the cache
  • a toll-free 0800 sent in either dialed form addresses the stored number
  • a mobile outside the lookup range and two landlines pass through untouched
  • a non-Brazilian number triggers no Brazilian logic at all

Unit tests in brPhone.test.ts and session.abc.brPhone.test.ts.

Please excuse my explanation of our complex Brazilian numbering system;

patron:PRO

@bergpinheiro

bergpinheiro commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

@devlikepro If you have time to review this PR before the next version, and if it makes sense for Waha, everyone here was asking me for this.

patron:PRO

@nestordavalos

nestordavalos commented Jul 23, 2026

Copy link
Copy Markdown

I love you

@gabrielgranado

Copy link
Copy Markdown

nice job!

@devlikepro

devlikepro commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

👁️

patron:PRO

@bergpinheiro

bergpinheiro commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Pushed a follow-up commit fixing a regression this PR itself introduced.

The PR routed rejectCall through the new resolveOutboundChatId, but from there is the JID that placed the call, not an outbound destination. Normalizing it retargets the reject at a different number, and WhatsApp drops the stanza silently — the RPC returns OK and the local event says "rejected", but the caller's phone keeps ringing.

validate: false does not avoid it: that only skips the network lookup and the throws, while the toll-free rewrite, the cache and the static DDD rule still run.

It affected GOWS, NOWEB and WEBJS (WPP has no rejectCall). The commit restores the pre-existing ensureSuffix behaviour in all three and adds a short comment so the same mistake isn't repeated. tsc --noEmit clean.

I hit this in production before opening the PR and had fixed it only in GOWS on my side, which is why it slipped through here — sorry about that.

patron:PRO

@OrionZap

OrionZap commented Aug 6, 2026

Copy link
Copy Markdown

+1

patron:PLUS

Brazilian mobile numbers may or may not carry the 9th digit, and only one
of the two forms is on WhatsApp. Sending to the wrong one fails silently.

Behind WAHA_BR_PHONE_NORMALIZE (off by default), outbound chat ids for
Brazilian mobiles are resolved to the form the account actually uses,
querying the engine once and caching the answer per session.

Reject is deliberately left alone: the caller's own JID is not an outbound
destination, and normalizing it would retarget the reject at a different
number, which WhatsApp drops silently while the caller keeps ringing.
@bergpinheiro
bergpinheiro force-pushed the feat/br-phone-resolution branch from 6e15336 to 4a00aa8 Compare August 17, 2026 16:16
@devlikepro

devlikepro commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Sorry, need a little more time to think about the proposal, but we'll definitely merge them!
After all, most of our audience uses Brazilian phone numbers these days :D

patron:PRO

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants