feat: Add WhatsApp health monitoring and self-service registration completion (#12556)

Fixes
https://linear.app/chatwoot/issue/CW-5692/whatsapp-es-numbers-stuck-in-pending-due-to-premature-registration


###  Problem  
Multiple customers reported that their WhatsApp numbers remain stuck in
**Pending** in WhatsApp Manager even after successful onboarding.

- Our system triggers a **registration call**
(`/<PHONE_NUMBER_ID>/register`) as soon as the number is OTP verified.
- In many cases, Meta hasn’t finished **display name
review/provisioning**, so the call fails with:

  ```
  code: 100, error_subcode: 2388001
  error_user_title: "Cannot Create Certificate"
error_user_msg: "Your display name could not be processed. Please edit
your display name and try again."
  ```

- This leaves the number stuck in Pending, no messaging can start until
we manually retry registration.
- Some customers have reported being stuck in this state for **7+
days**.

###  Root cause  
- We only check `code_verification_status = VERIFIED` before attempting
registration.
- We **don’t wait** for display name provisioning (`name_status` /
`platform_type`) to be complete.
- As a result, registration fails prematurely and the number never
transitions out of Pending.

### Solution  

#### 1. Health Status Monitoring  
- Build a backend service to fetch **real-time health data** from Graph
API:
  - `code_verification_status`  
  - `name_status` / `display_name_status`  
  - `platform_type`  
  - `throughput.level`  
  - `messaging_limit_tier`  
  - `quality_rating`  
- Expose health data via API
(`/api/v1/accounts/:account_id/inboxes/:id/health`).
- Display this in the UI as an **Account Health tab** with clear badges
and direct links to WhatsApp Manager.

#### 2. Smarter Registration Logic  
- Update `WebhookSetupService` to include a **dual-condition check**:  
  - Register if:  
    1. Phone is **not verified**, OR  
2. Phone is **verified but provisioning incomplete** (`platform_type =
NOT_APPLICABLE`, `throughput.level = NOT_APPLICABLE`).
- Skip registration if number is already provisioned.  
- Retry registration automatically when stuck.  
- Provide a UI banner with complete registration button so customers can
retry without manual support.

### Screenshot
<img width="2292" height="1344" alt="CleanShot 2025-09-30 at 16 01
03@2x"
src="https://github.com/user-attachments/assets/1c417d2a-b11c-475e-b092-3c5671ee59a7"
/>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
Muhsin Keloth
2025-10-02 11:25:48 +05:30
committed by GitHub
parent 109aaa2341
commit 66cfef9298
15 changed files with 914 additions and 40 deletions

View File

@@ -48,6 +48,15 @@ describe Whatsapp::EmbeddedSignupService do
allow(channel_creation).to receive(:perform).and_return(channel)
allow(channel).to receive(:setup_webhooks)
allow(channel).to receive(:phone_number).and_return('+1234567890')
health_service = instance_double(Whatsapp::HealthService)
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
allow(health_service).to receive(:fetch_health_status).and_return({
platform_type: 'CLOUD_API',
throughput: { 'level' => 'STANDARD' },
messaging_limit_tier: 'TIER_1000'
})
end
it 'creates channel and sets up webhooks' do
@@ -57,6 +66,49 @@ describe Whatsapp::EmbeddedSignupService do
expect(result).to eq(channel)
end
it 'checks health status after channel creation' do
health_service = instance_double(Whatsapp::HealthService)
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
expect(health_service).to receive(:fetch_health_status)
service.perform
end
context 'when channel is in pending state' do
it 'prompts reauthorization for pending channel' do
health_service = instance_double(Whatsapp::HealthService)
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
allow(health_service).to receive(:fetch_health_status).and_return({
platform_type: 'NOT_APPLICABLE',
throughput: { 'level' => 'STANDARD' },
messaging_limit_tier: 'TIER_1000'
})
expect(channel).to receive(:prompt_reauthorization!)
service.perform
end
it 'prompts reauthorization when throughput level is NOT_APPLICABLE' do
health_service = instance_double(Whatsapp::HealthService)
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
allow(health_service).to receive(:fetch_health_status).and_return({
platform_type: 'CLOUD_API',
throughput: { 'level' => 'NOT_APPLICABLE' },
messaging_limit_tier: 'TIER_1000'
})
expect(channel).to receive(:prompt_reauthorization!)
service.perform
end
end
context 'when channel is healthy' do
it 'does not prompt reauthorization for healthy channel' do
expect(channel).not_to receive(:prompt_reauthorization!)
service.perform
end
end
context 'when parameters are invalid' do
it 'raises ArgumentError for missing parameters' do
invalid_service = described_class.new(account: account, params: { code: '', business_id: '', waba_id: '' })
@@ -114,6 +166,16 @@ describe Whatsapp::EmbeddedSignupService do
business_id: params[:business_id]
).and_return(reauth_service)
allow(reauth_service).to receive(:perform).with(access_token, phone_info).and_return(channel)
allow(channel).to receive(:phone_number).and_return('+1234567890')
health_service = instance_double(Whatsapp::HealthService)
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
allow(health_service).to receive(:fetch_health_status).and_return({
platform_type: 'CLOUD_API',
throughput: { 'level' => 'STANDARD' },
messaging_limit_tier: 'TIER_1000'
})
end
it 'uses ReauthorizationService and sets up webhooks' do
@@ -124,36 +186,57 @@ describe Whatsapp::EmbeddedSignupService do
expect(result).to eq(channel)
end
it 'clears reauthorization flag' do
inbox = create(:inbox, account: account)
whatsapp_channel = create(:channel_whatsapp, account: account, phone_number: '+1234567890',
validate_provider_config: false, sync_templates: false)
inbox.update!(channel: whatsapp_channel)
whatsapp_channel.prompt_reauthorization!
context 'with real channel requiring reauthorization' do
let(:inbox) { create(:inbox, account: account) }
let(:whatsapp_channel) do
create(:channel_whatsapp, account: account, phone_number: '+1234567890',
validate_provider_config: false, sync_templates: false)
end
let(:service_with_real_inbox) { described_class.new(account: account, params: params, inbox_id: inbox.id) }
service_with_real_inbox = described_class.new(account: account, params: params, inbox_id: inbox.id)
before do
inbox.update!(channel: whatsapp_channel)
whatsapp_channel.prompt_reauthorization!
# Mock the ReauthorizationService to return our test channel
reauth_service = instance_double(Whatsapp::ReauthorizationService)
allow(Whatsapp::ReauthorizationService).to receive(:new).with(
account: account,
inbox_id: inbox.id,
phone_number_id: params[:phone_number_id],
business_id: params[:business_id]
).and_return(reauth_service)
# Perform the reauthorization and clear the flag
allow(reauth_service).to receive(:perform) do
whatsapp_channel.reauthorized!
whatsapp_channel
setup_reauthorization_mocks
setup_health_service_mock
end
allow(whatsapp_channel).to receive(:setup_webhooks).and_return(true)
it 'clears reauthorization flag when reauthorization completes' do
expect(whatsapp_channel.reauthorization_required?).to be true
result = service_with_real_inbox.perform
expect(result).to eq(whatsapp_channel)
expect(whatsapp_channel.reauthorization_required?).to be false
end
expect(whatsapp_channel.reauthorization_required?).to be true
result = service_with_real_inbox.perform
expect(result).to eq(whatsapp_channel)
expect(whatsapp_channel.reauthorization_required?).to be false
private
def setup_reauthorization_mocks
reauth_service = instance_double(Whatsapp::ReauthorizationService)
allow(Whatsapp::ReauthorizationService).to receive(:new).with(
account: account,
inbox_id: inbox.id,
phone_number_id: params[:phone_number_id],
business_id: params[:business_id]
).and_return(reauth_service)
allow(reauth_service).to receive(:perform) do
whatsapp_channel.reauthorized!
whatsapp_channel
end
allow(whatsapp_channel).to receive(:setup_webhooks).and_return(true)
end
def setup_health_service_mock
health_service = instance_double(Whatsapp::HealthService)
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
allow(health_service).to receive(:fetch_health_status).and_return({
platform_type: 'CLOUD_API',
throughput: { 'level' => 'STANDARD' },
messaging_limit_tier: 'TIER_1000'
})
end
end
end
end

