fix: clear whatsapp webhook override when manual cloud inbox is deleted (#15010)

Deleting a manually-configured WhatsApp Cloud inbox left its
phone-number-level webhook override still pointing at Chatwoot on Meta's
side. The number kept routing inbound events to us after the inbox was
gone, which blocked the customer's own app — subscribed separately on
the same WABA — from receiving messages, since the phone-level override
takes priority over the app-level subscription. Deleting the inbox now
releases the override, as it already did for embedded-signup inboxes.

## What changed

The setup and teardown paths gated on opposite halves of the same
condition. `Channel::Whatsapp#should_auto_setup_webhooks?` sets the
override for `whatsapp_cloud` inboxes where `source !=
'embedded_signup'` (i.e. manual ones), while
`Whatsapp::WebhookTeardownService#should_teardown_webhook?` only cleared
it when `source == 'embedded_signup'`. The two sets are disjoint, so
manual inboxes were exactly the ones that set an override on create and
never cleared it on destroy. Embedded-signup inboxes were unaffected
because `EmbeddedSignupService` calls `setup_webhooks` explicitly.

Dropping the `source` check from the teardown guard is the whole fix.
Manual `whatsapp_cloud` channels can't persist without `api_key`,
`phone_number_id` and `business_account_id` (`validate_provider_config`
verifies all three against Meta), so the remaining presence guards and
both API calls have everything they need. The WABA-level `DELETE
/subscribed_apps` now also fires for manual inboxes when the last one on
a WABA is removed, which is symmetric with manual setup subscribing the
app in the first place; the token only unsubscribes the app it belongs
to, so a customer's separate app subscription is untouched.

This fixes the leak going forward. Numbers already stranded still need
the override cleared with the customer's own token, since we no longer
hold their `api_key` once the inbox is deleted.

## How to reproduce

1. Create a WhatsApp Cloud inbox using manual API keys (not embedded
signup).
2. Confirm the override is set: `GET
/v22.0/{phone_number_id}?fields=webhook_configuration` shows
`phone_number` pointing at your Chatwoot install.
3. Delete the inbox.
4. Before this change, the override still points at Chatwoot. After it,
`webhook_configuration` no longer carries the phone-level override and
events fall back to the WABA/app-level subscription.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
Tanmay Deep Sharma
2026-07-14 15:05:27 +05:30
committed by GitHub
co-authored by Muhsin Keloth
parent 3e03f8da1e
commit 9328f8739c
2 changed files with 31 additions and 6 deletions
@@ -23,7 +23,6 @@ class Whatsapp::WebhookTeardownService
def should_teardown_webhook?
@channel.provider == 'whatsapp_cloud' &&
provider_config['source'] == 'embedded_signup' &&
provider_config['api_key'].present? &&
(provider_config['phone_number_id'].present? || provider_config['business_account_id'].present?)
end
@@ -38,8 +37,11 @@ class Whatsapp::WebhookTeardownService
Rails.logger.error "[WHATSAPP] Phone-level webhook clear failed for channel #{@channel.id}: #{e.message}"
end
# The app subscription is shared by every inbox on the WABA, so only unsubscribe when this is the last one.
# Embedded signup only — a manual token's subscribed app is the customer's, not ours to unsubscribe.
# The subscription is shared across the WABA, so only unsubscribe when this is the last inbox.
def unsubscribe_app_if_last_inbox(api_client)
return unless provider_config['source'] == 'embedded_signup'
waba_id = provider_config['business_account_id']
return if waba_id.blank?
return if waba_sibling_exists?(waba_id)
@@ -51,18 +51,41 @@ RSpec.describe Whatsapp::WebhookTeardownService do
end
end
context 'when channel is whatsapp_cloud but not embedded_signup' do
context 'when channel is whatsapp_cloud with manual setup' do
before do
allow(channel).to receive(:setup_webhooks).and_return(true)
channel.update!(
provider: 'whatsapp_cloud',
provider_config: { 'source' => 'manual' }
provider_config: {
'source' => 'manual',
'phone_number_id' => 'manual_phone_id',
'business_account_id' => 'manual_waba_id',
'api_key' => 'manual_api_key'
}
)
end
it 'does not attempt to unsubscribe webhook' do
expect(Whatsapp::FacebookApiClient).not_to receive(:new)
it 'clears the phone number callback override' do
api_client = instance_double(Whatsapp::FacebookApiClient)
allow(Whatsapp::FacebookApiClient).to receive(:new).with('manual_api_key').and_return(api_client)
allow(api_client).to receive(:clear_phone_number_callback_override).with('manual_phone_id')
service.perform
expect(api_client).to have_received(:clear_phone_number_callback_override).with('manual_phone_id')
end
# The manual token belongs to the customer's own Meta app, so its WABA subscription is not ours to remove.
it 'does not unsubscribe the app from the WABA' do
api_client = instance_double(Whatsapp::FacebookApiClient)
allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client)
allow(api_client).to receive(:clear_phone_number_callback_override)
allow(api_client).to receive(:unsubscribe_app_from_waba)
service.perform
expect(api_client).not_to have_received(:unsubscribe_app_from_waba)
end
end