-
-
-
- {{ 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/javascript/dashboard/routes/dashboard/settings/inbox/components/specs/AccountHealth.spec.js b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/specs/AccountHealth.spec.js
new file mode 100644
index 000000000..cca7bfbb5
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/specs/AccountHealth.spec.js
@@ -0,0 +1,85 @@
+import { shallowMount } from '@vue/test-utils';
+import ButtonV4 from 'next/button/Button.vue';
+import AccountHealth from '../AccountHealth.vue';
+
+vi.mock('vue-i18n', () => ({
+ useI18n: () => ({
+ t: key => key,
+ te: () => false,
+ locale: { value: 'en' },
+ }),
+}));
+
+describe('AccountHealth', () => {
+ const mountComponent = (healthData, props = {}) =>
+ shallowMount(AccountHealth, {
+ props: { healthData, ...props },
+ });
+
+ beforeEach(() => {
+ vi.spyOn(window, 'open').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('opens the phone numbers page for the correct WhatsApp Business Account', async () => {
+ const wrapper = mountComponent({
+ business_account_id: 'waba-456',
+ business_portfolio_id: 'business-123',
+ });
+
+ await wrapper.findComponent(ButtonV4).trigger('click');
+
+ expect(window.open).toHaveBeenCalledWith(
+ 'https://business.facebook.com/latest/whatsapp_manager/phone_numbers/?business_id=business-123&asset_id=waba-456',
+ '_blank'
+ );
+ });
+
+ it('opens Meta Business Manager when the WhatsApp Business Account ID is unavailable', async () => {
+ const wrapper = mountComponent({
+ business_portfolio_id: 'business-123',
+ });
+
+ await wrapper.findComponent(ButtonV4).trigger('click');
+
+ expect(window.open).toHaveBeenCalledWith(
+ 'https://business.facebook.com/',
+ '_blank'
+ );
+ });
+
+ it('formats unknown messaging tiers and account modes without exposing translation keys', () => {
+ const wrapper = mountComponent({
+ messaging_limit_tier: 'TIER_CUSTOM',
+ account_mode: 'CUSTOM_MODE',
+ });
+
+ expect(wrapper.text()).toContain('Tier Custom');
+ expect(wrapper.text()).toContain('Custom Mode');
+ expect(wrapper.text()).not.toContain(
+ 'INBOX_MGMT.ACCOUNT_HEALTH.VALUES.TIERS.TIER_CUSTOM'
+ );
+ expect(wrapper.text()).not.toContain(
+ 'INBOX_MGMT.ACCOUNT_HEALTH.VALUES.MODES.CUSTOM_MODE'
+ );
+ });
+
+ it('shows the current error instead of stale health data', () => {
+ const wrapper = mountComponent(
+ { verified_name: 'Stale Business Name' },
+ {
+ healthError: {
+ type: 'authorization',
+ message: 'The connection needs to be refreshed',
+ },
+ isEmbeddedSignup: true,
+ }
+ );
+
+ expect(wrapper.text()).toContain('The connection needs to be refreshed');
+ expect(wrapper.text()).not.toContain('Stale Business Name');
+ });
+});
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..258aac7e7
--- /dev/null
+++ b/app/jobs/channels/whatsapp/health_sync_job.rb
@@ -0,0 +1,9 @@
+class Channels::Whatsapp::HealthSyncJob < ApplicationJob
+ queue_as :low
+
+ def perform(whatsapp_channel)
+ Whatsapp::HealthService.new(whatsapp_channel).sync_health_status!
+ rescue Whatsapp::HealthService::ApiError, ArgumentError
+ nil
+ 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/internal/trigger_hourly_scheduled_items_job.rb b/app/jobs/internal/trigger_hourly_scheduled_items_job.rb
index f870d742b..4e6e6ef61 100644
--- a/app/jobs/internal/trigger_hourly_scheduled_items_job.rb
+++ b/app/jobs/internal/trigger_hourly_scheduled_items_job.rb
@@ -1,7 +1,9 @@
class Internal::TriggerHourlyScheduledItemsJob < ApplicationJob
queue_as :scheduled_jobs
- def perform; end
+ def perform
+ Channels::Whatsapp::HealthSyncSchedulerJob.perform_later
+ end
end
Internal::TriggerHourlyScheduledItemsJob.prepend_mod_with('Internal::TriggerHourlyScheduledItemsJob')
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..00a78a463 100644
--- a/app/services/whatsapp/health_service.rb
+++ b/app/services/whatsapp/health_service.rb
@@ -1,15 +1,71 @@
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
+ MINIMUM_HEALTH_API_VERSION = 24.0
+ 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
@access_token = channel.provider_config['api_key']
- @api_version = GlobalConfigService.load('WHATSAPP_API_VERSION', 'v22.0')
+ # TODO: Remove this health-specific minimum when all WhatsApp integrations are consolidated on the latest Graph API version.
+ configured_api_version = GlobalConfigService.load('WHATSAPP_API_VERSION', 'v22.0').delete_prefix('v').to_f
+ @api_version = "v#{[configured_api_version, MINIMUM_HEALTH_API_VERSION].max}"
end
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!
+ attempted_at = Time.current
+ previous_health = @channel.phone_number_health
+ health_status = fetch_health_status
+
+ log_risky_transition(previous_health, health_status) if persist_health_status(health_status, attempted_at)
+
+ health_status.merge(health_checked_at: attempted_at)
+ rescue StandardError => e
+ persist_health_error(e, attempted_at)
+ raise
end
private
@@ -18,15 +74,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 +89,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 +106,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 +172,54 @@ class Whatsapp::HealthService
"#{frontend_url}/webhooks/whatsapp/#{@channel.phone_number}"
end
+
+ def persist_health_status(health_status, attempted_at)
+ # Health polling must bypass credential validation, timestamps, inbox touches, and audit callbacks.
+ # rubocop:disable Rails/SkipsModelValidations
+ updated_rows = health_attempt_scope(attempted_at).update_all(
+ phone_number_health: health_status.slice(*PERSISTED_FIELDS),
+ phone_number_health_checked_at: attempted_at,
+ phone_number_health_error: nil
+ )
+ # rubocop:enable Rails/SkipsModelValidations
+
+ updated_rows == 1
+ end
+
+ def persist_health_error(error, attempted_at)
+ return unless @channel&.persisted?
+
+ # Recording a provider failure must not run the same provider validation or channel callbacks.
+ # rubocop:disable Rails/SkipsModelValidations
+ health_attempt_scope(attempted_at).update_all(
+ phone_number_health_checked_at: attempted_at,
+ phone_number_health_error: error.message.truncate(500)
+ )
+ # rubocop:enable Rails/SkipsModelValidations
+ end
+
+ def health_attempt_scope(attempted_at)
+ Channel::Whatsapp.where(id: @channel.id)
+ .where('phone_number_health_checked_at < ? OR phone_number_health_checked_at IS NULL', attempted_at)
+ 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)
+ 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..58ce84983
--- /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, limit: 500
+ add_index :channel_whatsapp, :phone_number_health_checked_at
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 28b303233..c7c5f6a77 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_14_123000) 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_14_123000) 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", limit: 500
+ 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..aeb1e6eb6
--- /dev/null
+++ b/spec/jobs/channels/whatsapp/health_sync_job_spec.rb
@@ -0,0 +1,48 @@
+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
+
+ it 'does not retry recorded API failures' do
+ error = Whatsapp::HealthService::ApiError.new(message: 'Request failed', http_status: 400)
+ allow(Whatsapp::HealthService).to receive(:new).with(whatsapp_channel).and_return(health_service)
+ allow(health_service).to receive(:sync_health_status!).and_raise(error)
+
+ expect { described_class.perform_now(whatsapp_channel) }.not_to raise_error
+ end
+
+ it 'does not retry recorded configuration failures' do
+ allow(Whatsapp::HealthService).to receive(:new).with(whatsapp_channel).and_return(health_service)
+ allow(health_service).to receive(:sync_health_status!).and_raise(ArgumentError, 'API key is missing')
+
+ expect { described_class.perform_now(whatsapp_channel) }.not_to raise_error
+ end
+
+ it 'retries unexpected failures' do
+ allow(Whatsapp::HealthService).to receive(:new).with(whatsapp_channel).and_return(health_service)
+ allow(health_service).to receive(:sync_health_status!).and_raise(StandardError, 'Database unavailable')
+
+ expect { described_class.perform_now(whatsapp_channel) }.to raise_error(StandardError, 'Database unavailable')
+ 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/internal/trigger_hourly_scheduled_items_job_spec.rb b/spec/jobs/internal/trigger_hourly_scheduled_items_job_spec.rb
new file mode 100644
index 000000000..bff55e593
--- /dev/null
+++ b/spec/jobs/internal/trigger_hourly_scheduled_items_job_spec.rb
@@ -0,0 +1,16 @@
+require 'rails_helper'
+
+RSpec.describe Internal::TriggerHourlyScheduledItemsJob do
+ subject(:perform_job) { described_class.perform_now }
+
+ it 'enqueues the job' do
+ expect { described_class.perform_later }.to have_enqueued_job(described_class)
+ .on_queue('scheduled_jobs')
+ end
+
+ it 'triggers Channels::Whatsapp::HealthSyncSchedulerJob' do
+ expect(Channels::Whatsapp::HealthSyncSchedulerJob).to receive(:perform_later).once
+
+ perform_job
+ end
+end
diff --git a/spec/jobs/trigger_scheduled_items_job_spec.rb b/spec/jobs/trigger_scheduled_items_job_spec.rb
index 141abdcdd..2852c4ad5 100644
--- a/spec/jobs/trigger_scheduled_items_job_spec.rb
+++ b/spec/jobs/trigger_scheduled_items_job_spec.rb
@@ -30,6 +30,12 @@ RSpec.describe TriggerScheduledItemsJob do
described_class.perform_now
end
+ it 'does not trigger the hourly WhatsApp health scheduler' do
+ expect(Channels::Whatsapp::HealthSyncSchedulerJob).not_to receive(:perform_later)
+
+ 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/models/email_template_spec.rb b/spec/models/email_template_spec.rb
index 8cc9e667f..1c7051b91 100644
--- a/spec/models/email_template_spec.rb
+++ b/spec/models/email_template_spec.rb
@@ -92,11 +92,11 @@ RSpec.describe EmailTemplate do
create(:email_template, :layout, body: 'Global {{ content_for_layout }}')
account_template = create(:email_template, :layout, account: account, body: 'Account {{ content_for_layout }}')
- expect(described_class.branded_layout_for(inbox: inbox, account: account, locale: :en)).to eq(account_template)
+ expect(described_class.branded_layout_for(inbox: inbox, account: account, locale: :en).id).to eq(account_template.id)
inbox_template = create(:email_template, :layout, account: account, inbox: inbox, body: 'Inbox {{ content_for_layout }}')
- expect(described_class.branded_layout_for(inbox: inbox, account: account, locale: :en)).to eq(inbox_template)
+ expect(described_class.branded_layout_for(inbox: inbox, account: account, locale: :en).id).to eq(inbox_template.id)
end
end
diff --git a/spec/services/whatsapp/health_service_spec.rb b/spec/services/whatsapp/health_service_spec.rb
new file mode 100644
index 000000000..57739f409
--- /dev/null
+++ b/spec/services/whatsapp/health_service_spec.rb
@@ -0,0 +1,262 @@
+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/v24\.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/v24\.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 with the minimum supported API version' do
+ service.fetch_health_status
+
+ expect(
+ a_request(:get, %r{graph\.facebook\.com/v24\.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
+
+ it 'uses a newer configured API version' do
+ allow(GlobalConfigService).to receive(:load).with('WHATSAPP_API_VERSION', 'v22.0').and_return('v25.0')
+ stub_request(:get, %r{graph\.facebook\.com/v25\.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/v25\.0/test_waba_id})
+ .to_return(status: 200, body: business_account_response.to_json, headers: { 'Content-Type' => 'application/json' })
+
+ service.fetch_health_status
+
+ expect(a_request(:get, %r{graph\.facebook\.com/v25\.0/test_phone_number_id})).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/v24\.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 a newer health check finishes first' do
+ let(:previous_health) { { quality_rating: 'GREEN', status: 'CONNECTED' } }
+ let(:newer_health) { { quality_rating: 'GREEN', status: 'CONNECTED' } }
+ let(:phone_health_response) { super().merge('quality_rating' => 'YELLOW') }
+
+ it 'does not overwrite the newer successful snapshot' do
+ attempted_at = Time.zone.parse('2026-07-21 12:00:00')
+ newer_attempted_at = attempted_at + 1.minute
+ expect(Rails.logger).not_to receive(:warn)
+ stub_request(:get, %r{graph\.facebook\.com/v24\.0/test_phone_number_id}).to_return do
+ channel.update_columns( # rubocop:disable Rails/SkipsModelValidations
+ phone_number_health: newer_health,
+ phone_number_health_checked_at: newer_attempted_at,
+ phone_number_health_error: nil
+ )
+ { status: 200, body: phone_health_response.to_json, headers: { 'Content-Type' => 'application/json' } }
+ end
+
+ travel_to(attempted_at) { service.sync_health_status! }
+ channel.reload
+
+ expect(channel.phone_number_health).to eq('quality_rating' => 'GREEN', 'status' => 'CONNECTED')
+ expect(channel.phone_number_health_checked_at).to eq(newer_attempted_at)
+ expect(channel.phone_number_health_error).to be_nil
+ end
+
+ it 'does not replace the newer successful snapshot with an older error' do
+ attempted_at = Time.zone.parse('2026-07-21 12:00:00')
+ newer_attempted_at = attempted_at + 1.minute
+ stub_request(:get, %r{graph\.facebook\.com/v24\.0/test_phone_number_id}).to_return do
+ channel.update_columns( # rubocop:disable Rails/SkipsModelValidations
+ phone_number_health: newer_health,
+ phone_number_health_checked_at: newer_attempted_at,
+ phone_number_health_error: nil
+ )
+ {
+ status: 400,
+ body: { error: { message: 'Older request failed' } }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ }
+ end
+
+ travel_to(attempted_at) do
+ expect { service.sync_health_status! }.to raise_error(described_class::ApiError, 'Older request failed')
+ end
+ channel.reload
+
+ expect(channel.phone_number_health).to eq('quality_rating' => 'GREEN', 'status' => 'CONNECTED')
+ expect(channel.phone_number_health_checked_at).to eq(newer_attempted_at)
+ expect(channel.phone_number_health_error).to be_nil
+ 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
+
+ context 'when only messaging capacity changes for an already risky number' do
+ let(:previous_health) { { quality_rating: 'YELLOW', status: 'CONNECTED', messaging_limit_tier: 'TIER_250' } }
+ let(:phone_health_response) do
+ super().merge(
+ 'quality_rating' => 'YELLOW',
+ 'whatsapp_business_manager_messaging_limit' => 'TIER_2K'
+ )
+ end
+
+ it 'does not log another risky transition' do
+ expect(Rails.logger).not_to receive(:warn)
+
+ service.sync_health_status!
+ end
+ end
+ end
+end