-
-
-
- {{ item.label }}
-
-
+
+
+
+ {{ section.title }}
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+ {{ item.value }}
+
+
+ {{ formatStatusDisplay(item.value) }}
+
+
+ {{ formatModeDisplay(item.value) }}
+
+
+ {{ formatTierDisplay(item.value) }}
+
+
+ {{ item.value }}
+
+
+
-
-
- {{ item.value }}
-
-
- {{ formatStatusDisplay(item.value) }}
-
-
- {{ formatModeDisplay(item.value) }}
-
-
- {{ formatTierDisplay(item.value) }}
-
- {{
- item.value
- }}
-
-
+
{
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.REGISTER_BUTTON') }}
+
+
+
+ {{ t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.CONFIGURED_URL') }}
+
+
+ {{ webhookUrl }}
+
+
+
+
+
+ {{ t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.EXPECTED_URL') }}
+
+
+ {{ healthData.expected_webhook_url }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ errorState.title }}
+
+
+ {{ errorState.description }}
+
+
+
+
+
{{ healthError.message }}
+
+
+
+ {{ t('INBOX_MGMT.ACCOUNT_HEALTH.ERRORS.GO_TO_CONFIGURATION') }}
+
+
diff --git a/app/jobs/channels/whatsapp/health_sync_job.rb b/app/jobs/channels/whatsapp/health_sync_job.rb
new file mode 100644
index 000000000..b6e09818a
--- /dev/null
+++ b/app/jobs/channels/whatsapp/health_sync_job.rb
@@ -0,0 +1,7 @@
+class Channels::Whatsapp::HealthSyncJob < ApplicationJob
+ queue_as :low
+
+ def perform(whatsapp_channel)
+ Whatsapp::HealthService.new(whatsapp_channel).sync_health_status!
+ end
+end
diff --git a/app/jobs/channels/whatsapp/health_sync_scheduler_job.rb b/app/jobs/channels/whatsapp/health_sync_scheduler_job.rb
new file mode 100644
index 000000000..2d08e93e1
--- /dev/null
+++ b/app/jobs/channels/whatsapp/health_sync_scheduler_job.rb
@@ -0,0 +1,15 @@
+class Channels::Whatsapp::HealthSyncSchedulerJob < ApplicationJob
+ queue_as :low
+
+ def perform
+ Channel::Whatsapp.joins(:account)
+ .merge(Account.active)
+ .where(provider: 'whatsapp_cloud')
+ .where('phone_number_health_checked_at <= ? OR phone_number_health_checked_at IS NULL', 6.hours.ago)
+ .order(Arel.sql('phone_number_health_checked_at IS NULL DESC, phone_number_health_checked_at ASC'))
+ .limit(Limits::BULK_EXTERNAL_HTTP_CALLS_LIMIT)
+ .each do |channel|
+ Channels::Whatsapp::HealthSyncJob.perform_later(channel)
+ end
+ end
+end
diff --git a/app/jobs/trigger_scheduled_items_job.rb b/app/jobs/trigger_scheduled_items_job.rb
index 0ec368c54..dd0cae3cb 100644
--- a/app/jobs/trigger_scheduled_items_job.rb
+++ b/app/jobs/trigger_scheduled_items_job.rb
@@ -19,6 +19,9 @@ class TriggerScheduledItemsJob < ApplicationJob
# Job to sync whatsapp templates
Channels::Whatsapp::TemplatesSyncSchedulerJob.perform_later
+
+ # Job to sync whatsapp phone number health
+ Channels::Whatsapp::HealthSyncSchedulerJob.perform_later
end
end
diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb
index 87ff199d5..b7557ccb2 100644
--- a/app/models/channel/whatsapp.rb
+++ b/app/models/channel/whatsapp.rb
@@ -6,6 +6,9 @@
# message_templates :jsonb
# message_templates_last_updated :datetime
# phone_number :string not null
+# phone_number_health :jsonb not null
+# phone_number_health_checked_at :datetime
+# phone_number_health_error :string
# provider :string default("default")
# provider_config :jsonb
# created_at :datetime not null
@@ -14,7 +17,8 @@
#
# Indexes
#
-# index_channel_whatsapp_on_phone_number (phone_number) UNIQUE
+# index_channel_whatsapp_on_phone_number (phone_number) UNIQUE
+# index_channel_whatsapp_on_phone_number_health_checked_at (phone_number_health_checked_at)
#
class Channel::Whatsapp < ApplicationRecord
diff --git a/app/services/whatsapp/health_service.rb b/app/services/whatsapp/health_service.rb
index 428fb4e6b..9aa554962 100644
--- a/app/services/whatsapp/health_service.rb
+++ b/app/services/whatsapp/health_service.rb
@@ -1,5 +1,41 @@
class Whatsapp::HealthService
+ class ApiError < StandardError
+ attr_reader :http_status, :code, :subcode
+
+ def initialize(message:, http_status:, code: nil, subcode: nil)
+ super(message)
+ @http_status = http_status
+ @code = code
+ @subcode = subcode
+ end
+
+ def authorization_error?
+ code.to_i == 190
+ end
+ end
+
BASE_URI = 'https://graph.facebook.com'.freeze
+ PERSISTED_FIELDS = %i[
+ id
+ display_phone_number
+ verified_name
+ name_status
+ quality_rating
+ messaging_limit_tier
+ status
+ account_mode
+ code_verification_status
+ throughput_level
+ last_onboarded_time
+ is_on_biz_app
+ platform_type
+ business_account_id
+ business_account_name
+ business_portfolio_id
+ business_portfolio_name
+ ].freeze
+ RISKY_QUALITY_RATINGS = %w[YELLOW RED].freeze
+ RISKY_STATUSES = %w[BANNED RESTRICTED RATE_LIMITED FLAGGED DISCONNECTED DELETED].freeze
def initialize(channel)
@channel = channel
@@ -9,7 +45,25 @@ class Whatsapp::HealthService
def fetch_health_status
validate_channel!
- fetch_phone_health_data
+
+ phone_health_data = fetch_graph_data(@channel.provider_config['phone_number_id'], phone_health_fields)
+ business_account_data = fetch_graph_data(@channel.provider_config['business_account_id'], business_account_fields)
+
+ format_phone_health_response(phone_health_data).merge(format_business_account_response(business_account_data))
+ end
+
+ def sync_health_status!
+ health_status = fetch_health_status
+ checked_at = Time.current
+ previous_health = @channel.phone_number_health
+
+ persist_health_status(health_status, checked_at)
+ log_risky_transition(previous_health, health_status)
+
+ health_status.merge(health_checked_at: checked_at)
+ rescue StandardError => e
+ persist_health_error(e)
+ raise
end
private
@@ -18,15 +72,14 @@ class Whatsapp::HealthService
raise ArgumentError, 'Channel is required' if @channel.blank?
raise ArgumentError, 'API key is missing' if @access_token.blank?
raise ArgumentError, 'Phone number ID is missing' if @channel.provider_config['phone_number_id'].blank?
+ raise ArgumentError, 'Business account ID is missing' if @channel.provider_config['business_account_id'].blank?
end
- def fetch_phone_health_data
- phone_number_id = @channel.provider_config['phone_number_id']
-
+ def fetch_graph_data(resource_id, fields)
response = HTTParty.get(
- "#{BASE_URI}/#{@api_version}/#{phone_number_id}",
+ "#{BASE_URI}/#{@api_version}/#{resource_id}",
query: {
- fields: health_fields,
+ fields: fields,
access_token: @access_token
}
)
@@ -34,14 +87,15 @@ class Whatsapp::HealthService
handle_response(response)
rescue StandardError => e
Rails.logger.error "[WHATSAPP HEALTH] Error fetching health data: #{e.message}"
- raise e
+ raise
end
- def health_fields
+ def phone_health_fields
%w[
id
quality_rating
- messaging_limit_tier
+ whatsapp_business_manager_messaging_limit
+ status
code_verification_status
account_mode
display_phone_number
@@ -50,39 +104,63 @@ class Whatsapp::HealthService
webhook_configuration
throughput
last_onboarded_time
+ is_on_biz_app
platform_type
- certificate
].join(',')
end
- def handle_response(response)
- unless response.success?
- error_message = "WhatsApp API request failed: #{response.code} - #{response.body}"
- Rails.logger.error "[WHATSAPP HEALTH] #{error_message}"
- raise error_message
- end
-
- data = response.parsed_response
- format_health_response(data)
+ def business_account_fields
+ %w[id name owner_business_info].join(',')
end
- def format_health_response(response)
+ def handle_response(response)
+ return response.parsed_response if response.success?
+
+ parsed_response = response.parsed_response
+ error_data = parsed_response.is_a?(Hash) ? parsed_response['error'].to_h : {}
+ error = ApiError.new(
+ message: error_data['message'].presence || 'WhatsApp API request failed',
+ http_status: response.code,
+ code: error_data['code'],
+ subcode: error_data['error_subcode']
+ )
+
+ Rails.logger.error(
+ "[WHATSAPP HEALTH] WhatsApp API request failed: http_status=#{error.http_status} " \
+ "code=#{error.code} subcode=#{error.subcode} message=#{error.message}"
+ )
+ raise error
+ end
+
+ def format_phone_health_response(phone_response)
{
- id: response['id'],
- display_phone_number: response['display_phone_number'],
- verified_name: response['verified_name'],
- name_status: response['name_status'],
- quality_rating: response['quality_rating'],
- messaging_limit_tier: response['messaging_limit_tier'],
- account_mode: response['account_mode'],
- code_verification_status: response['code_verification_status'],
- webhook_configuration: response['webhook_configuration'],
+ id: phone_response['id'],
+ display_phone_number: phone_response['display_phone_number'],
+ verified_name: phone_response['verified_name'],
+ name_status: phone_response['name_status'],
+ quality_rating: phone_response['quality_rating'],
+ messaging_limit_tier: phone_response['whatsapp_business_manager_messaging_limit'],
+ status: phone_response['status'],
+ account_mode: phone_response['account_mode'],
+ code_verification_status: phone_response['code_verification_status'],
+ webhook_configuration: phone_response['webhook_configuration'],
expected_webhook_url: build_expected_webhook_url,
- throughput: response['throughput'],
- last_onboarded_time: response['last_onboarded_time'],
- platform_type: response['platform_type'],
- certificate: response['certificate'],
- business_id: @channel.provider_config['business_account_id']
+ throughput: phone_response['throughput'],
+ throughput_level: phone_response.dig('throughput', 'level'),
+ last_onboarded_time: phone_response['last_onboarded_time'],
+ is_on_biz_app: phone_response['is_on_biz_app'],
+ platform_type: phone_response['platform_type']
+ }
+ end
+
+ def format_business_account_response(business_account_response)
+ owner_business_info = business_account_response['owner_business_info'] || {}
+
+ {
+ business_account_id: business_account_response['id'],
+ business_account_name: business_account_response['name'],
+ business_portfolio_id: owner_business_info['id'],
+ business_portfolio_name: owner_business_info['name']
}
end
@@ -92,4 +170,46 @@ class Whatsapp::HealthService
"#{frontend_url}/webhooks/whatsapp/#{@channel.phone_number}"
end
+
+ def persist_health_status(health_status, checked_at)
+ # Health polling should not touch the inbox or run channel credential validation.
+ # rubocop:disable Rails/SkipsModelValidations
+ @channel.update_columns(
+ phone_number_health: health_status.slice(*PERSISTED_FIELDS),
+ phone_number_health_checked_at: checked_at,
+ phone_number_health_error: nil
+ )
+ # rubocop:enable Rails/SkipsModelValidations
+ end
+
+ def persist_health_error(error)
+ return unless @channel&.persisted?
+
+ # rubocop:disable Rails/SkipsModelValidations
+ @channel.update_columns(
+ phone_number_health_checked_at: Time.current,
+ phone_number_health_error: error.message.truncate(500)
+ )
+ # rubocop:enable Rails/SkipsModelValidations
+ end
+
+ def log_risky_transition(previous_health, health_status)
+ return unless risky_health?(health_status)
+ return if risk_signature(previous_health) == risk_signature(health_status)
+
+ Rails.logger.warn(
+ '[WHATSAPP HEALTH] risky_phone_number ' \
+ "account_id=#{@channel.account_id} inbox_id=#{@channel.inbox&.id} channel_id=#{@channel.id} " \
+ "phone_number_id=#{health_status[:id]} quality_rating=#{health_status[:quality_rating]} " \
+ "status=#{health_status[:status]} messaging_limit_tier=#{health_status[:messaging_limit_tier]}"
+ )
+ end
+
+ def risky_health?(health_status)
+ RISKY_QUALITY_RATINGS.include?(health_status[:quality_rating]) || RISKY_STATUSES.include?(health_status[:status])
+ end
+
+ def risk_signature(health_status)
+ health_status.to_h.with_indifferent_access.values_at(:quality_rating, :status, :messaging_limit_tier)
+ end
end
diff --git a/db/migrate/20260718000000_add_phone_number_health_to_channel_whatsapp.rb b/db/migrate/20260718000000_add_phone_number_health_to_channel_whatsapp.rb
new file mode 100644
index 000000000..687bcda3f
--- /dev/null
+++ b/db/migrate/20260718000000_add_phone_number_health_to_channel_whatsapp.rb
@@ -0,0 +1,8 @@
+class AddPhoneNumberHealthToChannelWhatsapp < ActiveRecord::Migration[7.1]
+ def change
+ add_column :channel_whatsapp, :phone_number_health, :jsonb, default: {}, null: false
+ add_column :channel_whatsapp, :phone_number_health_checked_at, :datetime
+ add_column :channel_whatsapp, :phone_number_health_error, :string
+ add_index :channel_whatsapp, :phone_number_health_checked_at
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index a3dfbda46..b23e81786 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
+ActiveRecord::Schema[7.1].define(version: 2026_07_18_000000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -683,6 +683,10 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
t.datetime "updated_at", null: false
t.jsonb "message_templates", default: {}
t.datetime "message_templates_last_updated", precision: nil
+ t.jsonb "phone_number_health", default: {}, null: false
+ t.datetime "phone_number_health_checked_at"
+ t.string "phone_number_health_error"
+ t.index ["phone_number_health_checked_at"], name: "index_channel_whatsapp_on_phone_number_health_checked_at"
t.index ["phone_number"], name: "index_channel_whatsapp_on_phone_number", unique: true
end
diff --git a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
index 6a8055a04..8844e3bb1 100644
--- a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
@@ -1321,19 +1321,24 @@ RSpec.describe 'Inboxes API', type: :request do
let(:health_service) { instance_double(Whatsapp::HealthService) }
let(:health_data) do
{
+ id: 'phone123',
display_phone_number: '+1234567890',
verified_name: 'Test Business',
name_status: 'APPROVED',
quality_rating: 'GREEN',
messaging_limit_tier: 'TIER_1000',
account_mode: 'LIVE',
- business_id: 'business123'
+ status: 'CONNECTED',
+ business_account_id: 'waba123',
+ business_account_name: 'Test WABA',
+ business_portfolio_id: 'business123',
+ business_portfolio_name: 'Test Business Portfolio'
}
end
before do
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
- allow(health_service).to receive(:fetch_health_status).and_return(health_data)
+ allow(health_service).to receive(:sync_health_status!).and_return(health_data)
end
context 'when it is an unauthenticated user' do
@@ -1354,13 +1359,18 @@ RSpec.describe 'Inboxes API', type: :request do
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response).to include(
+ 'id' => 'phone123',
'display_phone_number' => '+1234567890',
'verified_name' => 'Test Business',
'name_status' => 'APPROVED',
'quality_rating' => 'GREEN',
'messaging_limit_tier' => 'TIER_1000',
'account_mode' => 'LIVE',
- 'business_id' => 'business123'
+ 'status' => 'CONNECTED',
+ 'business_account_id' => 'waba123',
+ 'business_account_name' => 'Test WABA',
+ 'business_portfolio_id' => 'business123',
+ 'business_portfolio_name' => 'Test Business Portfolio'
)
end
@@ -1386,7 +1396,7 @@ RSpec.describe 'Inboxes API', type: :request do
it 'calls the health service with correct channel' do
expect(Whatsapp::HealthService).to receive(:new).with(whatsapp_channel).and_return(health_service)
- expect(health_service).to receive(:fetch_health_status)
+ expect(health_service).to receive(:sync_health_status!)
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/health",
headers: admin.create_new_auth_token,
@@ -1396,7 +1406,7 @@ RSpec.describe 'Inboxes API', type: :request do
end
it 'handles service errors gracefully' do
- allow(health_service).to receive(:fetch_health_status).and_raise(StandardError, 'API Error')
+ allow(health_service).to receive(:sync_health_status!).and_raise(StandardError, 'API Error')
get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/health",
headers: admin.create_new_auth_token,
@@ -1406,6 +1416,29 @@ RSpec.describe 'Inboxes API', type: :request do
json_response = response.parsed_body
expect(json_response['error']).to include('API Error')
end
+
+ it 'classifies Meta authorization failures for the recovery UI' do
+ error = Whatsapp::HealthService::ApiError.new(
+ message: 'The access token cannot authorize this request.',
+ http_status: 400,
+ code: 190,
+ subcode: 464
+ )
+ allow(health_service).to receive(:sync_health_status!).and_raise(error)
+
+ get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}/health",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:unprocessable_entity)
+ expect(response.parsed_body['error']).to eq(
+ 'type' => 'authorization',
+ 'message' => 'The access token cannot authorize this request.',
+ 'http_status' => 400,
+ 'code' => 190,
+ 'subcode' => 464
+ )
+ end
end
context 'with non-WhatsApp inbox' do
diff --git a/spec/jobs/channels/whatsapp/health_sync_job_spec.rb b/spec/jobs/channels/whatsapp/health_sync_job_spec.rb
new file mode 100644
index 000000000..1a1c4eede
--- /dev/null
+++ b/spec/jobs/channels/whatsapp/health_sync_job_spec.rb
@@ -0,0 +1,26 @@
+require 'rails_helper'
+
+RSpec.describe Channels::Whatsapp::HealthSyncJob do
+ let(:whatsapp_channel) do
+ create(
+ :channel_whatsapp,
+ provider: 'whatsapp_cloud',
+ sync_templates: false,
+ validate_provider_config: false
+ )
+ end
+ let(:health_service) { instance_double(Whatsapp::HealthService, sync_health_status!: true) }
+
+ it 'enqueues on the low priority queue' do
+ expect { described_class.perform_later(whatsapp_channel) }
+ .to have_enqueued_job(described_class).with(whatsapp_channel).on_queue('low')
+ end
+
+ it 'synchronizes the channel health status' do
+ allow(Whatsapp::HealthService).to receive(:new).with(whatsapp_channel).and_return(health_service)
+
+ described_class.perform_now(whatsapp_channel)
+
+ expect(health_service).to have_received(:sync_health_status!)
+ end
+end
diff --git a/spec/jobs/channels/whatsapp/health_sync_scheduler_job_spec.rb b/spec/jobs/channels/whatsapp/health_sync_scheduler_job_spec.rb
new file mode 100644
index 000000000..898a612d3
--- /dev/null
+++ b/spec/jobs/channels/whatsapp/health_sync_scheduler_job_spec.rb
@@ -0,0 +1,85 @@
+require 'rails_helper'
+
+RSpec.describe Channels::Whatsapp::HealthSyncSchedulerJob do
+ it 'enqueues on the low priority queue' do
+ expect { described_class.perform_later }.to have_enqueued_job(described_class).on_queue('low')
+ end
+
+ it 'schedules stale and unchecked active Cloud API channels' do
+ unchecked = create(
+ :channel_whatsapp,
+ provider: 'whatsapp_cloud',
+ phone_number_health_checked_at: nil,
+ sync_templates: false,
+ validate_provider_config: false
+ )
+ stale = create(
+ :channel_whatsapp,
+ provider: 'whatsapp_cloud',
+ phone_number_health_checked_at: 7.hours.ago,
+ sync_templates: false,
+ validate_provider_config: false
+ )
+ recent = create(
+ :channel_whatsapp,
+ provider: 'whatsapp_cloud',
+ phone_number_health_checked_at: 1.hour.ago,
+ sync_templates: false,
+ validate_provider_config: false
+ )
+ non_cloud = create(
+ :channel_whatsapp,
+ provider: 'default',
+ phone_number_health_checked_at: 7.hours.ago,
+ sync_templates: false,
+ validate_provider_config: false
+ )
+ suspended = create(
+ :channel_whatsapp,
+ account: create(:account, status: :suspended),
+ provider: 'whatsapp_cloud',
+ phone_number_health_checked_at: nil,
+ sync_templates: false,
+ validate_provider_config: false
+ )
+
+ described_class.perform_now
+
+ expect(Channels::Whatsapp::HealthSyncJob).to have_been_enqueued.with(unchecked).on_queue('low')
+ expect(Channels::Whatsapp::HealthSyncJob).to have_been_enqueued.with(stale).on_queue('low')
+ expect(Channels::Whatsapp::HealthSyncJob).not_to have_been_enqueued.with(recent).on_queue('low')
+ expect(Channels::Whatsapp::HealthSyncJob).not_to have_been_enqueued.with(non_cloud).on_queue('low')
+ expect(Channels::Whatsapp::HealthSyncJob).not_to have_been_enqueued.with(suspended).on_queue('low')
+ end
+
+ it 'schedules unchecked and oldest channels first when the batch is limited' do
+ stub_const('Limits::BULK_EXTERNAL_HTTP_CALLS_LIMIT', 2)
+ unchecked = create(
+ :channel_whatsapp,
+ provider: 'whatsapp_cloud',
+ phone_number_health_checked_at: nil,
+ sync_templates: false,
+ validate_provider_config: false
+ )
+ older = create(
+ :channel_whatsapp,
+ provider: 'whatsapp_cloud',
+ phone_number_health_checked_at: 9.hours.ago,
+ sync_templates: false,
+ validate_provider_config: false
+ )
+ newer = create(
+ :channel_whatsapp,
+ provider: 'whatsapp_cloud',
+ phone_number_health_checked_at: 7.hours.ago,
+ sync_templates: false,
+ validate_provider_config: false
+ )
+
+ described_class.perform_now
+
+ expect(Channels::Whatsapp::HealthSyncJob).to have_been_enqueued.with(unchecked).on_queue('low')
+ expect(Channels::Whatsapp::HealthSyncJob).to have_been_enqueued.with(older).on_queue('low')
+ expect(Channels::Whatsapp::HealthSyncJob).not_to have_been_enqueued.with(newer).on_queue('low')
+ end
+end
diff --git a/spec/jobs/trigger_scheduled_items_job_spec.rb b/spec/jobs/trigger_scheduled_items_job_spec.rb
index 141abdcdd..f012fb6a7 100644
--- a/spec/jobs/trigger_scheduled_items_job_spec.rb
+++ b/spec/jobs/trigger_scheduled_items_job_spec.rb
@@ -30,6 +30,11 @@ RSpec.describe TriggerScheduledItemsJob do
described_class.perform_now
end
+ it 'triggers Channels::Whatsapp::HealthSyncSchedulerJob' do
+ expect(Channels::Whatsapp::HealthSyncSchedulerJob).to receive(:perform_later).once
+ described_class.perform_now
+ end
+
context 'when unexecuted Scheduled campaign jobs' do
let!(:twilio_sms) { create(:channel_twilio_sms, account: account) }
let!(:twilio_inbox) { create(:inbox, channel: twilio_sms, account: account) }
diff --git a/spec/services/whatsapp/health_service_spec.rb b/spec/services/whatsapp/health_service_spec.rb
new file mode 100644
index 000000000..f3c6d9858
--- /dev/null
+++ b/spec/services/whatsapp/health_service_spec.rb
@@ -0,0 +1,181 @@
+require 'rails_helper'
+
+RSpec.describe Whatsapp::HealthService do
+ subject(:service) { described_class.new(channel) }
+
+ let(:provider_config) do
+ {
+ 'api_key' => 'test_access_token',
+ 'phone_number_id' => 'test_phone_number_id',
+ 'business_account_id' => 'test_waba_id',
+ 'source' => 'embedded_signup'
+ }
+ end
+ let(:previous_health) { {} }
+ let(:channel) do
+ create(
+ :channel_whatsapp,
+ phone_number: '+15550001111',
+ provider: 'whatsapp_cloud',
+ sync_templates: false,
+ validate_provider_config: false
+ ).tap do |record|
+ record.provider_config = provider_config
+ record.phone_number_health = previous_health
+ record.save!
+ end
+ end
+ let(:phone_health_response) do
+ {
+ 'id' => 'test_phone_number_id',
+ 'display_phone_number' => '+1 555-000-1111',
+ 'verified_name' => 'Example Store',
+ 'name_status' => 'APPROVED',
+ 'quality_rating' => 'GREEN',
+ 'whatsapp_business_manager_messaging_limit' => 'TIER_250',
+ 'status' => 'CONNECTED',
+ 'account_mode' => 'LIVE',
+ 'code_verification_status' => 'VERIFIED',
+ 'webhook_configuration' => {
+ 'phone_number' => 'https://example.test/webhooks/whatsapp/+15550001111',
+ 'application' => 'https://example.test/webhooks/whatsapp/+15550001111'
+ },
+ 'throughput' => { 'level' => 'STANDARD' },
+ 'last_onboarded_time' => '2026-07-01T08:30:00+0000',
+ 'is_on_biz_app' => true,
+ 'platform_type' => 'CLOUD_API'
+ }
+ end
+ let(:business_account_response) do
+ {
+ 'id' => 'test_waba_id',
+ 'name' => 'Example WABA',
+ 'owner_business_info' => {
+ 'id' => 'test_business_portfolio_id',
+ 'name' => 'Example Business Portfolio'
+ }
+ }
+ end
+
+ before do
+ stub_request(:get, %r{graph\.facebook\.com/v22\.0/test_phone_number_id})
+ .to_return(status: 200, body: phone_health_response.to_json, headers: { 'Content-Type' => 'application/json' })
+ stub_request(:get, %r{graph\.facebook\.com/v22\.0/test_waba_id})
+ .to_return(status: 200, body: business_account_response.to_json, headers: { 'Content-Type' => 'application/json' })
+ end
+
+ describe '#fetch_health_status' do
+ it 'fetches and formats phone, WABA, portfolio, webhook, and coexistence health' do
+ result = nil
+
+ with_modified_env FRONTEND_URL: 'https://example.test' do
+ result = service.fetch_health_status
+ end
+
+ expect(result).to include(
+ id: 'test_phone_number_id',
+ display_phone_number: '+1 555-000-1111',
+ verified_name: 'Example Store',
+ quality_rating: 'GREEN',
+ messaging_limit_tier: 'TIER_250',
+ status: 'CONNECTED',
+ throughput: { 'level' => 'STANDARD' },
+ throughput_level: 'STANDARD',
+ is_on_biz_app: true,
+ platform_type: 'CLOUD_API',
+ business_account_id: 'test_waba_id',
+ business_account_name: 'Example WABA',
+ business_portfolio_id: 'test_business_portfolio_id',
+ business_portfolio_name: 'Example Business Portfolio',
+ expected_webhook_url: 'https://example.test/webhooks/whatsapp/+15550001111'
+ )
+ end
+
+ it 'requests the current messaging capacity field instead of the deprecated field' do
+ service.fetch_health_status
+
+ expect(
+ a_request(:get, %r{graph\.facebook\.com/v22\.0/test_phone_number_id}).with do |request|
+ fields = CGI.parse(request.uri.query)['fields']&.first.to_s.split(',')
+ fields.include?('whatsapp_business_manager_messaging_limit') && fields.exclude?('messaging_limit_tier')
+ end
+ ).to have_been_made.once
+ end
+ end
+
+ describe '#sync_health_status!' do
+ it 'persists the latest successful snapshot and clears an earlier error' do
+ channel.phone_number_health_error = 'Previous failure'
+ channel.save!
+
+ travel_to(Time.zone.parse('2026-07-21 10:00:00')) do
+ result = service.sync_health_status!
+ channel.reload
+
+ expect(result[:health_checked_at]).to eq(Time.current)
+ expect(channel.phone_number_health).to include(
+ 'quality_rating' => 'GREEN',
+ 'messaging_limit_tier' => 'TIER_250',
+ 'throughput_level' => 'STANDARD',
+ 'is_on_biz_app' => true,
+ 'platform_type' => 'CLOUD_API',
+ 'business_account_id' => 'test_waba_id',
+ 'business_portfolio_id' => 'test_business_portfolio_id'
+ )
+ expect(channel.phone_number_health).not_to have_key('webhook_configuration')
+ expect(channel.phone_number_health_checked_at).to eq(Time.current)
+ expect(channel.phone_number_health_error).to be_nil
+ end
+ end
+
+ context 'when Meta rejects the access token' do
+ let(:previous_health) { { quality_rating: 'GREEN', status: 'CONNECTED' } }
+
+ before do
+ stub_request(:get, %r{graph\.facebook\.com/v22\.0/test_phone_number_id})
+ .to_return(
+ status: 400,
+ body: {
+ error: {
+ message: 'The access token cannot authorize this request.',
+ type: 'OAuthException',
+ code: 190,
+ error_subcode: 464
+ }
+ }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+ end
+
+ it 'raises a structured authorization error' do
+ expect { service.sync_health_status! }.to raise_error(described_class::ApiError) do |error|
+ expect(error.http_status).to eq(400)
+ expect(error.code).to eq(190)
+ expect(error.subcode).to eq(464)
+ expect(error).to be_authorization_error
+ end
+ end
+
+ it 'preserves the successful snapshot and records the failed attempt' do
+ travel_to(Time.zone.parse('2026-07-21 11:00:00')) do
+ expect { service.sync_health_status! }.to raise_error(described_class::ApiError)
+ channel.reload
+ expect(channel.phone_number_health).to eq('quality_rating' => 'GREEN', 'status' => 'CONNECTED')
+ expect(channel.phone_number_health_checked_at).to eq(Time.current)
+ expect(channel.phone_number_health_error).to eq('The access token cannot authorize this request.')
+ end
+ end
+ end
+
+ context 'when health first becomes risky' do
+ let(:previous_health) { { quality_rating: 'GREEN', status: 'CONNECTED', messaging_limit_tier: 'TIER_250' } }
+ let(:phone_health_response) { super().merge('quality_rating' => 'YELLOW') }
+
+ it 'logs the risky transition once it is persisted' do
+ expect(Rails.logger).to receive(:warn).with(/risky_phone_number.*quality_rating=YELLOW/)
+
+ service.sync_health_status!
+ end
+ end
+ end
+end