feat(whatsapp): add webhook registration and status endpoints (#13551)

## Description

Adds webhook configuration management for WhatsApp Cloud API channels,
allowing administrators to check webhook status and register webhooks
directly from Chatwoot without accessing Meta Business Manager.

## Type of change

- [ ] New feature (non-breaking change which adds functionality)


## Screenshots

<img width="1130" height="676" alt="Screenshot 2026-03-05 at 7 04 18 PM"
src="https://github.com/user-attachments/assets/f5dcd9dd-8827-42c5-a52b-1024012703c2"
/>
<img width="1101" height="651" alt="Screenshot 2026-03-05 at 7 04 29 PM"
src="https://github.com/user-attachments/assets/e0bd59f9-2a90-4f24-87c0-b79f21e721ee"
/>



## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
Tanmay Deep Sharma
2026-03-16 12:48:16 +05:30
committed by GitHub
parent 28bf9fa5f9
commit a452ce9e84
9 changed files with 194 additions and 45 deletions

View File

@@ -0,0 +1,55 @@
module Api::V1::Accounts::Concerns::WhatsappHealthManagement
extend ActiveSupport::Concern
included do
skip_before_action :check_authorization, only: [:health, :register_webhook]
before_action :check_admin_authorization?, only: [:register_webhook]
before_action :validate_whatsapp_cloud_channel, only: [:health, :register_webhook]
end
def sync_templates
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
trigger_template_sync
render status: :ok, json: { message: 'Template sync initiated successfully' }
rescue StandardError => e
render status: :internal_server_error, json: { error: e.message }
end
def health
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
render json: health_data
rescue StandardError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
def register_webhook
Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback
render json: { message: 'Webhook registered successfully' }, status: :ok
rescue StandardError => e
Rails.logger.error "[INBOX WEBHOOK] Webhook registration failed: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
private
def validate_whatsapp_cloud_channel
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
end
def whatsapp_channel?
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
end
def trigger_template_sync
if @inbox.whatsapp?
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
elsif @inbox.twilio? && @inbox.channel.whatsapp?
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
end
end
end

View File

@@ -4,8 +4,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
before_action :fetch_agent_bot, only: [:set_agent_bot] before_action :fetch_agent_bot, only: [:set_agent_bot]
before_action :validate_limit, only: [:create] before_action :validate_limit, only: [:create]
# we are already handling the authorization in fetch inbox # we are already handling the authorization in fetch inbox
before_action :check_authorization, except: [:show, :health] before_action :check_authorization, except: [:show]
before_action :validate_whatsapp_cloud_channel, only: [:health]
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
def index def index
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] })) @inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
@@ -70,23 +71,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') } render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
end end
def sync_templates
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
trigger_template_sync
render status: :ok, json: { message: 'Template sync initiated successfully' }
rescue StandardError => e
render status: :internal_server_error, json: { error: e.message }
end
def health
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
render json: health_data
rescue StandardError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
private private
def fetch_inbox def fetch_inbox
@@ -98,12 +82,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot] @agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
end end
def validate_whatsapp_cloud_channel
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
end
def create_channel def create_channel
return unless allowed_channel_types.include?(permitted_params[:channel][:type]) return unless allowed_channel_types.include?(permitted_params[:channel][:type])
@@ -200,18 +178,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
def get_channel_attributes(channel_type) def get_channel_attributes(channel_type)
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : [] channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
end end
def whatsapp_channel?
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
end
def trigger_template_sync
if @inbox.whatsapp?
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
elsif @inbox.twilio? && @inbox.channel.whatsapp?
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
end
end
end end
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController') Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')

View File

@@ -9,6 +9,10 @@ class InboxHealthAPI extends ApiClient {
getHealthStatus(inboxId) { getHealthStatus(inboxId) {
return axios.get(`${this.url}/${inboxId}/health`); return axios.get(`${this.url}/${inboxId}/health`);
} }
registerWebhook(inboxId) {
return axios.post(`${this.url}/${inboxId}/register_webhook`);
}
} }
export default new InboxHealthAPI(); export default new InboxHealthAPI();

View File

@@ -685,6 +685,16 @@
"SANDBOX": "Sandbox", "SANDBOX": "Sandbox",
"LIVE": "Live" "LIVE": "Live"
} }
},
"WEBHOOK": {
"TITLE": "Webhook Configuration",
"DESCRIPTION": "Webhook URL is required for your WhatsApp Business Account to receive messages from customers",
"ACTION_REQUIRED": "Webhook not configured",
"REGISTER_BUTTON": "Register Webhook",
"REGISTER_SUCCESS": "Webhook registered successfully",
"REGISTER_ERROR": "Failed to register webhook. Please try again.",
"CONFIGURED_SUCCESS": "Webhook configured successfully",
"URL_MISMATCH": "Webhook URL mismatch"
} }
}, },
"SETTINGS": "Settings", "SETTINGS": "Settings",

