Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/api/src/.example.env
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ LAUNCH_DARKLY_SDK_KEY=

# Provider-managed MCP ("Add from Claude"). Process-env fallback when LAUNCH_DARKLY_SDK_KEY is unset.
# IS_MCP_PROVIDER_MANAGED_ENABLED=true
# WhatsApp Embedded Signup (Meta Tech Provider). Process-env fallback when LaunchDarkly is unset.
# IS_WHATSAPP_EMBEDDED_SIGNUP_ENABLED=true
Comment on lines +67 to +68

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Document the new API WhatsApp env vars in the example file.

Lines 67-68 add the flag toggle, but the API example still omits NOVU_WHATSAPP_APP_ID and NOVU_WHATSAPP_APP_SECRET, which are required for the embedded-signup backend path. This can lead to silent misconfiguration in self-hosted setups.

Suggested diff
 # WhatsApp Embedded Signup (Meta Tech Provider). Process-env fallback when LaunchDarkly is unset.
 # IS_WHATSAPP_EMBEDDED_SIGNUP_ENABLED=true
+NOVU_WHATSAPP_APP_ID=
+NOVU_WHATSAPP_APP_SECRET=
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/.example.env` around lines 67 - 68, The .example.env file
documents the IS_WHATSAPP_EMBEDDED_SIGNUP_ENABLED flag but is missing the
required WhatsApp API credential environment variables NOVU_WHATSAPP_APP_ID and
NOVU_WHATSAPP_APP_SECRET. Add these two missing environment variable entries to
the example file near the IS_WHATSAPP_EMBEDDED_SIGNUP_ENABLED flag so that
developers configuring WhatsApp embedded signup have all necessary variables
documented and can avoid silent misconfiguration in self-hosted setups.


GITHUB_API_TOKEN=

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,9 +255,9 @@ export class AgentIntegrationsController {
@ApiExcludeEndpoint()
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Send a hello_world WhatsApp template from the agent integration',
summary: 'Send a WhatsApp test template from the agent integration',
description:
'Sends the standard `hello_world` template via the configured WhatsApp Business phone number to a recipient supplied by the user, used at the end of the onboarding flow to verify outbound delivery without asking the user to send an inbound message themselves.',
'Sends the `hello_world` template via the configured WhatsApp Business phone number to verify outbound delivery.',
})
@ApiNotFoundResponse({ description: 'The agent or integration was not found.' })
@RequirePermissions(PermissionsEnum.AGENT_WRITE)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import {
subscribeWabaMessagesField,
WHATSAPP_BUSINESS_MANAGEMENT_SCOPE,
} from '../../../../integrations/usecases/whatsapp/whatsapp-graph-api.utils';
import {
resolveWhatsAppAppId,
resolveWhatsAppAppSecret,
} from '../../../../integrations/usecases/whatsapp/whatsapp-credentials.utils';
import { ConfigureWhatsAppWebhookCommand } from './configure-whatsapp-webhook.command';

export type ConfigureWhatsAppWebhookFailure = {
Expand Down Expand Up @@ -117,7 +121,7 @@ export class ConfigureWhatsAppWebhook {
const accessToken = typeof credentials.apiToken === 'string' ? credentials.apiToken.trim() : '';
const verifyToken = typeof credentials.token === 'string' ? credentials.token.trim() : '';
const wabaId = typeof credentials.businessAccountId === 'string' ? credentials.businessAccountId.trim() : '';
const appSecret = typeof credentials.secretKey === 'string' ? credentials.secretKey.trim() : '';
const appSecret = resolveWhatsAppAppSecret(credentials) ?? '';

if (!accessToken || !wabaId) {
return {
Expand Down Expand Up @@ -160,24 +164,24 @@ export class ConfigureWhatsAppWebhook {
fallbackToManual: true,
reason: {
code: 'missing_app_secret',
message:
'Save the App Secret in the credentials form — Novu needs it to subscribe your Meta app to WhatsApp webhooks.',
message: credentials.isNovuManaged
? 'Novu WhatsApp Tech Provider app secret is not configured on this deployment. Contact support.'
: 'Save the App Secret in the credentials form — Novu needs it to subscribe your Meta app to WhatsApp webhooks.',
},
};
}

// Look up the Meta App ID from the access token. Required for the
// app-level subscription Meta demands before per-WABA `subscribed_apps`
// accepts an `override_callback_uri`.
let appId: string | undefined;
try {
const debug = await debugAccessToken(accessToken);
appId = debug.body.data?.app_id;
} catch (err) {
this.logger.warn(
{ err, agentId: agent._id, integrationId: integration._id },
'WhatsApp auto-configure: debug_token call failed'
);
let appId = resolveWhatsAppAppId(credentials);
if (!appId) {
try {
const debug = await debugAccessToken(accessToken);
appId = debug.body.data?.app_id;
} catch (err) {
this.logger.warn(
{ err, agentId: agent._id, integrationId: integration._id },
'WhatsApp auto-configure: debug_token call failed'
);
}
}

if (!appId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,5 +109,31 @@ describe('SendWhatsAppTestTemplate usecase', () => {
expect(subscriberRepository.findBySubscriberId.calledOnceWithExactly(ENV_ID, SUBSCRIBER_ID)).to.equal(true);
expect(sendWhatsAppTemplateStub.calledOnce).to.equal(true);
expect(sendWhatsAppTemplateStub.firstCall.args[0].to).to.equal('14155551234');
expect(sendWhatsAppTemplateStub.firstCall.args[0].templateName).to.equal('hello_world');
expect(sendWhatsAppTemplateStub.firstCall.args[0].bodyParameters).to.equal(undefined);
});

it('sends the embedded signup sample template for Novu-managed credentials', async () => {
integrationRepository.findOne.resolves({
_id: INTEGRATION_ID,
providerId: ChatProviderIdEnum.WhatsAppBusiness,
credentials: {
apiToken: 'token',
phoneNumberIdentification: 'phone-number-id',
businessAccountId: 'waba-id',
isNovuManaged: true,
},
});
subscriberRepository.findBySubscriberId.resolves({
subscriberId: SUBSCRIBER_ID,
phone: '+14155551234',
firstName: 'Alice',
});

const result = await buildUsecase().execute(buildCommand());

expect(result.success).to.equal(true);
expect(sendWhatsAppTemplateStub.firstCall.args[0].templateName).to.equal('sample_order_confirmation');
expect(sendWhatsAppTemplateStub.firstCall.args[0].bodyParameters).to.deep.equal(['Alice', 'TEST-001']);
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Injectable, NotFoundException, UnprocessableEntityException } from '@nestjs/common';
import { decryptCredentials, InstrumentUsecase, PinoLogger } from '@novu/application-generic';
import { AgentIntegrationRepository, AgentRepository, IntegrationRepository, SubscriberRepository } from '@novu/dal';
import { ChatProviderIdEnum } from '@novu/shared';
import { ChatProviderIdEnum, type ICredentials } from '@novu/shared';

import {
debugAccessToken,
Expand All @@ -12,11 +12,41 @@ import {
import { normalizePhoneForMeta } from '../../../shared/util/phone-normalization';
import { SendWhatsAppTestTemplateCommand } from './send-whatsapp-test-template.command';

const TEMPLATE_NAME = 'hello_world';
const MANUAL_TEST_TEMPLATE = 'hello_world';
const EMBEDDED_TEST_TEMPLATE = 'sample_order_confirmation';
const TEMPLATE_LANGUAGE = 'en_US';

const META_DEV_CONSOLE_URL_BASE = 'https://developers.facebook.com/apps';

type TestTemplateSend = {
templateName: string;
languageCode: string;
bodyParameters?: string[];
};

function resolveTestTemplateSend(
credentials: ICredentials,
subscriber: { firstName?: string | null }
): TestTemplateSend {
if (credentials.isNovuManaged === true) {
const recipientName =
typeof subscriber.firstName === 'string' && subscriber.firstName.trim()
? subscriber.firstName.trim()
: 'Novu';

return {
templateName: EMBEDDED_TEST_TEMPLATE,
languageCode: TEMPLATE_LANGUAGE,
bodyParameters: [recipientName, 'TEST-001'],
};
}

return {
templateName: MANUAL_TEST_TEMPLATE,
languageCode: TEMPLATE_LANGUAGE,
};
}

export type SendWhatsAppTestTemplateError = {
code:
| 'missing_credentials'
Expand Down Expand Up @@ -134,14 +164,17 @@ export class SendWhatsAppTestTemplate {
};
}

const templateSend = resolveTestTemplateSend(credentials, subscriber);

let response: Awaited<ReturnType<typeof sendWhatsAppTemplate>>;
try {
response = await sendWhatsAppTemplate({
accessToken,
phoneNumberId,
to: normalizePhoneForMeta(subscriberPhone),
templateName: TEMPLATE_NAME,
languageCode: TEMPLATE_LANGUAGE,
templateName: templateSend.templateName,
languageCode: templateSend.languageCode,
bodyParameters: templateSend.bodyParameters,
});
} catch (err) {
this.logger.warn({ err, integrationId: integration._id }, 'WhatsApp test template send failed');
Expand All @@ -157,7 +190,7 @@ export class SendWhatsAppTestTemplate {

const error = extractMetaError(response.body);
if (error || response.statusCode >= 400) {
const failure = this.classifyMetaError(error, response.statusCode, subscriberPhone);
const failure = this.classifyMetaError(error, response.statusCode, subscriberPhone, templateSend.templateName);

if (failure.code === 'recipient_not_allowed') {
failure.helpUrl = await this.resolveDevConsoleUrl(accessToken);
Expand Down Expand Up @@ -198,10 +231,21 @@ export class SendWhatsAppTestTemplate {
private classifyMetaError(
error: MetaErrorSummary | undefined,
statusCode: number,
recipient: string
recipient: string,
templateName: string
): SendWhatsAppTestTemplateError {
const message = error?.message ?? `Meta returned HTTP ${statusCode}`;

if (error?.code === 131058) {
return {
code: 'template_unavailable',
message:
templateName === MANUAL_TEST_TEMPLATE
? `The "${templateName}" template can only be sent from a Meta public test number. Connect via WhatsApp Embedded Signup to send from your own business number.`
: `The "${templateName}" template cannot be sent from this phone number. Confirm the template is approved in Meta Business Manager.`,
};
}

if (error?.code === 131030 || error?.subcode === 2494051) {
return {
code: 'recipient_not_allowed',
Expand All @@ -220,7 +264,7 @@ export class SendWhatsAppTestTemplate {
if (error?.code === 132001 || error?.code === 132000 || error?.code === 132005) {
return {
code: 'template_unavailable',
message: `The "${TEMPLATE_NAME}" template isn't approved for this WhatsApp Business Account. Check the templates section in Meta.`,
message: `The "${templateName}" template is not available on this WhatsApp Business account. Use WhatsApp Embedded Signup or add an approved template in Meta.`,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { BadRequestException, Injectable, OnModuleDestroy } from '@nestjs/common
import { CacheService, PinoLogger } from '@novu/application-generic';
import type { Chat, Message, ReactionEvent, Thread } from 'chat';
import { LRUCache } from 'lru-cache';
import { resolveWhatsAppAppSecret } from '../../../integrations/usecases/whatsapp/whatsapp-credentials.utils';
import { AgentConfigResolver, ResolvedAgentConfig } from '../../channels/agent-config-resolver.service';
import { AgentEmailActionTokenService } from '../../email/agent-email-action-token.service';
import { AgentEmailSender, resolveAgentEmailSenderName } from '../../email/agent-email-sender.service';
Expand Down Expand Up @@ -289,9 +290,11 @@ export class ChatInstanceRegistry implements OnModuleDestroy {
};
}
case AgentPlatformEnum.WHATSAPP: {
const appSecret = resolveWhatsAppAppSecret(credentials);

if (
!credentials.apiToken ||
!credentials.secretKey ||
!appSecret ||
!credentials.token ||
!credentials.phoneNumberIdentification
) {
Expand All @@ -305,7 +308,7 @@ export class ChatInstanceRegistry implements OnModuleDestroy {
return {
whatsapp: createWhatsAppAdapter({
accessToken: credentials.apiToken,
appSecret: credentials.secretKey,
appSecret,
verifyToken: credentials.token,
phoneNumberId: credentials.phoneNumberIdentification,
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export class SendWhatsAppTestTemplateRequestDto {
@ApiProperty({
type: String,
description:
'Novu subscriber ID whose phone field receives the hello_world WhatsApp template. The dashboard patches subscriber.phone before calling this endpoint.',
'Novu subscriber ID whose phone field receives the WhatsApp test template (sample_order_confirmation for Embedded Signup, hello_world for manual credentials). The dashboard patches subscriber.phone before calling this endpoint.',
example: 'connect:user-123',
})
@IsString()
Expand All @@ -22,6 +22,7 @@ export class SendWhatsAppTestTemplateErrorDto {
'recipient_not_allowed',
'token_expired',
'template_unavailable',
'template_pending_approval',
'invalid_recipient',
'rate_limited',
'meta_rejected',
Expand Down
93 changes: 93 additions & 0 deletions apps/api/src/app/integrations/dtos/whatsapp-embedded-signup.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';

import type { ConfigureWhatsAppWebhookFailure } from '../../agents/channels/whatsapp/configure-whatsapp-webhook/configure-whatsapp-webhook.usecase';

export class WhatsAppEmbeddedSignupRequestDto {
@ApiProperty({
type: String,
description: 'Authorization code returned by Meta Embedded Signup via the Facebook JS SDK',
})
@IsString()
@IsNotEmpty()
code: string;

@ApiProperty({ type: String, description: 'WhatsApp Business Account ID from the WA_EMBEDDED_SIGNUP session event' })
@IsString()
@IsNotEmpty()
wabaId: string;

@ApiProperty({ type: String, description: 'Phone number ID from the WA_EMBEDDED_SIGNUP session event' })
@IsString()
@IsNotEmpty()
phoneNumberId: string;

@ApiProperty({ type: String, description: 'Identifier of the WhatsApp integration to update' })
@IsString()
@IsNotEmpty()
integrationIdentifier: string;

@ApiProperty({ type: String, description: 'Agent identifier used to configure the webhook callback URL' })
@IsString()
@IsNotEmpty()
agentIdentifier: string;
}

export type WhatsAppEmbeddedSignupFailure = {
code:
| 'feature_disabled'
| 'missing_platform_config'
| 'token_exchange_failed'
| 'meta_validation_failed'
| 'integration_not_found'
| 'phone_registration_failed'
| 'webhook_configuration_failed'
| 'unknown';
message: string;
};

export class WhatsAppEmbeddedSignupFailureDto {
@ApiProperty({ type: String })
code: WhatsAppEmbeddedSignupFailure['code'];

@ApiProperty({ type: String })
message: string;
}

export class WhatsAppEmbeddedSignupResponseDto {
@ApiProperty({ type: Boolean })
success: boolean;

@ApiPropertyOptional({ type: String })
integrationId?: string;

@ApiPropertyOptional({ type: String })
integrationIdentifier?: string;

@ApiPropertyOptional({ type: String })
callbackUrl?: string;

@ApiPropertyOptional({ type: String })
wabaId?: string;

@ApiPropertyOptional({
type: String,
description: 'Human-readable WhatsApp business phone number saved on the integration for onboarding deep links',
})
displayPhoneNumber?: string;

@ApiPropertyOptional({
type: String,
description: 'Present when phone registration failed but credentials were saved',
})
phoneRegistrationWarning?: string;

@ApiPropertyOptional({ type: WhatsAppEmbeddedSignupFailureDto })
error?: WhatsAppEmbeddedSignupFailure;

@ApiPropertyOptional({
type: Object,
description: 'Populated when webhook auto-configure fails after credentials save',
})
webhookReason?: ConfigureWhatsAppWebhookFailure;
}
Loading
Loading