From a3a961919e4415d7661c68f6da197e58aff991cb Mon Sep 17 00:00:00 2001 From: Marco Cabral Date: Mon, 27 Jul 2026 06:35:22 -0300 Subject: [PATCH] fix(whatsapp): send messages to business scoped user ids (BSUID) (#15150) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description WhatsApp's migration to usernames / **Business-Scoped User IDs (BSUID)** means a contact can become addressable only by a BSUID (format `CC.`, e.g. `BR.1393...`) when no phone number is exposed. Per Meta's Cloud API, a BSUID recipient must be sent in the **`recipient`** field (with `recipient_type: "individual"`), **not** in `to`. Today `Whatsapp::Providers::WhatsappCloudService` always places the recipient in `to`. When `to` carries a BSUID, the Graph API returns **HTTP 200 with a message id** but **silently drops the message**: it strips the country prefix and treats the remainder as a phone number (`wa_id`), which never resolves, so nothing is delivered and **no error is surfaced**. In the username-only era this means agents reply into the void — Chatwoot marks the message as sent while the customer receives nothing. This PR adds a small helper, `recipient_params`, that routes the outgoing identifier to the correct field: - a **BSUID** → `{ recipient_type: "individual", recipient: }` - a **phone number** → `{ to: }` (unchanged behaviour) It is applied to all four Cloud send paths: text, attachment, template and interactive. BSUID detection reuses the existing `RegexHelper::WHATSAPP_BSUID_REGEX`. No Graph API version bump is required (see testing below). Related to #13837 (this covers the **outbound sending** part). ### References - Meta — *Business-scoped user IDs*: https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids/ — "set `recipient` to the user's BSUID or parent BSUID"; when both `to` and `recipient` are present, `to` takes precedence. - BSUIDs began appearing in webhooks in April 2026; sending **to** a BSUID was enabled by Meta in July 2026. ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] This change requires a documentation update ## How Has This Been Tested? Verified **live** against a real, Meta-registered WhatsApp Cloud number, sending to a real BSUID captured from an inbound webhook, on **both Graph API v13.0 (the current default in this service) and v22.0**: | # | API version | recipient field | Meta response | delivered? | |---|-------------|-----------------|---------------|:----------:| | 1 | v22.0 | `recipient` = BSUID | `200` · `contacts[].user_id` echoed | ✅ | | 2 | v22.0 | `to` = BSUID | `200` · `contacts[].wa_id` (prefix stripped) | ❌ | | 3 | v13.0 | `recipient` = BSUID | `200` · `contacts[].user_id` echoed | ✅ | - When the API echoes `user_id`, the BSUID is accepted and the message **is delivered**; when it echoes `wa_id` (prefix stripped), it is **not** — confirmed on the receiving handset. - **v13.0 already accepts `recipient`**, so no Graph API version bump is needed. - Added unit specs asserting the request body uses `recipient` + `recipient_type` for a BSUID and `to` for a phone number, across the send paths. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my own code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Muhsin Keloth --- .../whatsapp/providers/base_service.rb | 14 +++++ .../providers/whatsapp_cloud_service.rb | 9 ++-- .../providers/whatsapp_cloud_service_spec.rb | 53 +++++++++++++++++++ 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/app/services/whatsapp/providers/base_service.rb b/app/services/whatsapp/providers/base_service.rb index 9fd1f6267..4f8a6e489 100644 --- a/app/services/whatsapp/providers/base_service.rb +++ b/app/services/whatsapp/providers/base_service.rb @@ -54,6 +54,20 @@ class Whatsapp::Providers::BaseService message.save! end + # WhatsApp coexistence / username migration: a contact may become addressable only by a Business-Scoped + # User ID (BSUID, e.g. "BR.123..."), with no phone number available. The Cloud API requires a BSUID to be + # passed in the `recipient` field (with recipient_type: individual), NOT in `to`. Passing a BSUID in `to` + # returns HTTP 200 with a message id but the message is silently dropped: the "CC." prefix is stripped and + # the remainder is treated as a phone number (wa_id), which never resolves. Phone numbers keep using `to`. + # See: https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids/ + def recipient_params(identifier) + if identifier.to_s.match?(RegexHelper::WHATSAPP_BSUID_REGEX) + { recipient_type: 'individual', recipient: identifier } + else + { to: identifier } + end + end + def create_buttons(items) buttons = [] items.each do |item| diff --git a/app/services/whatsapp/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb index d65c6cc62..09270a588 100644 --- a/app/services/whatsapp/providers/whatsapp_cloud_service.rb +++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb @@ -17,7 +17,8 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi request_body = { messaging_product: 'whatsapp', recipient_type: 'individual', # Only individual messages supported (not group messages) - to: phone_number, + # BSUID -> `recipient`; phone number -> `to` (see recipient_params in the base provider). + **recipient_params(phone_number), type: 'template', template: template_body } @@ -128,7 +129,7 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi body: { messaging_product: 'whatsapp', context: whatsapp_reply_context(message), - to: phone_number, + **recipient_params(phone_number), text: { body: message.outgoing_content }, type: 'text' }.to_json @@ -148,7 +149,7 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi body: { :messaging_product => 'whatsapp', :context => whatsapp_reply_context(message), - 'to' => phone_number, + **recipient_params(phone_number), 'type' => type, type.to_s => type_content }.to_json @@ -243,7 +244,7 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi headers: api_headers, body: { messaging_product: 'whatsapp', - to: phone_number, + **recipient_params(phone_number), interactive: payload, type: 'interactive' }.to_json diff --git a/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb b/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb index a94ba2e44..c409c9a69 100644 --- a/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb +++ b/spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb @@ -227,6 +227,59 @@ describe Whatsapp::Providers::WhatsappCloudService do end end + describe 'when the recipient is a Business-Scoped User ID (BSUID)' do + # Meta requires a BSUID to be sent in the `recipient` field (with recipient_type: individual), not `to`. + let(:bsuid) { 'BR.13491208655302741918' } + + it 'sends a text message via the recipient field instead of to' do + stub_request(:post, 'https://graph.facebook.com/v13.0/123456789/messages') + .with( + body: { + messaging_product: 'whatsapp', + context: nil, + recipient_type: 'individual', + recipient: bsuid, + text: { body: message.content }, + type: 'text' + }.to_json + ) + .to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers) + + expect(service.send_message(bsuid, message)).to eq 'message_id' + end + + it 'sends a template via the recipient field instead of to' do + template_info = { name: 'test_template', namespace: 'test_namespace', lang_code: 'en_US', parameters: [] } + stub_request(:post, 'https://graph.facebook.com/v13.0/123456789/messages') + .with(body: hash_including({ messaging_product: 'whatsapp', recipient_type: 'individual', recipient: bsuid, type: 'template' })) + .to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers) + + expect(service.send_template(bsuid, template_info, message)).to eq 'message_id' + end + + it 'sends an interactive message via the recipient field instead of to' do + interactive_message = create(:message, message_type: :outgoing, content: 'test', inbox: whatsapp_channel.inbox, + content_type: 'input_select', + content_attributes: { items: [{ title: 'Burito', value: 'Burito' }] }) + stub_request(:post, 'https://graph.facebook.com/v13.0/123456789/messages') + .with(body: hash_including({ messaging_product: 'whatsapp', recipient_type: 'individual', recipient: bsuid, type: 'interactive' })) + .to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers) + + expect(service.send_message(bsuid, interactive_message)).to eq 'message_id' + end + + it 'sends an attachment via the recipient field instead of to' do + attachment = message.attachments.new(account_id: message.account_id, file_type: :image) + attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png') + + stub_request(:post, 'https://graph.facebook.com/v24.0/123456789/messages') + .with(body: hash_including({ messaging_product: 'whatsapp', recipient_type: 'individual', recipient: bsuid, type: 'image' })) + .to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers) + + expect(service.send_message(bsuid, message)).to eq 'message_id' + end + end + describe '#sync_templates' do context 'when called' do it 'updated the message templates' do