feat: Whatsapp embedded signup (#11612)

## Description

This PR introduces WhatsApp Embedded Signup functionality, enabling
users to connect their WhatsApp Business accounts through Meta's
streamlined OAuth flow without manual webhook configuration. This
significantly improves the user experience by automating the entire
setup process.

**Key Features:**

- Embedded signup flow using Facebook SDK and Meta's OAuth 2.0
- Automatic webhook registration and phone number configuration
- Enhanced provider selection UI with card-based design
- Real-time progress tracking during signup process
- Comprehensive error handling and user feedback


## Required Configuration

The following environment variables must be configured by administrators
before this feature can be used:
Super Admin Configuration (via
super_admin/app_config?config=whatsapp_embedded)

- `WHATSAPP_APP_ID`: The Facebook App ID for WhatsApp Business API
integration
- `WHATSAPP_CONFIGURATION_ID`: The Configuration ID for WhatsApp
Embedded Signup flow (obtained from Meta Developer Portal)
- `WHATSAPP_APP_SECRET`: The App Secret for WhatsApp Embedded Signup
flow (required for token exchange)
![Screenshot 2025-06-09 at 11 21
08 AM](https://github.com/user-attachments/assets/1615fb0d-27fc-4d9e-b193-9be7894ea93a)


## How Has This Been Tested?

#### Backend Tests (RSpec):

- Authentication validation for embedded signup endpoints
- Authorization code validation and error handling
- Missing business parameter validation
- Proper response format for configuration endpoint
- Unauthorized access prevention

#### Manual Test Cases:

- Complete embedded signup flow (happy path)
- Provider selection UI navigation
- Facebook authentication popup handling
- Error scenarios (cancelled auth, invalid business data, API failures)
- Configuration presence/absence behavior

## Related Screenshots:

![Screenshot 2025-06-09 at 7 48
18 PM](https://github.com/user-attachments/assets/34001425-df11-4d78-9424-334461e3178f)
![Screenshot 2025-06-09 at 7 48
22 PM](https://github.com/user-attachments/assets/c09f4964-3aba-4c39-9285-d1e8e37d0e33)
![Screenshot 2025-06-09 at 7 48
32 PM](https://github.com/user-attachments/assets/a34d5382-7a91-4e1c-906e-dc2d570c864a)
![Screenshot 2025-06-09 at 10 43
05 AM](https://github.com/user-attachments/assets/a15840d8-8223-4513-82e4-b08f23c95927)
![Screenshot 2025-06-09 at 10 42
56 AM](https://github.com/user-attachments/assets/8c345022-38b5-44c4-aba2-0cda81389c69)


Fixes
https://linear.app/chatwoot/issue/CW-2131/spec-for-whatsapp-cloud-channels-sign-in-with-facebook

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
This commit is contained in:
Tanmay Deep Sharma
2025-07-15 11:37:06 +07:00
committed by GitHub
parent 4378506a35
commit 61d10044a0
58 changed files with 2384 additions and 63 deletions

View File

@@ -11,4 +11,4 @@ class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::Bas
def fetch_hook
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'notion')
end
end
end

View File

@@ -18,4 +18,4 @@ class Api::V1::Accounts::Notion::AuthorizationsController < Api::V1::Accounts::O
render json: { success: false }, status: :unprocessable_entity
end
end
end
end

View File

@@ -0,0 +1,64 @@
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
before_action :validate_feature_enabled!
# POST /api/v1/accounts/:account_id/whatsapp/authorization
# Handles the embedded signup callback data from the Facebook SDK
def create
validate_embedded_signup_params!
channel = process_embedded_signup
render_success_response(channel.inbox)
rescue StandardError => e
render_error_response(e)
end
private
def process_embedded_signup
service = Whatsapp::EmbeddedSignupService.new(
account: Current.account,
code: params[:code],
business_id: params[:business_id],
waba_id: params[:waba_id],
phone_number_id: params[:phone_number_id]
)
service.perform
end
def render_success_response(inbox)
render json: {
success: true,
id: inbox.id,
name: inbox.name,
channel_type: 'whatsapp'
}
end
def render_error_response(error)
Rails.logger.error "[WHATSAPP AUTHORIZATION] Embedded signup error: #{error.message}"
Rails.logger.error error.backtrace.join("\n")
render json: {
success: false,
error: error.message
}, status: :unprocessable_entity
end
def validate_feature_enabled!
return if Current.account.feature_whatsapp_embedded_signup?
render json: {
success: false,
error: 'WhatsApp embedded signup is not enabled for this account'
}, status: :forbidden
end
def validate_embedded_signup_params!
missing_params = []
missing_params << 'code' if params[:code].blank?
missing_params << 'business_id' if params[:business_id].blank?
missing_params << 'waba_id' if params[:waba_id].blank?
return if missing_params.empty?
raise ArgumentError, "Required parameters are missing: #{missing_params.join(', ')}"
end
end

View File

@@ -67,6 +67,8 @@ class DashboardController < ActionController::Base
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH

View File

@@ -33,4 +33,4 @@ class Notion::CallbacksController < OauthCallbackController
def notion_redirect_uri
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/notion"
end
end
end

View File

@@ -39,8 +39,9 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET]
}
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])

View File

@@ -0,0 +1,14 @@
/* global axios */
import ApiClient from '../ApiClient';
class WhatsappChannel extends ApiClient {
constructor() {
super('whatsapp', { accountScoped: true });
}
createEmbeddedSignup(params) {
return axios.post(`${this.baseUrl()}/whatsapp/authorization`, params);
}
}
export default new WhatsappChannel();

View File

@@ -36,6 +36,7 @@ export const FEATURE_FLAGS = {
REPORT_V4: 'report_v4',
CHANNEL_INSTAGRAM: 'channel_instagram',
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
WHATSAPP_EMBEDDED_SIGNUP: 'whatsapp_embedded_signup',
CAPTAIN_V2: 'captain_integration_v2',
};

View File

@@ -222,10 +222,17 @@
"DESC": "Start supporting your customers via WhatsApp.",
"PROVIDERS": {
"LABEL": "API Provider",
"WHATSAPP_EMBEDDED": "WhatsApp Business",
"TWILIO": "Twilio",
"WHATSAPP_CLOUD": "WhatsApp Cloud",
"WHATSAPP_CLOUD_DESC": "Quick setup through Meta",
"TWILIO_DESC": "Connect via Twilio credentials",
"360_DIALOG": "360Dialog"
},
"SELECT_PROVIDER": {
"TITLE": "Select your API provider",
"DESCRIPTION": "Choose your WhatsApp provider. You can connect directly through Meta which requires no setup, or connect through Twilio using your account credentials."
},
"INBOX_NAME": {
"LABEL": "Inbox Name",
"PLACEHOLDER": "Please enter an inbox name",
@@ -264,6 +271,28 @@
"WEBHOOK_VERIFICATION_TOKEN": "Webhook Verification Token"
},
"SUBMIT_BUTTON": "Create WhatsApp Channel",
"EMBEDDED_SIGNUP": {
"TITLE": "Quick Setup with Meta",
"DESC": "You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
"BENEFITS": {
"TITLE": "Benefits of Embedded Signup:",
"EASY_SETUP": "No manual configuration required",
"SECURE_AUTH": "Secure OAuth based authentication",
"AUTO_CONFIG": "Automatic webhook and phone number configuration"
},
"SUBMIT_BUTTON": "Connect with WhatsApp Business",
"AUTH_PROCESSING": "Authenticating with Meta",
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
"PROCESSING": "Setting up your WhatsApp Business Account",
"LOADING_SDK": "Loading Facebook SDK...",
"CANCELLED": "WhatsApp Signup was cancelled",
"SUCCESS_TITLE": "WhatsApp Business Account Connected!",
"WAITING_FOR_AUTH": "Waiting for authentication...",
"INVALID_BUSINESS_DATA": "Invalid business data received from Facebook. Please try again.",
"SIGNUP_ERROR": "Signup error occurred",
"AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.",
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured"
},
"API": {
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
}

View File

@@ -47,6 +47,13 @@ export default {
this.currentInbox.provider === 'whatsapp_cloud'
);
},
// If the inbox is a whatsapp cloud inbox and the source is not embedded signup, then show the webhook details
shouldShowWhatsAppWebhookDetails() {
return (
this.isWhatsAppCloudInbox &&
this.currentInbox.provider_config?.source !== 'embedded_signup'
);
},
message() {
if (this.isATwilioInbox) {
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
@@ -66,7 +73,7 @@ export default {
)}`;
}
if (this.isWhatsAppCloudInbox) {
if (this.isWhatsAppCloudInbox && this.shouldShowWhatsAppWebhookDetails) {
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
'INBOX_MGMT.ADD.WHATSAPP.API_CALLBACK.SUBTITLE'
)}`;
@@ -113,8 +120,11 @@ export default {
:script="currentInbox.callback_webhook_url"
/>
</div>
<div v-if="isWhatsAppCloudInbox" class="w-[50%] max-w-[50%] ml-[25%]">
<p class="mt-8 font-medium text-n-slate-11">
<div
v-if="shouldShowWhatsAppWebhookDetails"
class="w-[50%] max-w-[50%] ml-[25%]"
>
<p class="mt-8 font-medium text-slate-700 dark:text-slate-200">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.API_CALLBACK.WEBHOOK_URL') }}
</p>
<woot-code lang="html" :script="currentInbox.callback_webhook_url" />

View File

@@ -86,6 +86,12 @@ export default {
selectedTabKey() {
return this.tabs[this.selectedTabIndex]?.key;
},
shouldShowWhatsAppConfiguration() {
return !!(
this.isAWhatsAppCloudChannel &&
this.inbox.provider_config?.source !== 'embedded_signup'
);
},
whatsAppAPIProviderName() {
if (this.isAWhatsAppCloudChannel) {
return this.$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD');
@@ -137,7 +143,7 @@ export default {
this.isALineChannel ||
this.isAPIInbox ||
(this.isAnEmailChannel && !this.inbox.provider) ||
this.isAWhatsAppChannel ||
this.shouldShowWhatsAppConfiguration ||
this.isAWebWidgetInbox
) {
visibleToAllChannelTabs = [
@@ -383,7 +389,7 @@ export default {
<template>
<div
class="flex-grow flex-shrink w-full min-w-0 pl-0 pr-0 overflow-auto settings"
class="overflow-auto flex-grow flex-shrink pr-0 pl-0 w-full min-w-0 settings"
>
<SettingIntroBanner
:header-image="inbox.avatarUrl"
@@ -405,7 +411,7 @@ export default {
/>
</woot-tabs>
</SettingIntroBanner>
<section class="w-full max-w-6xl mx-auto">
<section class="mx-auto w-full max-w-6xl">
<MicrosoftReauthorize v-if="microsoftUnauthorized" :inbox="inbox" />
<FacebookReauthorize v-if="facebookUnauthorized" :inbox="inbox" />
<GoogleReauthorize v-if="googleUnauthorized" :inbox="inbox" />
@@ -735,7 +741,7 @@ export default {
:business-name="businessName"
@update="toggleSenderNameType"
/>
<div class="flex flex-col items-start gap-2 mt-2">
<div class="flex flex-col gap-2 items-start mt-2">
<NextButton
ghost
blue

View File

@@ -1,48 +1,156 @@
<script>
import PageHeader from '../../SettingsSubPageHeader.vue';
<script setup>
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import Twilio from './Twilio.vue';
import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
import CloudWhatsapp from './CloudWhatsapp.vue';
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
export default {
components: {
PageHeader,
Twilio,
ThreeSixtyDialogWhatsapp,
CloudWhatsapp,
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const store = useStore();
const PROVIDER_TYPES = {
WHATSAPP: 'whatsapp',
TWILIO: 'twilio',
WHATSAPP_CLOUD: 'whatsapp_cloud',
WHATSAPP_EMBEDDED: 'whatsapp_embedded',
THREE_SIXTY_DIALOG: '360dialog',
};
const hasWhatsappAppId = computed(() => {
return (
window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
);
});
const isWhatsappEmbeddedSignupEnabled = computed(() => {
const accountId = route.params.accountId;
return store.getters['accounts/isFeatureEnabledonAccount'](
accountId,
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP
);
});
const selectedProvider = computed(() => route.query.provider);
const showProviderSelection = computed(() => !selectedProvider.value);
const showConfiguration = computed(() => Boolean(selectedProvider.value));
const availableProviders = computed(() => [
{
value: PROVIDER_TYPES.WHATSAPP,
label: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD'),
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD_DESC'),
icon: '/assets/images/dashboard/channels/whatsapp.png',
},
data() {
return {
provider: 'whatsapp_cloud',
};
{
value: PROVIDER_TYPES.TWILIO,
label: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO'),
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO_DESC'),
icon: '/assets/images/dashboard/channels/twilio.png',
},
]);
const selectProvider = providerValue => {
router.push({
name: route.name,
params: route.params,
query: { provider: providerValue },
});
};
const shouldShowEmbeddedSignup = provider => {
// Check if the feature is enabled for the account
if (!isWhatsappEmbeddedSignupEnabled.value) {
return false;
}
return (
(provider === PROVIDER_TYPES.WHATSAPP && hasWhatsappAppId.value) ||
provider === PROVIDER_TYPES.WHATSAPP_EMBEDDED
);
};
const shouldShowCloudWhatsapp = provider => {
// If embedded signup feature is enabled and app ID is configured, don't show cloud whatsapp
if (isWhatsappEmbeddedSignupEnabled.value && hasWhatsappAppId.value) {
return false;
}
// Show cloud whatsapp when:
// 1. Provider is whatsapp AND
// 2. Either no app ID is configured OR embedded signup feature is disabled
return provider === PROVIDER_TYPES.WHATSAPP;
};
</script>
<template>
<div
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
class="overflow-auto col-span-6 p-6 w-full h-full rounded-t-lg border border-b-0 border-n-weak bg-n-solid-1"
>
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.WHATSAPP.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.WHATSAPP.DESC')"
/>
<div class="flex-shrink-0 flex-grow-0">
<label>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.LABEL') }}
<select v-model="provider">
<option value="whatsapp_cloud">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD') }}
</option>
<option value="twilio">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO') }}
</option>
</select>
</label>
<div v-if="showProviderSelection">
<div class="mb-10 text-left">
<h1 class="mb-2 text-lg font-medium text-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.TITLE') }}
</h1>
<p class="text-sm leading-relaxed text-slate-11">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.DESCRIPTION') }}
</p>
</div>
<div class="flex gap-6 justify-start">
<div
v-for="provider in availableProviders"
:key="provider.value"
class="gap-6 px-5 py-6 w-96 rounded-2xl border transition-all duration-200 cursor-pointer border-n-weak hover:bg-n-slate-3"
@click="selectProvider(provider.value)"
>
<div class="flex justify-start mb-5">
<div
class="flex justify-center items-center rounded-full size-10 bg-n-alpha-2"
>
<img
:src="provider.icon"
:alt="provider.label"
class="object-contain size-[26px]"
/>
</div>
</div>
<div class="text-start">
<h3 class="mb-1.5 text-sm font-medium text-slate-12">
{{ provider.label }}
</h3>
<p class="text-sm text-slate-11">
{{ provider.description }}
</p>
</div>
</div>
</div>
</div>
<Twilio v-if="provider === 'twilio'" type="whatsapp" />
<ThreeSixtyDialogWhatsapp v-else-if="provider === '360dialog'" />
<CloudWhatsapp v-else />
<div v-else-if="showConfiguration">
<div class="px-6 py-5 rounded-2xl border bg-n-solid-2 border-n-weak">
<WhatsappEmbeddedSignup
v-if="shouldShowEmbeddedSignup(selectedProvider)"
/>
<CloudWhatsapp v-else-if="shouldShowCloudWhatsapp(selectedProvider)" />
<Twilio
v-else-if="selectedProvider === PROVIDER_TYPES.TWILIO"
type="whatsapp"
/>
<ThreeSixtyDialogWhatsapp
v-else-if="selectedProvider === PROVIDER_TYPES.THREE_SIXTY_DIALOG"
/>
<CloudWhatsapp v-else />
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,327 @@
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useStore } from 'vuex';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Icon from 'next/icon/Icon.vue';
import NextButton from 'next/button/Button.vue';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import { loadScript } from 'dashboard/helper/DOMHelpers';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
const store = useStore();
const router = useRouter();
const { t } = useI18n();
// State
const fbSdkLoaded = ref(false);
const isProcessing = ref(false);
const processingMessage = ref('');
const authCodeReceived = ref(false);
const authCode = ref(null);
const businessData = ref(null);
const isAuthenticating = ref(false);
// Computed
const whatsappIconPath = '/assets/images/dashboard/channels/whatsapp.png';
const benefits = computed(() => [
{
key: 'EASY_SETUP',
text: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.EASY_SETUP'),
},
{
key: 'SECURE_AUTH',
text: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.SECURE_AUTH'),
},
{
key: 'AUTO_CONFIG',
text: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.AUTO_CONFIG'),
},
]);
const showLoader = computed(() => isAuthenticating.value || isProcessing.value);
// Error handling
const handleSignupError = data => {
isProcessing.value = false;
authCodeReceived.value = false;
isAuthenticating.value = false;
const errorMessage =
data.error ||
data.message ||
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE');
useAlert(errorMessage);
};
const handleSignupCancellation = () => {
isProcessing.value = false;
authCodeReceived.value = false;
isAuthenticating.value = false;
};
const handleSignupSuccess = inboxData => {
isProcessing.value = false;
isAuthenticating.value = false;
if (inboxData && inboxData.id) {
useAlert(t('INBOX_MGMT.FINISH.MESSAGE'));
router.replace({
name: 'settings_inboxes_add_agents',
params: {
page: 'new',
inbox_id: inboxData.id,
},
});
} else {
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUCCESS_FALLBACK'));
router.replace({
name: 'settings_inbox_list',
});
}
};
// Signup flow
const completeSignupFlow = async businessDataParam => {
if (!authCodeReceived.value || !authCode.value) {
handleSignupError({
error: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_NOT_COMPLETED'),
});
return;
}
isProcessing.value = true;
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.PROCESSING'
);
try {
const params = {
code: authCode.value,
business_id: businessDataParam.business_id,
waba_id: businessDataParam.waba_id,
phone_number_id: businessDataParam.phone_number_id,
};
const responseData = await store.dispatch(
'inboxes/createWhatsAppEmbeddedSignup',
params
);
authCode.value = null;
handleSignupSuccess(responseData);
} catch (error) {
const errorMessage =
parseAPIErrorResponse(error) ||
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE');
handleSignupError({ error: errorMessage });
}
};
const isValidBusinessData = businessDataLocal => {
return (
businessDataLocal &&
businessDataLocal.business_id &&
businessDataLocal.waba_id
);
};
// Message handling
const handleEmbeddedSignupData = async data => {
if (data.event === 'FINISH') {
const businessDataLocal = data.data;
if (isValidBusinessData(businessDataLocal)) {
businessData.value = businessDataLocal;
if (authCodeReceived.value && authCode.value) {
await completeSignupFlow(businessDataLocal);
} else {
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_AUTH'
);
}
} else {
handleSignupError({
error: t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.INVALID_BUSINESS_DATA'
),
});
}
} else if (data.event === 'CANCEL') {
handleSignupCancellation();
} else if (data.event === 'error') {
handleSignupError({
error:
data.error_message ||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SIGNUP_ERROR'),
error_id: data.error_id,
session_id: data.session_id,
});
}
};
const fbLoginCallback = response => {
if (response.authResponse && response.authResponse.code) {
authCode.value = response.authResponse.code;
authCodeReceived.value = true;
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_BUSINESS_INFO'
);
if (businessData.value) {
completeSignupFlow(businessData.value);
}
} else if (response.error) {
handleSignupError({ error: response.error });
} else {
isProcessing.value = false;
isAuthenticating.value = false;
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
}
};
const handleSignupMessage = event => {
// Validate origin for security - following Facebook documentation
// https://developers.facebook.com/docs/whatsapp/embedded-signup/implementation#step-3--add-embedded-signup-to-your-website
if (!event.origin.endsWith('facebook.com')) return;
// Parse and handle WhatsApp embedded signup events
try {
const data = JSON.parse(event.data);
if (data.type === 'WA_EMBEDDED_SIGNUP') {
handleEmbeddedSignupData(data);
}
} catch {
// Ignore non-JSON or irrelevant messages
}
};
const runFBInit = () => {
window.FB.init({
appId: window.chatwootConfig?.whatsappAppId,
autoLogAppEvents: true,
xfbml: true,
version: window.chatwootConfig?.whatsappApiVersion || 'v22.0',
});
fbSdkLoaded.value = true;
};
const loadFacebookSdk = async () => {
return loadScript('https://connect.facebook.net/en_US/sdk.js', {
async: true,
defer: true,
crossOrigin: 'anonymous',
});
};
const tryWhatsAppLogin = () => {
isAuthenticating.value = true;
processingMessage.value = t(
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_PROCESSING'
);
window.FB.login(fbLoginCallback, {
config_id: window.chatwootConfig?.whatsappConfigurationId,
response_type: 'code',
override_default_response_type: true,
extras: {
setup: {},
featureType: '',
sessionInfoVersion: '3',
},
});
};
const launchEmbeddedSignup = async () => {
try {
// Load SDK first if not loaded, following Facebook.vue pattern exactly
await loadFacebookSdk();
runFBInit(); // Initialize FB after loading
// Now proceed with login
tryWhatsAppLogin();
} catch (error) {
handleSignupError({
error: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SDK_LOAD_ERROR'),
});
}
};
// Lifecycle
const setupMessageListener = () => {
window.addEventListener('message', handleSignupMessage);
};
const cleanupMessageListener = () => {
window.removeEventListener('message', handleSignupMessage);
};
const initialize = () => {
window.fbAsyncInit = runFBInit;
setupMessageListener();
};
onMounted(() => {
initialize();
});
onBeforeUnmount(() => {
cleanupMessageListener();
});
</script>
<template>
<div class="h-full">
<LoadingState v-if="showLoader" :message="processingMessage" />
<div v-else>
<div class="flex flex-col items-start mb-6 text-start">
<div class="flex justify-start mb-6">
<div
class="flex justify-center items-center w-12 h-12 rounded-full bg-n-alpha-2"
>
<img
:src="whatsappIconPath"
:alt="$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD')"
class="object-contain w-8 h-8"
draggable="false"
/>
</div>
</div>
<h3 class="mb-2 text-base font-medium text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.TITLE') }}
</h3>
<p class="text-sm leading-[24px] text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.DESC') }}
</p>
</div>
<div class="flex flex-col gap-2 mb-6">
<div
v-for="benefit in benefits"
:key="benefit.key"
class="flex gap-2 items-center text-sm text-n-slate-11"
>
<Icon icon="i-lucide-check" class="text-n-slate-11 size-4" />
{{ benefit.text }}
</div>
</div>
<div class="flex mt-4">
<NextButton
:disabled="isAuthenticating"
:is-loading="isAuthenticating"
faded
slate
class="w-full"
@click="launchEmbeddedSignup"
>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUBMIT_BUTTON') }}
</NextButton>
</div>
</div>
</div>
</template>

View File

@@ -5,6 +5,7 @@ import InboxesAPI from '../../api/inboxes';
import WebChannel from '../../api/channel/webChannel';
import FBChannel from '../../api/channel/fbChannel';
import TwilioChannel from '../../api/channel/twilioChannel';
import WhatsappChannel from '../../api/channel/whatsappChannel';
import { throwErrorMessage } from '../utils/api';
import AnalyticsHelper from '../../helper/AnalyticsHelper';
import camelcaseKeys from 'camelcase-keys';
@@ -198,6 +199,19 @@ export const actions = {
throw new Error(error);
}
},
createWhatsAppEmbeddedSignup: async ({ commit }, params) => {
try {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
const response = await WhatsappChannel.createEmbeddedSignup(params);
commit(types.default.ADD_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
sendAnalyticsEvent('whatsapp');
return response.data;
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
throw error;
}
},
...channelActions,
// TODO: Extract other create channel methods to separate files to reduce file size
// - createChannel

View File

@@ -33,6 +33,7 @@ class Channel::Whatsapp < ApplicationRecord
validate :validate_provider_config
after_create :sync_templates
after_create_commit :setup_webhooks
def name
'Whatsapp'
@@ -67,4 +68,41 @@ class Channel::Whatsapp < ApplicationRecord
def validate_provider_config
errors.add(:provider_config, 'Invalid Credentials') unless provider_service.validate_provider_config?
end
def setup_webhooks
return unless should_setup_webhooks?
perform_webhook_setup
rescue StandardError => e
handle_webhook_setup_error(e)
end
def should_setup_webhooks?
whatsapp_cloud_provider? && embedded_signup_source? && webhook_config_present?
end
def whatsapp_cloud_provider?
provider == 'whatsapp_cloud'
end
def embedded_signup_source?
provider_config['source'] == 'embedded_signup'
end
def webhook_config_present?
provider_config['business_account_id'].present? && provider_config['api_key'].present?
end
def perform_webhook_setup
business_account_id = provider_config['business_account_id']
api_key = provider_config['api_key']
Whatsapp::WebhookSetupService.new(self, business_account_id, api_key).perform
end
def handle_webhook_setup_error(error)
Rails.logger.error "[WHATSAPP] Webhook setup failed: #{error.message}"
# Don't raise the error to prevent channel creation from failing
# Webhooks can be retried later
end
end

View File

@@ -17,4 +17,4 @@ class MessageContentPresenter < SimpleDelegator
def survey_url(conversation_uuid)
"#{ENV.fetch('FRONTEND_URL', nil)}/survey/responses/#{conversation_uuid}"
end
end
end

View File

@@ -0,0 +1,75 @@
class Whatsapp::ChannelCreationService
def initialize(account, waba_info, phone_info, access_token)
@account = account
@waba_info = waba_info
@phone_info = phone_info
@access_token = access_token
end
def perform
validate_parameters!
existing_channel = find_existing_channel
raise "Channel already exists: #{existing_channel.phone_number}" if existing_channel
create_channel_with_inbox
end
private
def validate_parameters!
raise ArgumentError, 'Account is required' if @account.blank?
raise ArgumentError, 'WABA info is required' if @waba_info.blank?
raise ArgumentError, 'Phone info is required' if @phone_info.blank?
raise ArgumentError, 'Access token is required' if @access_token.blank?
end
def find_existing_channel
Channel::Whatsapp.find_by(
account: @account,
phone_number: @phone_info[:phone_number]
)
end
def create_channel_with_inbox
ActiveRecord::Base.transaction do
channel = create_channel
create_inbox(channel)
channel.reload
channel
end
end
def create_channel
Channel::Whatsapp.create!(
account: @account,
phone_number: @phone_info[:phone_number],
provider: 'whatsapp_cloud',
provider_config: build_provider_config
)
end
def build_provider_config
{
api_key: @access_token,
phone_number_id: @phone_info[:phone_number_id],
business_account_id: @waba_info[:waba_id],
source: 'embedded_signup'
}
end
def create_inbox(channel)
inbox_name = build_inbox_name
Inbox.create!(
account: @account,
name: inbox_name,
channel: channel
)
end
def build_inbox_name
business_name = @phone_info[:business_name] || @waba_info[:business_name]
"#{business_name} WhatsApp"
end
end

View File

@@ -0,0 +1,44 @@
class Whatsapp::EmbeddedSignupService
def initialize(account:, code:, business_id:, waba_id:, phone_number_id:)
@account = account
@code = code
@business_id = business_id
@waba_id = waba_id
@phone_number_id = phone_number_id
end
def perform
validate_parameters!
# Exchange code for user access token
access_token = Whatsapp::TokenExchangeService.new(@code).perform
# Fetch phone information
phone_info = Whatsapp::PhoneInfoService.new(@waba_id, @phone_number_id, access_token).perform
# Validate token has access to the WABA
Whatsapp::TokenValidationService.new(access_token, @waba_id).perform
# Create channel
waba_info = { waba_id: @waba_id, business_name: phone_info[:business_name] }
# Webhook setup is now handled in the channel after_create_commit callback
Whatsapp::ChannelCreationService.new(@account, waba_info, phone_info, access_token).perform
rescue StandardError => e
Rails.logger.error("[WHATSAPP] Embedded signup failed: #{e.message}")
raise e
end
private
def validate_parameters!
missing_params = []
missing_params << 'code' if @code.blank?
missing_params << 'business_id' if @business_id.blank?
missing_params << 'waba_id' if @waba_id.blank?
return if missing_params.empty?
raise ArgumentError, "Required parameters are missing: #{missing_params.join(', ')}"
end
end

View File

@@ -0,0 +1,86 @@
class Whatsapp::FacebookApiClient
BASE_URI = 'https://graph.facebook.com'.freeze
def initialize(access_token = nil)
@access_token = access_token
@api_version = GlobalConfigService.load('WHATSAPP_API_VERSION', 'v22.0')
end
def exchange_code_for_token(code)
response = HTTParty.get(
"#{BASE_URI}/#{@api_version}/oauth/access_token",
query: {
client_id: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
client_secret: GlobalConfigService.load('WHATSAPP_APP_SECRET', ''),
code: code
}
)
handle_response(response, 'Token exchange failed')
end
def fetch_phone_numbers(waba_id)
response = HTTParty.get(
"#{BASE_URI}/#{@api_version}/#{waba_id}/phone_numbers",
query: { access_token: @access_token }
)
handle_response(response, 'WABA phone numbers fetch failed')
end
def debug_token(input_token)
response = HTTParty.get(
"#{BASE_URI}/#{@api_version}/debug_token",
query: {
input_token: input_token,
access_token: build_app_access_token
}
)
handle_response(response, 'Token validation failed')
end
def register_phone_number(phone_number_id, pin)
response = HTTParty.post(
"#{BASE_URI}/#{@api_version}/#{phone_number_id}/register",
headers: request_headers,
body: { messaging_product: 'whatsapp', pin: pin.to_s }.to_json
)
handle_response(response, 'Phone registration failed')
end
def subscribe_waba_webhook(waba_id, callback_url, verify_token)
response = HTTParty.post(
"#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
headers: request_headers,
body: {
override_callback_uri: callback_url,
verify_token: verify_token
}.to_json
)
handle_response(response, 'Webhook subscription failed')
end
private
def request_headers
{
'Authorization' => "Bearer #{@access_token}",
'Content-Type' => 'application/json'
}
end
def build_app_access_token
app_id = GlobalConfigService.load('WHATSAPP_APP_ID', '')
app_secret = GlobalConfigService.load('WHATSAPP_APP_SECRET', '')
"#{app_id}|#{app_secret}"
end
def handle_response(response, error_message)
raise "#{error_message}: #{response.body}" unless response.success?
response.parsed_response
end
end

View File

@@ -0,0 +1,57 @@
class Whatsapp::PhoneInfoService
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!
fetch_and_process_phone_info
end
private
def validate_parameters!
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
raise ArgumentError, 'Access token is required' if @access_token.blank?
end
def fetch_and_process_phone_info
response = @api_client.fetch_phone_numbers(@waba_id)
phone_numbers = response['data']
phone_data = find_phone_data(phone_numbers)
raise "No phone numbers found for WABA #{@waba_id}" if phone_data.nil?
build_phone_info(phone_data)
end
def find_phone_data(phone_numbers)
return nil if phone_numbers.blank?
if @phone_number_id.present?
phone_numbers.find { |phone| phone['id'] == @phone_number_id } || phone_numbers.first
else
phone_numbers.first
end
end
def build_phone_info(phone_data)
display_phone_number = sanitize_phone_number(phone_data['display_phone_number'])
{
phone_number_id: phone_data['id'],
phone_number: "+#{display_phone_number}",
verified: phone_data['code_verification_status'] == 'VERIFIED',
business_name: phone_data['verified_name'] || phone_data['display_phone_number']
}
end
def sanitize_phone_number(phone_number)
return phone_number if phone_number.blank?
phone_number.gsub(/[\s\-\(\)\.\+]/, '').strip
end
end

View File

@@ -0,0 +1,26 @@
class Whatsapp::TokenExchangeService
def initialize(code)
@code = code
@api_client = Whatsapp::FacebookApiClient.new
end
def perform
validate_code!
exchange_token
end
private
def validate_code!
raise ArgumentError, 'Authorization code is required' if @code.blank?
end
def exchange_token
response = @api_client.exchange_code_for_token(@code)
access_token = response['access_token']
raise "No access token in response: #{response}" if access_token.blank?
access_token
end
end

View File

@@ -0,0 +1,42 @@
class Whatsapp::TokenValidationService
def initialize(access_token, waba_id)
@access_token = access_token
@waba_id = waba_id
@api_client = Whatsapp::FacebookApiClient.new(access_token)
end
def perform
validate_parameters!
validate_token_waba_access
end
private
def validate_parameters!
raise ArgumentError, 'Access token is required' if @access_token.blank?
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
end
def validate_token_waba_access
token_debug_data = @api_client.debug_token(@access_token)
waba_scope = extract_waba_scope(token_debug_data)
verify_waba_authorization(waba_scope)
end
def extract_waba_scope(token_data)
granular_scopes = token_data.dig('data', 'granular_scopes')
waba_scope = granular_scopes&.find { |scope| scope['scope'] == 'whatsapp_business_management' }
raise 'No WABA scope found in token' unless waba_scope
waba_scope
end
def verify_waba_authorization(waba_scope)
authorized_waba_ids = waba_scope['target_ids'] || []
return if authorized_waba_ids.include?(@waba_id)
raise "Token does not have access to WABA #{@waba_id}. Authorized WABAs: #{authorized_waba_ids}"
end
end

View File

@@ -0,0 +1,67 @@
class Whatsapp::WebhookSetupService
def initialize(channel, waba_id, access_token)
@channel = channel
@waba_id = waba_id
@access_token = access_token
@api_client = Whatsapp::FacebookApiClient.new(access_token)
end
def perform
validate_parameters!
register_phone_number
setup_webhook
end
private
def validate_parameters!
raise ArgumentError, 'Channel is required' if @channel.blank?
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
raise ArgumentError, 'Access token is required' if @access_token.blank?
end
def register_phone_number
phone_number_id = @channel.provider_config['phone_number_id']
pin = fetch_or_create_pin
@api_client.register_phone_number(phone_number_id, pin)
store_pin(pin)
rescue StandardError => e
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
def fetch_or_create_pin
# Check if we have a stored PIN for this phone number
existing_pin = @channel.provider_config['verification_pin']
return existing_pin.to_i if existing_pin.present?
# Generate a new 6-digit PIN if none exists
SecureRandom.random_number(900_000) + 100_000
end
def store_pin(pin)
# Store the PIN in provider_config for future use
@channel.provider_config['verification_pin'] = pin
@channel.save!
end
def setup_webhook
callback_url = build_callback_url
verify_token = @channel.provider_config['webhook_verify_token']
@api_client.subscribe_waba_webhook(@waba_id, callback_url, verify_token)
rescue StandardError => e
Rails.logger.error("[WHATSAPP] Webhook setup failed: #{e.message}")
raise "Webhook setup failed: #{e.message}"
end
def build_callback_url
frontend_url = ENV.fetch('FRONTEND_URL', nil)
phone_number = @channel.phone_number
"#{frontend_url}/webhooks/whatsapp/#{phone_number}"
end
end

View File

@@ -4,4 +4,4 @@ json.payload do
json.partial! 'article', formats: [:json], article: article
end
end
end
end

View File

@@ -0,0 +1 @@
json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox

View File

@@ -39,6 +39,9 @@
googleOAuthClientId: '<%= ENV.fetch('GOOGLE_OAUTH_CLIENT_ID', nil) %>',
googleOAuthCallbackUrl: '<%= ENV.fetch('GOOGLE_OAUTH_CALLBACK_URL', nil) %>',
fbApiVersion: '<%= @global_config['FACEBOOK_API_VERSION'] %>',
whatsappAppId: '<%= @global_config['WHATSAPP_APP_ID'] %>',
whatsappConfigurationId: '<%= @global_config['WHATSAPP_CONFIGURATION_ID'] %>',
whatsappApiVersion: '<%= @global_config['WHATSAPP_API_VERSION'] %>',
signupEnabled: '<%= @global_config['ENABLE_ACCOUNT_SIGNUP'] %>',
isEnterprise: '<%= @global_config['IS_ENTERPRISE'] %>',
<% if @global_config['IS_ENTERPRISE'] %>