fix(whatsapp): send messages to business scoped user ids (BSUID) (#15150)

## Description

WhatsApp's migration to usernames / **Business-Scoped User IDs (BSUID)**
means a contact can become addressable only by a BSUID (format
`CC.<id>`, 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: <bsuid> }`
- a **phone number** → `{ to: <phone> }` (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 <muhsinkeramam@gmail.com>
This commit is contained in:
Marco Cabral
2026-07-27 06:35:22 -03:00
committed by GitHub
parent b2716e15e1
commit a3a961919e
3 changed files with 72 additions and 4 deletions

View File

@@ -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|

View File

@@ -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

View File

@@ -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