View File

@@ -16,6 +16,7 @@ describe Whatsapp::WebhookSetupService do
let(:access_token) { 'test_access_token' }
let(:service) { described_class.new(channel, waba_id, access_token) }
let(:api_client) { instance_double(Whatsapp::FacebookApiClient) }
let(:health_service) { instance_double(Whatsapp::HealthService) }
before do
# Stub webhook teardown to prevent HTTP calls during cleanup
@@ -24,8 +25,14 @@ describe Whatsapp::WebhookSetupService do
# Clean up any existing channels to avoid phone number conflicts
Channel::Whatsapp.destroy_all
allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client)
# Default stub for phone_number_verified? with any argument
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
# Default stubs for phone_number_verified? and health service
allow(api_client).to receive(:phone_number_verified?).and_return(false)
allow(health_service).to receive(:fetch_health_status).and_return({
platform_type: 'APPLICABLE',
throughput: { level: 'APPLICABLE' }
})
end
describe '#perform' do
@@ -49,9 +56,13 @@ describe Whatsapp::WebhookSetupService do
end
end
context 'when phone number IS verified (should NOT register)' do
context 'when phone number IS verified AND fully provisioned (should NOT register)' do
before do
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
allow(health_service).to receive(:fetch_health_status).and_return({
platform_type: 'APPLICABLE',
throughput: { level: 'APPLICABLE' }
})
allow(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
end
@@ -66,16 +77,68 @@ describe Whatsapp::WebhookSetupService do
end
end
context 'when phone number IS verified BUT needs registration (pending provisioning)' do
before do
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
allow(health_service).to receive(:fetch_health_status).and_return({
platform_type: 'NOT_APPLICABLE',
throughput: { level: 'APPLICABLE' }
})
allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
allow(api_client).to receive(:register_phone_number).with('123456789', 223_456)
allow(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
allow(channel).to receive(:save!)
end
it 'registers the phone number due to pending provisioning state' do
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
expect(api_client).to receive(:register_phone_number).with('123456789', 223_456)
expect(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token')
service.perform
end
end
end
context 'when phone number needs registration due to throughput level' do
before do
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
allow(health_service).to receive(:fetch_health_status).and_return({
platform_type: 'APPLICABLE',
throughput: { level: 'NOT_APPLICABLE' }
})
allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
allow(api_client).to receive(:register_phone_number).with('123456789', 223_456)
allow(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
allow(channel).to receive(:save!)
end
it 'registers the phone number due to throughput not applicable' do
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
expect(api_client).to receive(:register_phone_number).with('123456789', 223_456)
expect(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token')
service.perform
end
end
end
context 'when phone_number_verified? raises error' do
before do
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_raise('API down')
allow(health_service).to receive(:fetch_health_status).and_return({
platform_type: 'APPLICABLE',
throughput: { level: 'APPLICABLE' }
})
allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
allow(api_client).to receive(:register_phone_number)
allow(api_client).to receive(:subscribe_waba_webhook).and_return({ 'success' => true })
allow(channel).to receive(:save!)
end
it 'tries to register phone and proceeds with webhook setup' do
it 'tries to register phone (due to verification error) and proceeds with webhook setup' do
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
expect(api_client).to receive(:register_phone_number)
expect(api_client).to receive(:subscribe_waba_webhook)
@@ -84,6 +147,22 @@ describe Whatsapp::WebhookSetupService do
end
end
context 'when health service raises error' do
before do
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
allow(health_service).to receive(:fetch_health_status).and_raise('Health API down')
allow(api_client).to receive(:subscribe_waba_webhook).and_return({ 'success' => true })
end
it 'does not register phone (conservative approach) and proceeds with webhook setup' do
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
expect(api_client).not_to receive(:register_phone_number)
expect(api_client).to receive(:subscribe_waba_webhook)
expect { service.perform }.not_to raise_error
end
end
end
context 'when phone registration fails (not blocking)' do
before do
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(false)
@@ -193,6 +272,10 @@ describe Whatsapp::WebhookSetupService do
before do
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
allow(health_service).to receive(:fetch_health_status).and_return({
platform_type: 'APPLICABLE',
throughput: { level: 'APPLICABLE' }
})
allow(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, anything, 'existing_verify_token').and_return({ 'success' => true })
end
@@ -218,6 +301,10 @@ describe Whatsapp::WebhookSetupService do
context 'when webhook setup is successful in creation flow' do
before do
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
allow(health_service).to receive(:fetch_health_status).and_return({
platform_type: 'APPLICABLE',
throughput: { level: 'APPLICABLE' }
})
allow(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
end