View File

@@ -99,6 +99,7 @@ export default {
healthData: null, healthData: null,
isLoadingHealth: false, isLoadingHealth: false,
healthError: null, healthError: null,
isRegisteringWebhook: false,
widgetBubblePosition: 'right', widgetBubblePosition: 'right',
widgetBubbleType: 'standard', widgetBubbleType: 'standard',
widgetBubbleLauncherTitle: '', widgetBubbleLauncherTitle: '',
@@ -424,6 +425,23 @@ export default {
this.isLoadingHealth = false; this.isLoadingHealth = false;
} }
}, },
async registerWebhook() {
if (!this.inbox) return;
try {
this.isRegisteringWebhook = true;
await InboxHealthAPI.registerWebhook(this.inbox.id);
useAlert(this.$t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.REGISTER_SUCCESS'));
await this.fetchHealthData();
} catch (error) {
useAlert(
error.message ||
this.$t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.REGISTER_ERROR')
);
} finally {
this.isRegisteringWebhook = false;
}
},
handleFeatureFlag(e) { handleFeatureFlag(e) {
this.selectedFeatureFlags = this.toggleInput( this.selectedFeatureFlags = this.toggleInput(
this.selectedFeatureFlags, this.selectedFeatureFlags,
@@ -1162,7 +1180,11 @@ export default {
<BotConfiguration :inbox="inbox" /> <BotConfiguration :inbox="inbox" />
</div> </div>
<div v-if="selectedTabKey === 'whatsapp-health'"> <div v-if="selectedTabKey === 'whatsapp-health'">
<AccountHealth :health-data="healthData" /> <AccountHealth
:health-data="healthData"
:is-registering-webhook="isRegisteringWebhook"
@register-webhook="registerWebhook"
/>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -10,8 +10,14 @@ const props = defineProps({
type: Object, type: Object,
default: null, default: null,
}, },
isRegisteringWebhook: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits(['registerWebhook']);
const { t } = useI18n(); const { t } = useI18n();
const QUALITY_COLORS = { const QUALITY_COLORS = {
@@ -133,6 +139,28 @@ const formatModeDisplay = mode =>
const getModeStatusTextColor = mode => MODE_COLORS[mode] || 'text-n-slate-12'; const getModeStatusTextColor = mode => MODE_COLORS[mode] || 'text-n-slate-12';
const getStatusTextColor = status => STATUS_COLORS[status] || 'text-n-slate-12'; const getStatusTextColor = status => STATUS_COLORS[status] || 'text-n-slate-12';
const showWebhookSection = computed(
() => props.healthData?.webhook_configuration !== undefined
);
const webhookUrl = computed(
() =>
props.healthData?.webhook_configuration?.whatsapp_business_account ||
props.healthData?.webhook_configuration?.application
);
const webhookConfigured = computed(() => !!webhookUrl.value);
const webhookUrlMismatch = computed(
() =>
webhookConfigured.value &&
webhookUrl.value !== props.healthData?.expected_webhook_url
);
const handleRegisterWebhook = () => {
emit('registerWebhook');
};
</script> </script>
<template> <template>
@@ -211,6 +239,55 @@ const getStatusTextColor = status => STATUS_COLORS[status] || 'text-n-slate-12';
}}</span> }}</span>
</div> </div>
</div> </div>
<!-- Webhook configuration card -->
<div
v-if="showWebhookSection"
class="flex flex-col gap-2 p-4 rounded-lg border border-n-weak bg-n-solid-1"
>
<div class="flex gap-2 items-center">
<span class="text-body-main font-medium text-n-slate-11">
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.TITLE') }}
</span>
<Icon
v-tooltip.top="t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.DESCRIPTION')"
icon="i-lucide-info"
class="flex-shrink-0 w-4 h-4 cursor-help text-n-slate-9"
/>
</div>
<div class="flex items-center justify-between gap-3">
<span
v-if="webhookConfigured && !webhookUrlMismatch"
class="inline-flex items-center gap-1.5 px-2 py-0.5 min-h-6 text-label-small rounded-md bg-n-alpha-2 text-n-teal-11"
>
<Icon icon="i-lucide-check-circle" class="w-3.5 h-3.5" />
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.CONFIGURED_SUCCESS') }}
</span>
<span
v-else
class="inline-flex items-center gap-1.5 px-2 py-0.5 min-h-6 text-label-small rounded-md bg-n-alpha-2 text-n-amber-11"
>
<Icon icon="i-lucide-alert-triangle" class="w-3.5 h-3.5" />
{{
webhookUrlMismatch
? t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.URL_MISMATCH')
: t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.ACTION_REQUIRED')
}}
</span>
<ButtonV4
v-if="!webhookConfigured || webhookUrlMismatch"
sm
solid
blue
:loading="isRegisteringWebhook"
:disabled="isRegisteringWebhook"
class="flex-shrink-0"
@click="handleRegisterWebhook"
>
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.WEBHOOK.REGISTER_BUTTON') }}
</ButtonV4>
</div>
</div>
</div> </div>
<div v-else class="pt-8"> <div v-else class="pt-8">

View File

@@ -39,11 +39,11 @@ class Whatsapp::HealthService
def health_fields def health_fields
%w[ %w[
id
quality_rating quality_rating
messaging_limit_tier messaging_limit_tier
code_verification_status code_verification_status
account_mode account_mode
id
display_phone_number display_phone_number
name_status name_status
verified_name verified_name
@@ -68,6 +68,7 @@ class Whatsapp::HealthService
def format_health_response(response) def format_health_response(response)
{ {
id: response['id'],
display_phone_number: response['display_phone_number'], display_phone_number: response['display_phone_number'],
verified_name: response['verified_name'], verified_name: response['verified_name'],
name_status: response['name_status'], name_status: response['name_status'],
@@ -75,10 +76,20 @@ class Whatsapp::HealthService
messaging_limit_tier: response['messaging_limit_tier'], messaging_limit_tier: response['messaging_limit_tier'],
account_mode: response['account_mode'], account_mode: response['account_mode'],
code_verification_status: response['code_verification_status'], code_verification_status: response['code_verification_status'],
webhook_configuration: response['webhook_configuration'],
expected_webhook_url: build_expected_webhook_url,
throughput: response['throughput'], throughput: response['throughput'],
last_onboarded_time: response['last_onboarded_time'], last_onboarded_time: response['last_onboarded_time'],
platform_type: response['platform_type'], platform_type: response['platform_type'],
certificate: response['certificate'],
business_id: @channel.provider_config['business_account_id'] business_id: @channel.provider_config['business_account_id']
} }
end end
def build_expected_webhook_url
frontend_url = ENV.fetch('FRONTEND_URL', nil)
return nil if frontend_url.blank?
"#{frontend_url}/webhooks/whatsapp/#{@channel.phone_number}"
end
end end

View File

@@ -1,9 +1,9 @@
class Whatsapp::WebhookSetupService class Whatsapp::WebhookSetupService
def initialize(channel, waba_id, access_token) def initialize(channel, waba_id = nil, access_token = nil)
@channel = channel @channel = channel
@waba_id = waba_id @waba_id = waba_id || channel.provider_config['business_account_id']
@access_token = access_token @access_token = access_token || channel.provider_config['api_key']
@api_client = Whatsapp::FacebookApiClient.new(access_token) @api_client = Whatsapp::FacebookApiClient.new(@access_token)
end end
def perform def perform
@@ -17,6 +17,11 @@ class Whatsapp::WebhookSetupService
setup_webhook setup_webhook
end end
def register_callback
validate_parameters!
setup_webhook
end
private private
def validate_parameters! def validate_parameters!
@@ -33,8 +38,6 @@ class Whatsapp::WebhookSetupService
store_pin(pin) store_pin(pin)
rescue StandardError => e rescue StandardError => e
Rails.logger.warn("[WHATSAPP] Phone registration failed but continuing: #{e.message}") Rails.logger.warn("[WHATSAPP] Phone registration failed but continuing: #{e.message}")
# Continue with webhook setup even if registration fails
# This is just a warning, not a blocking error
end end
def fetch_or_create_pin def fetch_or_create_pin

View File

@@ -218,6 +218,7 @@ Rails.application.routes.draw do
delete :avatar, on: :member delete :avatar, on: :member
post :sync_templates, on: :member post :sync_templates, on: :member
get :health, on: :member get :health, on: :member
post :register_webhook, on: :member
if ChatwootApp.enterprise? if ChatwootApp.enterprise?
resource :conference, only: %i[create destroy], controller: 'conference' do resource :conference, only: %i[create destroy], controller: 'conference' do
get :token, on: :member get :token, on: :member