diff --git a/app/controllers/api/v1/accounts/whatsapp/manual_setup_controller.rb b/app/controllers/api/v1/accounts/whatsapp/manual_setup_controller.rb
new file mode 100644
index 000000000..b8c225017
--- /dev/null
+++ b/app/controllers/api/v1/accounts/whatsapp/manual_setup_controller.rb
@@ -0,0 +1,78 @@
+class Api::V1::Accounts::Whatsapp::ManualSetupController < Api::V1::Accounts::BaseController
+ before_action :authorize_create, only: [:preview, :connect]
+ before_action :fetch_inbox, only: [:webhook_status, :setup_webhook]
+
+ def preview
+ render json: validation_service.perform
+ rescue StandardError => e
+ render_setup_error(e)
+ end
+
+ def connect
+ setup = Whatsapp::ManualSetupService.new(account: Current.account, **connect_params.to_h.symbolize_keys).perform
+ render json: connection_response(setup), status: :created
+ rescue CustomExceptions::Inbox::LimitExceeded => e
+ render_error_response(e)
+ rescue StandardError => e
+ render_setup_error(e)
+ end
+
+ def webhook_status
+ render json: Whatsapp::ManualWebhookStatusService.new(@inbox.channel).perform
+ rescue StandardError => e
+ render_setup_error(e)
+ end
+
+ def setup_webhook
+ channel = @inbox.channel
+ Whatsapp::WebhookSetupService.new(channel).register_callback
+ render json: Whatsapp::ManualWebhookStatusService.new(channel.reload).perform
+ rescue StandardError => e
+ render_setup_error(e)
+ end
+
+ private
+
+ def authorize_create
+ authorize ::Inbox, :create?
+ end
+
+ def fetch_inbox
+ @inbox = Current.account.inboxes.find(params[:inbox_id])
+ authorize @inbox, :update?
+ channel = @inbox.channel
+ return if channel.is_a?(Channel::Whatsapp) && channel.provider_config['source'] == 'manual_setup_v2'
+
+ raise ActiveRecord::RecordNotFound
+ end
+
+ def validation_service
+ Whatsapp::ManualSetupValidationService.new(**connection_params.to_h.symbolize_keys)
+ end
+
+ def connection_params
+ params.permit(:waba_id, :phone_number_id, :access_token)
+ end
+
+ def connect_params
+ params.permit(:waba_id, :phone_number_id, :access_token, :inbox_name)
+ end
+
+ def connection_response(setup)
+ channel = setup.channel.reload
+ {
+ id: channel.inbox.id,
+ name: channel.inbox.name,
+ number_access: true,
+ template_access: true,
+ webhook_setup: setup.webhook_setup?,
+ webhook_verified: channel.webhook_verified_at.present?,
+ webhook_error: setup.webhook_error
+ }
+ end
+
+ def render_setup_error(error)
+ Rails.logger.error "[WHATSAPP MANUAL SETUP] account_id=#{Current.account.id} error=#{error.class}: #{error.message}"
+ render json: { message: error.message }, status: :unprocessable_entity
+ end
+end
diff --git a/app/controllers/concerns/meta_token_verify_concern.rb b/app/controllers/concerns/meta_token_verify_concern.rb
index 42fe918cc..eb20ecdca 100644
--- a/app/controllers/concerns/meta_token_verify_concern.rb
+++ b/app/controllers/concerns/meta_token_verify_concern.rb
@@ -9,6 +9,7 @@ module MetaTokenVerifyConcern
def verify
service = is_a?(Webhooks::WhatsappController) ? 'whatsapp' : 'instagram'
if valid_token?(params['hub.verify_token'])
+ mark_webhook_verified if respond_to?(:mark_webhook_verified, true)
Rails.logger.info("#{service.capitalize} webhook verified")
render json: params['hub.challenge']
else
diff --git a/app/controllers/webhooks/whatsapp_controller.rb b/app/controllers/webhooks/whatsapp_controller.rb
index ee71f3c92..eecc0dac3 100644
--- a/app/controllers/webhooks/whatsapp_controller.rb
+++ b/app/controllers/webhooks/whatsapp_controller.rb
@@ -22,6 +22,14 @@ class Webhooks::WhatsappController < ActionController::API
token == whatsapp_webhook_verify_token if whatsapp_webhook_verify_token.present?
end
+ def mark_webhook_verified
+ channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number])
+ # The verification callback must not trigger remote provider validation.
+ # rubocop:disable Rails/SkipsModelValidations
+ channel&.update_column(:webhook_verified_at, Time.current)
+ # rubocop:enable Rails/SkipsModelValidations
+ end
+
def meta_app_secrets
[
*channel_meta_app_secrets(whatsapp_channel),
diff --git a/app/javascript/dashboard/api/channel/whatsappChannel.js b/app/javascript/dashboard/api/channel/whatsappChannel.js
index 8f51f4878..e5e59f221 100644
--- a/app/javascript/dashboard/api/channel/whatsappChannel.js
+++ b/app/javascript/dashboard/api/channel/whatsappChannel.js
@@ -16,6 +16,26 @@ class WhatsappChannel extends ApiClient {
inbox_id: inboxId,
});
}
+
+ previewManualSetup(params) {
+ return axios.post(`${this.baseUrl()}/whatsapp/manual/preview`, params);
+ }
+
+ connectManualSetup(params) {
+ return axios.post(`${this.baseUrl()}/whatsapp/manual/connect`, params);
+ }
+
+ getManualWebhookStatus(inboxId) {
+ return axios.get(
+ `${this.baseUrl()}/whatsapp/manual/${inboxId}/webhook_status`
+ );
+ }
+
+ setupManualWebhook(inboxId) {
+ return axios.post(
+ `${this.baseUrl()}/whatsapp/manual/${inboxId}/setup_webhook`
+ );
+ }
}
export default new WhatsappChannel();
diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json
index 5db865c06..779d8710e 100644
--- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json
+++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json
@@ -294,6 +294,111 @@
"WEBHOOK_URL": "Webhook URL",
"WEBHOOK_VERIFICATION_TOKEN": "Webhook Verification Token"
},
+ "MANUAL_SETUP": {
+ "HEADER": {
+ "TITLE": "Connect WhatsApp manually",
+ "DESCRIPTION": "Follow these steps to prepare your Meta account and connect your number to Chatwoot."
+ },
+ "PROGRESS": "Step {current} of {total}",
+ "STEPS": {
+ "1": { "LABEL": "Create Meta app" },
+ "2": { "LABEL": "Add phone number and get IDs" },
+ "3": { "LABEL": "Generate access token" },
+ "4": { "LABEL": "Review and connect" },
+ "5": { "LABEL": "Verify connection" }
+ },
+ "APP": {
+ "TITLE": "Create or select a Meta app",
+ "DESCRIPTION": "Your WhatsApp number must belong to a Meta app with the WhatsApp use case enabled.",
+ "ITEM_1": "Open {metaDevelopers} and sign in with an administrator account.",
+ "META_DEVELOPERS": "Meta Developers",
+ "ITEM_2": "Create a new app, or select the app you already use for this WhatsApp number.",
+ "ITEM_3": "Choose the option to connect with customers through WhatsApp.",
+ "ITEM_4": "Select the business portfolio that owns, or will own, the WhatsApp number.",
+ "VIDEO_TITLE": "Watch: Create a Meta app",
+ "VIDEO_DESCRIPTION": "This short walkthrough shows where to start a new app in Meta Developers."
+ },
+ "NUMBER": {
+ "TITLE": "Add your phone number and get its IDs",
+ "DESCRIPTION": "Add and verify the production number in your Meta app, then copy the two identifiers shown in API Setup.",
+ "ITEM_1": "Open the WhatsApp use case in your Meta app and choose API Setup.",
+ "ITEM_2": "In the Send and receive messages section, open the From selector.",
+ "ITEM_3": "Select an existing production number, or choose Add phone number.",
+ "ITEM_4": "Complete the WhatsApp business profile requested by Meta.",
+ "ITEM_5": "Verify the phone number using the OTP sent by SMS or voice call.",
+ "ITEM_6": "Copy the Phone Number ID and WhatsApp Business Account ID shown in API Setup.",
+ "VIDEO_TITLE": "Watch: Add a phone number and find its IDs",
+ "VIDEO_DESCRIPTION": "This walkthrough shows how to add or select a production number and copy the identifiers from Meta."
+ },
+ "TOKEN": {
+ "TITLE": "Generate a permanent access token",
+ "DESCRIPTION": "Create a Meta system user with access to your app and WhatsApp Business Account.",
+ "ITEM_1": "Open Meta Business Settings and go to Users → System users.",
+ "ITEM_2": "Create an admin system user, or select an existing one.",
+ "ITEM_3": "Assign your Meta app and WhatsApp Business Account to the system user.",
+ "ITEM_4": "Grant full control for the assigned WhatsApp assets.",
+ "ITEM_5": "Generate a token for your Meta app and set its expiration to Never.",
+ "ITEM_6": "Select whatsapp_business_management and whatsapp_business_messaging, then copy the token.",
+ "VIDEO_TITLE": "Watch: Generate a permanent access token",
+ "VIDEO_DESCRIPTION": "This walkthrough shows how to select your Meta app, choose a non-expiring token, and grant the required WhatsApp permissions.",
+ "WARNING": "Meta shows the token only once. Copy it before closing the dialog."
+ },
+ "DETAILS": {
+ "WABA_LABEL": "WhatsApp Business Account ID",
+ "WABA_PLACEHOLDER": "Enter WABA ID",
+ "WABA_HELP": "Copy this from the API Setup page in your Meta app.",
+ "PHONE_ID_LABEL": "Phone Number ID",
+ "PHONE_ID_PLACEHOLDER": "Enter Phone Number ID",
+ "PHONE_ID_HELP": "Copy the ID shown beside your production phone number in Meta.",
+ "TOKEN_LABEL": "Permanent access token",
+ "TOKEN_PLACEHOLDER": "Paste access token",
+ "TOKEN_HELP": "Use a permanent system-user token with WhatsApp messaging and management permissions."
+ },
+ "REVIEW": {
+ "TITLE": "Review and connect your number",
+ "DESCRIPTION": "These details were retrieved directly from Meta.",
+ "VERIFIED": "The number and token were verified with Meta.",
+ "BUSINESS_NAME": "Business name",
+ "PHONE_NUMBER": "Phone number",
+ "PHONE_ID": "Phone Number ID",
+ "WABA_ID": "WhatsApp Business Account ID",
+ "INBOX_NAME": "Inbox name",
+ "INBOX_NAME_HELP": "We generated this name from your verified Meta business name. You can change it."
+ },
+ "VERIFY": {
+ "TITLE": "Verify your connection",
+ "DESCRIPTION": "Chatwoot is checking number access and configuring your Meta webhook.",
+ "NUMBER_ACCESS": "Number access",
+ "TEMPLATE_ACCESS": "Template access",
+ "CALLBACK": "Webhook callback",
+ "SUBSCRIPTION": "Webhook subscription",
+ "WEBHOOK_URL": "Webhook URL",
+ "COPY": "Copy",
+ "COPY_SUCCESS": "Webhook URL copied to clipboard",
+ "COMPLETE": "Verified",
+ "PENDING": "Pending",
+ "SUCCESS": "Your WhatsApp number is connected and ready for agent assignment."
+ },
+ "ACTIONS": {
+ "EXIT": "Change provider",
+ "BACK": "Back",
+ "OPEN_META_APPS": "Open Meta Apps",
+ "APP_READY": "My Meta app is ready",
+ "NEXT": "Next",
+ "OPEN_BUSINESS_SETTINGS": "Open Meta Business Settings",
+ "SHOW_TOKEN": "Show token",
+ "HIDE_TOKEN": "Hide token",
+ "VERIFY_DETAILS": "Verify details",
+ "CONNECT": "Connect number",
+ "RETRY_WEBHOOK": "Retry webhook setup",
+ "CONTINUE": "Continue to add agents"
+ },
+ "ERRORS": {
+ "IDS_REQUIRED": "Enter the Phone Number ID and WhatsApp Business Account ID.",
+ "REQUIRED": "Enter the WABA ID, Phone Number ID, and permanent access token.",
+ "GENERIC": "We could not complete the WhatsApp setup. Check your details and try again."
+ }
+ },
"SUBMIT_BUTTON": "Create WhatsApp Channel",
"EMBEDDED_SIGNUP": {
"TITLE": "Quick setup with Meta",
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue
index ec89ec930..410683387 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/FinishSetup.vue
@@ -49,9 +49,10 @@ const hasDuplicateInstagramInbox = computed(() => {
});
const shouldShowWhatsAppWebhookDetails = computed(() => {
+ const source = currentInbox.value.provider_config?.source;
return (
isAWhatsAppCloudChannel.value &&
- currentInbox.value.provider_config?.source !== 'embedded_signup'
+ !['embedded_signup', 'manual_setup_v2'].includes(source)
);
});
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/InboxChannels.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/InboxChannels.vue
index 2cebedebe..c7e7c2da6 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/InboxChannels.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/InboxChannels.vue
@@ -69,7 +69,7 @@ const items = computed(() => {
:global-config="globalConfig"
:items="items"
/>
-
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue
index 3e822f796..0dc5f0c25 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Whatsapp.vue
@@ -5,6 +5,7 @@ import { useI18n, I18nT } from 'vue-i18n';
import Twilio from './Twilio.vue';
import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
import CloudWhatsapp from './CloudWhatsapp.vue';
+import WhatsappManualSetup from './WhatsappManualSetup.vue';
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
import { useAccount } from 'dashboard/composables/useAccount';
@@ -71,14 +72,21 @@ const shouldShowCloudWhatsapp = provider => {
);
};
+const isManualSetup = computed(
+ () =>
+ showConfiguration.value && shouldShowCloudWhatsapp(selectedProvider.value)
+);
+
const handleManualLinkClick = () => {
selectProvider(PROVIDER_TYPES.WHATSAPP_MANUAL);
};
-
+
diff --git a/app/models/channel/whatsapp.rb b/app/models/channel/whatsapp.rb
index 2a558cd13..32e168092 100644
--- a/app/models/channel/whatsapp.rb
+++ b/app/models/channel/whatsapp.rb
@@ -8,6 +8,7 @@
# phone_number :string not null
# provider :string default("default")
# provider_config :jsonb
+# webhook_verified_at :datetime
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
@@ -142,8 +143,9 @@ class Channel::Whatsapp < ApplicationRecord
end
def should_auto_setup_webhooks?
- # Only auto-setup webhooks for whatsapp_cloud provider with manual setup
- # Embedded signup calls setup_webhooks explicitly in EmbeddedSignupService
- provider == 'whatsapp_cloud' && provider_config['source'] != 'embedded_signup'
+ # Embedded signup and Manual V2 run webhook setup explicitly so their API
+ # responses can reflect the real result instead of swallowing callback errors.
+ explicitly_configured_sources = %w[embedded_signup manual_setup_v2]
+ provider == 'whatsapp_cloud' && explicitly_configured_sources.exclude?(provider_config['source'])
end
end
diff --git a/app/services/whatsapp/facebook_api_client.rb b/app/services/whatsapp/facebook_api_client.rb
index 7e74e8ac6..7528025c9 100644
--- a/app/services/whatsapp/facebook_api_client.rb
+++ b/app/services/whatsapp/facebook_api_client.rb
@@ -30,6 +30,54 @@ class Whatsapp::FacebookApiClient
handle_response(response, 'WABA phone numbers fetch failed')
end
+ def fetch_all_phone_numbers(waba_id)
+ phone_numbers = []
+ after_cursor = nil
+
+ loop do
+ response = HTTParty.get(
+ "#{BASE_URI}/#{@api_version}/#{waba_id}/phone_numbers",
+ headers: request_headers,
+ query: after_cursor.present? ? { after: after_cursor } : {}
+ )
+ data = handle_response(response, 'WABA phone numbers fetch failed')
+ phone_numbers.concat(data['data'] || [])
+ after_cursor = data.dig('paging', 'cursors', 'after') if data.dig('paging', 'next').present?
+ break if after_cursor.blank?
+ end
+
+ phone_numbers
+ end
+
+ def fetch_message_templates(waba_id)
+ response = HTTParty.get(
+ "#{BASE_URI}/#{@api_version}/#{waba_id}/message_templates",
+ headers: request_headers,
+ query: { limit: 1 }
+ )
+
+ handle_response(response, 'WABA message templates fetch failed')
+ end
+
+ def fetch_subscribed_apps(waba_id)
+ response = HTTParty.get(
+ "#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
+ headers: request_headers
+ )
+
+ handle_response(response, 'WABA webhook subscription fetch failed')
+ end
+
+ def fetch_phone_number(phone_number_id, fields: nil)
+ response = HTTParty.get(
+ "#{BASE_URI}/#{@api_version}/#{phone_number_id}",
+ headers: request_headers,
+ query: fields.present? ? { fields: fields } : {}
+ )
+
+ handle_response(response, 'Phone number fetch failed')
+ end
+
def debug_token(input_token)
response = HTTParty.get(
"#{BASE_URI}/#{@api_version}/debug_token",
diff --git a/app/services/whatsapp/manual_setup_service.rb b/app/services/whatsapp/manual_setup_service.rb
new file mode 100644
index 000000000..b3a8c765d
--- /dev/null
+++ b/app/services/whatsapp/manual_setup_service.rb
@@ -0,0 +1,57 @@
+class Whatsapp::ManualSetupService
+ attr_reader :channel, :webhook_error
+
+ def initialize(account:, waba_id:, phone_number_id:, access_token:, inbox_name: nil)
+ @account = account
+ @waba_id = waba_id
+ @phone_number_id = phone_number_id
+ @access_token = access_token
+ @inbox_name = inbox_name
+ end
+
+ def perform
+ preview = validate_setup
+ create_channel_and_inbox(preview)
+ setup_webhook
+ self
+ end
+
+ def webhook_setup?
+ webhook_error.blank?
+ end
+
+ private
+
+ def validate_setup
+ Whatsapp::ManualSetupValidationService.new(
+ waba_id: @waba_id,
+ phone_number_id: @phone_number_id,
+ access_token: @access_token
+ ).perform
+ end
+
+ def create_channel_and_inbox(preview)
+ ActiveRecord::Base.transaction do
+ @channel = @account.whatsapp_channels.create!(
+ phone_number: preview[:display_phone_number],
+ provider: 'whatsapp_cloud',
+ provider_config: {
+ api_key: @access_token,
+ phone_number_id: preview[:phone_number_id],
+ business_account_id: preview[:waba_id],
+ source: 'manual_setup_v2'
+ }
+ )
+ @account.inboxes.create!(
+ name: @inbox_name.to_s.strip.presence || preview[:suggested_inbox_name],
+ channel: @channel
+ )
+ end
+ end
+
+ def setup_webhook
+ Whatsapp::WebhookSetupService.new(@channel, @waba_id, @access_token).register_callback
+ rescue StandardError => e
+ @webhook_error = e.message
+ end
+end
diff --git a/app/services/whatsapp/manual_setup_validation_service.rb b/app/services/whatsapp/manual_setup_validation_service.rb
new file mode 100644
index 000000000..3ae6bad42
--- /dev/null
+++ b/app/services/whatsapp/manual_setup_validation_service.rb
@@ -0,0 +1,79 @@
+class Whatsapp::ManualSetupValidationService
+ LOG_PREFIX = '[WHATSAPP MANUAL SETUP]'.freeze
+
+ def initialize(waba_id:, phone_number_id:, access_token:)
+ @waba_id = waba_id
+ @phone_number_id = phone_number_id
+ @access_token = access_token
+ @api_client = Whatsapp::FacebookApiClient.new(access_token)
+ end
+
+ def perform
+ validate_parameters!
+ Rails.logger.info "#{LOG_PREFIX} Validation started waba_id=#{@waba_id} phone_number_id=#{@phone_number_id}"
+
+ phone_data = find_phone_data!
+ verify_uniqueness!(phone_data)
+ verify_template_access!
+
+ build_preview(phone_data)
+ end
+
+ private
+
+ def validate_parameters!
+ raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
+ raise ArgumentError, 'Phone Number ID is required' if @phone_number_id.blank?
+ raise ArgumentError, 'Access token is required' if @access_token.blank?
+ end
+
+ def find_phone_data!
+ phone_numbers = @api_client.fetch_all_phone_numbers(@waba_id)
+ returned_phone_number_ids = phone_numbers.filter_map { |phone| phone['id'] }.join(',')
+ Rails.logger.info "#{LOG_PREFIX} Meta returned #{phone_numbers.size} phone number(s) for waba_id=#{@waba_id} " \
+ "phone_number_ids=#{returned_phone_number_ids}"
+
+ phone_data = phone_numbers.find { |phone| phone['id'].to_s == @phone_number_id.to_s }
+ raise ArgumentError, 'This Phone Number ID does not belong to the WABA ID you entered.' if phone_data.blank?
+
+ Rails.logger.info "#{LOG_PREFIX} Matched phone_number_id=#{@phone_number_id} fields=#{phone_data.keys.sort.join(',')} " \
+ "code_verification_status=#{phone_data['code_verification_status'].inspect} " \
+ "name_status=#{phone_data['name_status'].inspect}"
+
+ phone_data
+ end
+
+ def verify_uniqueness!(phone_data)
+ phone_number = normalized_phone_number(phone_data['display_phone_number'])
+ raise ArgumentError, 'This WhatsApp number is already connected to another inbox.' if Channel::Whatsapp.exists?(phone_number: phone_number)
+
+ duplicate_phone_id = Channel::Whatsapp.exists?(["provider_config->>'phone_number_id' = ?", @phone_number_id.to_s])
+ raise ArgumentError, 'This Phone Number ID is already used by another WhatsApp inbox.' if duplicate_phone_id
+ end
+
+ def verify_template_access!
+ @api_client.fetch_message_templates(@waba_id)
+ rescue StandardError
+ raise ArgumentError,
+ 'The token can access the number but cannot access message templates. Generate a token with whatsapp_business_management permission.'
+ end
+
+ def build_preview(phone_data)
+ phone_number = normalized_phone_number(phone_data['display_phone_number'])
+ verified_name = phone_data['verified_name'].presence
+
+ {
+ verified_name: verified_name,
+ display_phone_number: phone_number,
+ phone_number_id: phone_data['id'].to_s,
+ waba_id: @waba_id.to_s,
+ template_access: true,
+ suggested_inbox_name: "#{verified_name || phone_number} WhatsApp"
+ }
+ end
+
+ def normalized_phone_number(phone_number)
+ digits = phone_number.to_s.gsub(/[^\d]/, '')
+ "+#{digits}"
+ end
+end
diff --git a/app/services/whatsapp/manual_webhook_status_service.rb b/app/services/whatsapp/manual_webhook_status_service.rb
new file mode 100644
index 000000000..79935ec4f
--- /dev/null
+++ b/app/services/whatsapp/manual_webhook_status_service.rb
@@ -0,0 +1,39 @@
+class Whatsapp::ManualWebhookStatusService
+ def initialize(channel)
+ @channel = channel
+ @api_client = Whatsapp::FacebookApiClient.new(channel.provider_config['api_key'])
+ end
+
+ def perform
+ callback_configured = callback_configured?
+
+ {
+ callback_verified: @channel.webhook_verified_at.present? || callback_configured,
+ callback_configured: callback_configured,
+ callback_url: callback_url,
+ subscription_verified: subscription_verified?
+ }
+ end
+
+ private
+
+ def callback_configured?
+ phone_number = @api_client.fetch_phone_number(
+ @channel.provider_config['phone_number_id'],
+ fields: 'webhook_configuration'
+ )
+ webhook_configuration = phone_number.fetch('webhook_configuration', {})
+
+ %w[override_callback_uri phone_number whatsapp_business_account application].any? do |key|
+ webhook_configuration[key] == callback_url
+ end
+ end
+
+ def subscription_verified?
+ @api_client.fetch_subscribed_apps(@channel.provider_config['business_account_id']).fetch('data', []).present?
+ end
+
+ def callback_url
+ "#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/whatsapp/#{@channel.phone_number}"
+ end
+end
diff --git a/config/routes.rb b/config/routes.rb
index c31400719..f85f8bf3a 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -345,6 +345,10 @@ Rails.application.routes.draw do
namespace :whatsapp do
resource :authorization, only: [:create]
+ post 'manual/preview', to: 'manual_setup#preview'
+ post 'manual/connect', to: 'manual_setup#connect'
+ get 'manual/:inbox_id/webhook_status', to: 'manual_setup#webhook_status'
+ post 'manual/:inbox_id/setup_webhook', to: 'manual_setup#setup_webhook'
end
resources :webhooks, only: [:index, :create, :update, :destroy]
diff --git a/db/migrate/20260711090000_add_webhook_verified_at_to_channel_whatsapp.rb b/db/migrate/20260711090000_add_webhook_verified_at_to_channel_whatsapp.rb
new file mode 100644
index 000000000..b01919a12
--- /dev/null
+++ b/db/migrate/20260711090000_add_webhook_verified_at_to_channel_whatsapp.rb
@@ -0,0 +1,5 @@
+class AddWebhookVerifiedAtToChannelWhatsapp < ActiveRecord::Migration[7.1]
+ def change
+ add_column :channel_whatsapp, :webhook_verified_at, :datetime
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 5e506a475..0f42a802e 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_06_215758) do
+ActiveRecord::Schema[7.1].define(version: 2026_07_11_090000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -623,6 +623,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_06_215758) do
t.datetime "updated_at", null: false
t.jsonb "message_templates", default: {}
t.datetime "message_templates_last_updated", precision: nil
+ t.datetime "webhook_verified_at"
t.index ["phone_number"], name: "index_channel_whatsapp_on_phone_number", unique: true
end
diff --git a/public/videos/whatsapp/manual-setup/add-phone-number-poster.jpg b/public/videos/whatsapp/manual-setup/add-phone-number-poster.jpg
new file mode 100644
index 000000000..e7d150b2f
Binary files /dev/null and b/public/videos/whatsapp/manual-setup/add-phone-number-poster.jpg differ
diff --git a/public/videos/whatsapp/manual-setup/add-phone-number.mp4 b/public/videos/whatsapp/manual-setup/add-phone-number.mp4
new file mode 100644
index 000000000..6b56fc3b1
Binary files /dev/null and b/public/videos/whatsapp/manual-setup/add-phone-number.mp4 differ
diff --git a/public/videos/whatsapp/manual-setup/create-meta-app-poster.jpg b/public/videos/whatsapp/manual-setup/create-meta-app-poster.jpg
new file mode 100644
index 000000000..4e9b48f1f
Binary files /dev/null and b/public/videos/whatsapp/manual-setup/create-meta-app-poster.jpg differ
diff --git a/public/videos/whatsapp/manual-setup/create-meta-app.mp4 b/public/videos/whatsapp/manual-setup/create-meta-app.mp4
new file mode 100644
index 000000000..947be71e0
Binary files /dev/null and b/public/videos/whatsapp/manual-setup/create-meta-app.mp4 differ
diff --git a/public/videos/whatsapp/manual-setup/generate-access-token-poster.jpg b/public/videos/whatsapp/manual-setup/generate-access-token-poster.jpg
new file mode 100644
index 000000000..f8ba582a1
Binary files /dev/null and b/public/videos/whatsapp/manual-setup/generate-access-token-poster.jpg differ
diff --git a/public/videos/whatsapp/manual-setup/generate-access-token.mp4 b/public/videos/whatsapp/manual-setup/generate-access-token.mp4
new file mode 100644
index 000000000..cb56915bd
Binary files /dev/null and b/public/videos/whatsapp/manual-setup/generate-access-token.mp4 differ
diff --git a/whatsapp_manual_connect_v2.md b/whatsapp_manual_connect_v2.md
new file mode 100644
index 000000000..195f9b556
--- /dev/null
+++ b/whatsapp_manual_connect_v2.md
@@ -0,0 +1,333 @@
+# WhatsApp Manual Connect V2
+
+## Goal
+
+Replace the current single-screen WhatsApp Cloud API form with a guided manual connection flow that helps non-technical administrators connect an already-onboarded WhatsApp number, validates the supplied Meta assets before creating the inbox, and confirms that Chatwoot can receive webhooks before continuing to agent assignment.
+
+This plan covers new manual WhatsApp inbox creation. It does not replace the separate flow for migrating or reconnecting an existing inbox.
+
+## Confirmed Product Decisions
+
+- Ask for only:
+ - WhatsApp Business Account ID (WABA ID).
+ - Phone Number ID.
+ - Permanent system-user access token.
+- Do not ask for or store the Meta App ID.
+- Do not ask for or store the Meta App Secret in this iteration.
+- Do not ask the user to type the WhatsApp display phone number.
+- Resolve the phone number and verified business name from Meta.
+- Use the verified business name as the default inbox name, with the resolved phone number as the fallback.
+- Let the user optionally edit the generated inbox name during review.
+- Configure the webhook automatically.
+- Add a real webhook verification step before the user continues to agent assignment.
+- Keep manual callback URL and verify-token instructions as a recovery path only when automatic setup fails.
+
+## Important Security Boundary
+
+Manual V2 will continue the current manual-flow behavior of accepting WhatsApp webhook payloads without validating `X-Hub-Signature-256`, because Chatwoot will not have the customer's Meta App Secret.
+
+The webhook verification described in this plan proves that Meta can reach the Chatwoot callback and that the app is subscribed to the required WhatsApp events. It does not prove the authenticity of every later webhook payload. Signature verification for customer-owned Meta apps is deferred work and should not be implied as complete in the UI.
+
+## Experience Structure
+
+Manual Connect V2 lives inside the standard `Create inbox` stage so the existing `Choose channel`, `Create inbox`, `Add agents`, and `Finish` wizard remains visible and consistent with other channel setups. The WhatsApp content pane uses its own focused five-step progress header, then continues to the existing `Add agents` and `Finish` stages after verification.
+
+The experience follows the task sequence used in Meta rather than presenting every field at once. Every administrator follows the Meta app step so the prerequisites are clear and the flow remains consistent. Guidance is built from Chatwoot-native instructions, direct links, and short walkthrough media; it does not copy another product's visual identity.
+
+### Step 1: Create Or Select A Meta App
+
+Guide the administrator to:
+
+- Open Meta Developers.
+- Create a new app or select the app already used by the number.
+- Enable the WhatsApp use case.
+- Select the business portfolio that owns or will own the number.
+
+Primary action: `My Meta app is ready`.
+
+### Step 2: Add The Phone Number And Get IDs
+
+Guide the administrator through the Meta app's WhatsApp API Setup screen:
+
+- Select or add the production phone number.
+- Complete the WhatsApp business profile.
+- Complete OTP verification.
+- Copy the Phone Number ID and WABA ID.
+
+Fields:
+
+- WABA ID.
+- Phone Number ID.
+
+Field behavior:
+
+- Use neutral placeholders such as `Enter WABA ID` and `Enter Phone Number ID`.
+- Explain where each value is found immediately below its field.
+- Keep entered values when validation fails.
+
+Primary action: `Next`.
+
+### Step 3: Generate A Permanent Access Token
+
+Guide the administrator through Meta Business Settings:
+
+- Create or select an admin system user.
+- Assign the Meta app and WhatsApp Business Account.
+- Grant full control for the assigned WhatsApp assets.
+- Generate a token that never expires.
+- Select `whatsapp_business_messaging` and `whatsapp_business_management`.
+
+Render the token as a password field with an explicit show/hide control and remind the administrator that Meta displays it only once.
+
+- Keep the entered value when validation fails.
+- Never place the access token in a query string, URL, analytics event, or client-side log.
+
+Primary action: `Verify details`.
+
+### Step 4: Review And Connect
+
+After validation, show Meta-resolved information:
+
+- Verified business name.
+- Display phone number.
+- Phone Number ID.
+- WABA ID.
+- Phone-number verification status.
+- Template access status.
+
+Inbox naming:
+
+- Default to ` WhatsApp`.
+- Fall back to ` WhatsApp` when Meta does not return a verified name.
+- Allow the administrator to edit the inbox name here.
+- Do not introduce a separate `WhatsApp number name` field.
+
+Primary action: `Connect number`.
+
+### Step 5: Verify Connection
+
+Create the inbox, configure the webhook, and show concrete connection checks:
+
+- `Number access` — Chatwoot can retrieve the exact Phone Number ID under the supplied WABA.
+- `Messaging access` — the token can access the APIs required for WhatsApp messaging.
+- `Template access` — the token can access WABA templates.
+- `Webhook callback` — Meta completed the callback challenge against Chatwoot.
+- `Webhook subscription` — the app is subscribed to at least `messages` and `smb_message_echoes`.
+
+Required checks before continuing:
+
+- Number access.
+- Messaging access.
+- Webhook callback.
+- Webhook subscription.
+
+Template access is required for the initial V2 scope because Chatwoot depends on template sync and template messaging. If product requirements later allow messaging-only inboxes, this can become a warning through a separate decision.
+
+Actions:
+
+- `Retry webhook setup` when callback or subscription setup fails.
+- `View manual instructions` as a recovery option after automatic setup fails.
+- `Continue to add agents` only after the required checks pass.
+
+Do not show a fully connected success state while a required check is red. Do not use optimistic green checkmarks before the corresponding backend verification completes.
+
+## Backend Validation
+
+Introduce a manual-setup validation service that is shared by the preview and create operations.
+
+Required validation sequence:
+
+1. Require WABA ID, Phone Number ID, and access token.
+2. Fetch `GET /{waba_id}/phone_numbers` using `Authorization: Bearer `.
+3. Follow pagination until the requested Phone Number ID is found or all pages are exhausted.
+4. Match the requested Phone Number ID exactly.
+5. Do not fall back to the first number in the WABA.
+6. Normalize the returned display phone number to Chatwoot's canonical E.164 representation.
+7. Require `code_verification_status == VERIFIED` for this already-onboarded-number flow.
+8. Check that no other `Channel::Whatsapp` uses the same normalized phone number.
+9. Check that no other `Channel::Whatsapp` has the same `provider_config.phone_number_id`.
+10. Verify template access with `GET /{waba_id}/message_templates` using the authorization header.
+11. Return a sanitized preview containing only the resolved number metadata and validation results.
+
+Do not return or echo the access token in the validation response.
+
+### Blockers
+
+- The token cannot access the WABA.
+- The requested Phone Number ID is not present under the WABA.
+- The phone number has not completed code verification.
+- The phone number is already connected to another Chatwoot inbox.
+- The Phone Number ID is already connected to another Chatwoot inbox.
+- The token cannot access messaging or templates.
+
+### Actionable Error Copy
+
+Prefer errors that tell the administrator what to fix:
+
+- `We could not access this WhatsApp Business Account with the provided token. Confirm that the system user has access to this WABA.`
+- `This Phone Number ID does not belong to the WABA ID you entered.`
+- `This WhatsApp number has not completed verification in Meta.`
+- `This WhatsApp number is already connected to another inbox.`
+- `This Phone Number ID is already used by another WhatsApp inbox.`
+- `The token can access the number but cannot access message templates. Generate a token with whatsapp_business_management permission.`
+
+Raw Meta errors may be logged for diagnostics, but the primary UI message should remain concise and actionable.
+
+## Connection API Shape
+
+Add account-scoped endpoints under the existing WhatsApp API namespace.
+
+### Preview
+
+`POST /api/v1/accounts/:account_id/whatsapp/manual/preview`
+
+Request:
+
+```json
+{
+ "waba_id": "...",
+ "phone_number_id": "...",
+ "access_token": "..."
+}
+```
+
+Response:
+
+```json
+{
+ "verified_name": "Acme",
+ "display_phone_number": "+15551234567",
+ "phone_number_id": "...",
+ "waba_id": "...",
+ "code_verified": true,
+ "template_access": true,
+ "suggested_inbox_name": "Acme WhatsApp"
+}
+```
+
+### Connect
+
+`POST /api/v1/accounts/:account_id/whatsapp/manual/connect`
+
+Request:
+
+```json
+{
+ "waba_id": "...",
+ "phone_number_id": "...",
+ "access_token": "...",
+ "inbox_name": "Acme WhatsApp"
+}
+```
+
+The connect operation must repeat the full validation. Do not trust the earlier browser preview as proof that the identifiers are still valid.
+
+Connection behavior:
+
+1. Re-run strict validation.
+2. Create the `Channel::Whatsapp` and inbox with source `manual_setup_v2`.
+3. Generate the webhook verify token through the existing channel behavior.
+4. Subscribe the token's app to the WABA.
+5. Configure the phone-level callback override.
+6. Return the created inbox and individual connection-check results.
+
+The V2 source must skip the existing automatic `after_commit` webhook callback so the explicit connection operation is the only setup attempt. This avoids duplicate Meta calls and lets the API return the real result to the UI.
+
+If database creation succeeds but webhook setup fails, retain the inbox in an incomplete state and return its ID with the failed checks. Do not delete a newly created channel after making Meta-side changes. Keep the user on the verification step and allow a safe retry.
+
+### Retry Webhook Setup
+
+`POST /api/v1/accounts/:account_id/inboxes/:inbox_id/whatsapp/webhook/setup`
+
+- Restrict the action to administrators.
+- Restrict it to WhatsApp Cloud API inboxes created through Manual V2.
+- Re-run WABA subscription and phone callback setup idempotently.
+- Return each webhook check independently.
+- Do not mutate unrelated inbox settings.
+
+### Webhook Status
+
+`GET /api/v1/accounts/:account_id/inboxes/:inbox_id/whatsapp/webhook/status`
+
+Return:
+
+- Callback challenge observed.
+- WABA subscription configured.
+- Required subscribed fields configured.
+- Last setup error in a sanitized form, if available.
+
+## Recording Webhook Verification
+
+Add `webhook_verified_at` to `channel_whatsapp`.
+
+When `Webhooks::WhatsappController#verify` receives the correct verify token:
+
+1. Return Meta's challenge as it does today.
+2. Record `webhook_verified_at` for the matched channel without triggering remote provider validation.
+
+The connection screen should not mark the callback as verified until this timestamp is present. A successful outbound Graph API call by itself is not enough evidence that Meta reached the Chatwoot endpoint.
+
+Use the existing WABA subscription and phone-level callback APIs rather than asking the user to configure the callback manually in the normal path.
+
+## Frontend Implementation
+
+- Replace `CloudWhatsapp.vue` for `provider=whatsapp_manual` with a new Composition API component such as `WhatsappManualSetup.vue`.
+- Render the manual guide inside the standard `Create inbox` content pane while preserving the existing provider picker and legacy component for unrelated providers.
+- Store field state in the component only; do not persist the access token to local storage.
+- Use existing `components-next` inputs, buttons, alerts, and status patterns.
+- Use Tailwind utility classes only.
+- When walkthrough media is available, prefer short muted MP4/WebM clips with a poster image and text instructions as the accessible fallback. Avoid GIFs because they are larger and provide weaker playback controls.
+- Add frontend copy only to `app/javascript/dashboard/i18n/locale/en/inboxMgmt.json`.
+- Route successful webhook verification to the existing `settings_inboxes_add_agents` page.
+- Update `FinishSetup.vue` so Manual V2 inboxes show the normal completed state instead of asking users to configure the callback again.
+- Preserve the current callback URL and verify-token display for legacy manual inboxes and as the V2 recovery view.
+
+## Backend Implementation Areas
+
+Expected files or responsibilities:
+
+- A manual setup controller under `Api::V1::Accounts::Whatsapp`.
+- A strict manual validation service.
+- Reuse or extend `Whatsapp::PhoneInfoService` without changing legacy fallback behavior for unrelated callers.
+- Reuse `Whatsapp::ChannelCreationService` where practical, while allowing the Manual V2 source and user-reviewed inbox name.
+- Reuse `Whatsapp::WebhookSetupService` for the actual subscription and callback override.
+- Extend `Whatsapp::FacebookApiClient` for paginated phone lookup and webhook-status reads where required.
+- Add account-scoped routes.
+- Add `webhook_verified_at` to the WhatsApp channel schema.
+- Check Enterprise overlays for affected controllers, serializers, routes, and inbox behavior before editing shared code.
+
+## Out Of Scope
+
+- Meta Embedded Signup changes.
+- Migrating or reconnecting an existing WhatsApp inbox.
+- Creating a new WABA or provisioning a new phone number inside Chatwoot.
+- Fresh WhatsApp Business App Coexistence onboarding.
+- Collecting Meta Business Portfolio ID.
+- Collecting or storing Meta App ID.
+- Collecting or storing Meta App Secret.
+- HMAC validation for customer-owned manual Meta apps.
+- Automatically repairing Meta business verification, billing, payment, quality, or restriction problems.
+- Changing agent assignment or the final inbox setup screens beyond the Manual V2 routing and success copy.
+
+## Rollout
+
+1. Test with an internal WABA and one already-onboarded production-like number.
+2. Verify inbound messages, outbound session messages, outbound templates, media, template sync, and webhook retry behavior.
+3. Pilot with one assisted customer.
+4. Compare setup completion and support-failure rates against the legacy form.
+5. Keep the existing Help Center article until the new screenshots and Meta navigation are verified against the released flow.
+
+## Acceptance Criteria
+
+- A user can complete setup without typing the display phone number.
+- A user does not need to invent an inbox name.
+- Meta App ID and App Secret are never requested or stored.
+- A mismatched WABA ID and Phone Number ID are blocked before inbox creation.
+- A duplicate Phone Number ID is blocked even when the display number differs.
+- The phone lookup never silently selects the first number in the WABA.
+- The access token is sent to Meta only through the authorization header.
+- Webhook setup happens automatically and its result is visible.
+- The user cannot continue to agent assignment until callback verification and required subscriptions pass.
+- A webhook failure can be retried without recreating the inbox.
+- The UI does not claim that webhook payload signatures are verified.
+- Legacy manual inboxes continue to work and retain their existing recovery instructions.