From 9328f8739ce420bb031550f87d83e5f4bc32b61d Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:05:27 +0530 Subject: [PATCH] fix: clear whatsapp webhook override when manual cloud inbox is deleted (#15010) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../whatsapp/webhook_teardown_service.rb | 6 ++-- .../whatsapp/webhook_teardown_service_spec.rb | 31 ++++++++++++++++--- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/app/services/whatsapp/webhook_teardown_service.rb b/app/services/whatsapp/webhook_teardown_service.rb index 948d84f04..de794f8e3 100644 --- a/app/services/whatsapp/webhook_teardown_service.rb +++ b/app/services/whatsapp/webhook_teardown_service.rb @@ -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) diff --git a/spec/services/whatsapp/webhook_teardown_service_spec.rb b/spec/services/whatsapp/webhook_teardown_service_spec.rb index be94f3c44..a5bdeef0b 100644 --- a/spec/services/whatsapp/webhook_teardown_service_spec.rb +++ b/spec/services/whatsapp/webhook_teardown_service_spec.rb @@ -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