feat: monitor whatsapp phone number health (#15100)

Adds scheduled WhatsApp Cloud API phone-number health synchronization
and expands the Account Health page with phone, capacity,
business-account, webhook, coexistence, and recovery details.
Authorization failures now guide administrators to the appropriate
Configuration flow without exposing technical Meta error codes.

Fixes
[CW-7621](https://linear.app/chatwoot/issue/CW-7621/store-whatsapp-phone-number-health-status)

**Preview**

<img width="2594" height="1676" alt="CleanShot 2026-07-22 at 10 55
01@2x"
src="https://github.com/user-attachments/assets/de606cb4-5682-4178-87e9-c18752d299b5"
/>

<img width="2576" height="1506" alt="CleanShot 2026-07-22 at 10 55
08@2x"
src="https://github.com/user-attachments/assets/4eb1810f-be12-4fbc-bcb0-9e2906785c48"
/>


<img width="1748" height="1150" alt="CleanShot 2026-07-22 at 11 10
05@2x"
src="https://github.com/user-attachments/assets/64f5be5a-c127-4372-889f-d392947c13b8"
/>



### How to test

1. Open **Settings → Inboxes → a WhatsApp Cloud API inbox → Account
Health**.
2. Confirm the page shows separate Phone number, Health and capacity,
Business account, and Webhook configuration sections.
3. Confirm the configured webhook URL can be copied and the expected URL
appears only when it differs.
4. For a coexistence number, confirm **Coexistence · Active** appears;
confirm it is hidden for standard Cloud API numbers.
5. With an invalid Embedded Signup token, confirm the page asks to
refresh the WhatsApp connection and **Go to Configuration** opens the
Configuration tab.
6. With an invalid manually configured token, confirm the page asks the
administrator to verify or replace the access token.
7. Confirm authorization states do not display Meta error codes or the
manual-migration recommendation.

### What changed

- Persists the latest successful phone health snapshot, check time, and
most recent error while retaining the last successful data after a
failed refresh.
- Refreshes stale active Cloud API channels every six hours through
low-priority jobs.
- Fetches phone, WABA, business portfolio, webhook, and coexistence
details.
- Uses Meta's current `whatsapp_business_manager_messaging_limit` field
while preserving the existing UI response key.
- Classifies authorization failures for setup-specific recovery guidance
and logs new risky quality/status transitions.
- Adds focused service, scheduler, job, trigger, and API coverage.

Internal alerts, throttling, automatic inbox disablement, and customer
notifications remain outside this PR and are tracked separately in
CW-7622.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
This commit is contained in:
Muhsin Keloth
2026-07-24 12:00:37 +04:00
committed by GitHub
parent 1bff2d86ed
commit d644c207f0
19 changed files with 1282 additions and 165 deletions

View File

@@ -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