From b2716e15e16bf76d5995836c882e2d7db60652d8 Mon Sep 17 00:00:00 2001 From: Petterson <58094725+hahuma@users.noreply.github.com> Date: Mon, 27 Jul 2026 06:06:33 -0300 Subject: [PATCH] fix: deregister WhatsApp Cloud number on inbox delete (#14940) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description When a WhatsApp Cloud inbox is deleted, `Whatsapp::WebhookTeardownService` clears the phone-level webhook override and unsubscribes the app from the WABA (when it's the last inbox), but it **never deregisters the phone number**. Because the number stays registered to the app, Meta reports that the number is **"already registered to a partner app"** when the user later tries to re-add it under a different app/BSP — leaving the number effectively stuck. This adds a deregister step so the number is released on deletion: - New `Whatsapp::FacebookApiClient#deregister_phone_number` → `POST /{phone_number_id}/deregister`. - `WebhookTeardownService` calls it during teardown (alongside the existing override-clear and app-unsubscribe), guarded on `phone_number_id` and wrapped so a failure is logged and never blocks the channel delete. Docs: https://developers.facebook.com/docs/whatsapp/cloud-api/reference/registration (deregister) ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How has this been tested? Unit specs for both the new API client method and the teardown service (Meta API stubbed with WebMock), mirroring the existing `register_phone_number` / teardown coverage. ## Checklist - [x] My code follows the style guidelines of this project - [x] I have added tests that prove my fix is effective - [x] New and existing unit tests pass locally with my changes --------- Co-authored-by: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Co-authored-by: Tanmay Deep Sharma --- app/services/whatsapp/facebook_api_client.rb | 11 ++++++++ .../whatsapp/webhook_teardown_service.rb | 15 ++++++++++ .../whatsapp/facebook_api_client_spec.rb | 28 +++++++++++++++++++ .../whatsapp/webhook_teardown_service_spec.rb | 13 +++++++++ 4 files changed, 67 insertions(+) diff --git a/app/services/whatsapp/facebook_api_client.rb b/app/services/whatsapp/facebook_api_client.rb index 7e74e8ac6..136b7fcdd 100644 --- a/app/services/whatsapp/facebook_api_client.rb +++ b/app/services/whatsapp/facebook_api_client.rb @@ -52,6 +52,17 @@ class Whatsapp::FacebookApiClient handle_response(response, 'Phone registration failed') end + # Releases the number from this app so it can be re-added under another app/BSP. Without this, + # after an inbox is deleted the number stays registered and Meta reports "already in a partner app". + def deregister_phone_number(phone_number_id) + response = HTTParty.post( + "#{BASE_URI}/#{@api_version}/#{phone_number_id}/deregister", + headers: request_headers + ) + + handle_response(response, 'Phone deregistration failed') + end + def phone_number_verified?(phone_number_id) response = HTTParty.get( "#{BASE_URI}/#{@api_version}/#{phone_number_id}", diff --git a/app/services/whatsapp/webhook_teardown_service.rb b/app/services/whatsapp/webhook_teardown_service.rb index de794f8e3..f22d49eb8 100644 --- a/app/services/whatsapp/webhook_teardown_service.rb +++ b/app/services/whatsapp/webhook_teardown_service.rb @@ -9,6 +9,7 @@ class Whatsapp::WebhookTeardownService api_client = Whatsapp::FacebookApiClient.new(provider_config['api_key']) clear_phone_number_override(api_client) + deregister_phone_number(api_client) unsubscribe_app_if_last_inbox(api_client) rescue StandardError => e # before_destroy must never block a channel delete — log and move on. @@ -37,6 +38,20 @@ class Whatsapp::WebhookTeardownService Rails.logger.error "[WHATSAPP] Phone-level webhook clear failed for channel #{@channel.id}: #{e.message}" end + # Embedded signup only — deregistering a manually connected number disables it on the customer's own app. + # Releases the number from our app so the customer can re-add it elsewhere. + def deregister_phone_number(api_client) + return unless provider_config['source'] == 'embedded_signup' + + phone_number_id = provider_config['phone_number_id'] + return if phone_number_id.blank? + + api_client.deregister_phone_number(phone_number_id) + Rails.logger.info "[WHATSAPP] Phone number deregistered for channel #{@channel.id}" + rescue StandardError => e + Rails.logger.error "[WHATSAPP] Phone deregistration failed for channel #{@channel.id}: #{e.message}" + end + # 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) diff --git a/spec/services/whatsapp/facebook_api_client_spec.rb b/spec/services/whatsapp/facebook_api_client_spec.rb index 5dda2aaeb..deb47375d 100644 --- a/spec/services/whatsapp/facebook_api_client_spec.rb +++ b/spec/services/whatsapp/facebook_api_client_spec.rb @@ -154,6 +154,34 @@ describe Whatsapp::FacebookApiClient do end end + describe '#deregister_phone_number' do + let(:phone_number_id) { 'test_phone_id' } + + context 'when successful' do + before do + stub_request(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}/deregister") + .with(headers: { 'Authorization' => "Bearer #{access_token}", 'Content-Type' => 'application/json' }) + .to_return(status: 200, body: { success: true }.to_json, headers: { 'Content-Type' => 'application/json' }) + end + + it 'returns success response' do + result = api_client.deregister_phone_number(phone_number_id) + expect(result['success']).to be(true) + end + end + + context 'when failed' do + before do + stub_request(:post, "https://graph.facebook.com/#{api_version}/#{phone_number_id}/deregister") + .to_return(status: 400, body: { error: 'Deregistration failed' }.to_json) + end + + it 'raises an error' do + expect { api_client.deregister_phone_number(phone_number_id) }.to raise_error(/Phone deregistration failed/) + end + end + end + describe '#subscribe_phone_number_webhook' do let(:waba_id) { 'test_waba_id' } let(:phone_number_id) { 'test_phone_id' } diff --git a/spec/services/whatsapp/webhook_teardown_service_spec.rb b/spec/services/whatsapp/webhook_teardown_service_spec.rb index a5bdeef0b..bf2bd335d 100644 --- a/spec/services/whatsapp/webhook_teardown_service_spec.rb +++ b/spec/services/whatsapp/webhook_teardown_service_spec.rb @@ -24,16 +24,29 @@ RSpec.describe Whatsapp::WebhookTeardownService do api_client = instance_double(Whatsapp::FacebookApiClient) allow(Whatsapp::FacebookApiClient).to receive(:new).with('test_api_key').and_return(api_client) allow(api_client).to receive(:clear_phone_number_callback_override).with('test_phone_id') + allow(api_client).to receive(:deregister_phone_number).with('test_phone_id') service.perform expect(api_client).to have_received(:clear_phone_number_callback_override).with('test_phone_id') end + it 'deregisters the phone number so it is freed from the app' do + api_client = instance_double(Whatsapp::FacebookApiClient) + allow(Whatsapp::FacebookApiClient).to receive(:new).with('test_api_key').and_return(api_client) + allow(api_client).to receive(:clear_phone_number_callback_override) + allow(api_client).to receive(:deregister_phone_number).with('test_phone_id') + + service.perform + + expect(api_client).to have_received(:deregister_phone_number).with('test_phone_id') + end + it 'handles errors gracefully without raising' 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).and_raise(StandardError, 'API Error') + allow(api_client).to receive(:deregister_phone_number) expect { service.perform }.not_to raise_error end