Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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,6 @@ 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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,13 @@ export class SendWhatsAppTestTemplate {
): SendWhatsAppTestTemplateError {
const message = error?.message ?? `Meta returned HTTP ${statusCode}`;

if (error?.code === 131058) {
return {
code: 'template_unavailable',
message: `The "${TEMPLATE_NAME}" template can only be sent from a Meta public test number. Connect via WhatsApp Embedded Signup to send from your own business number.`,
};
}

if (error?.code === 131030 || error?.subcode === 2494051) {
return {
code: 'recipient_not_allowed',
Expand All @@ -220,7 +227,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 "${TEMPLATE_NAME}" 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;
}
36 changes: 36 additions & 0 deletions apps/api/src/app/integrations/integrations.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ import { IssueIntegrationStoreTelegramMobileLinkResponseDto } from './dtos/issue
import { SlackQuickSetupRequestDto, SlackQuickSetupResponseDto } from './dtos/slack-quick-setup.dto';
import { UpdateIntegrationRequestDto } from './dtos/update-integration.dto';
import { WhatsAppValidateTokenRequestDto, WhatsAppValidateTokenResponseDto } from './dtos/whatsapp-validate-token.dto';
import {
WhatsAppEmbeddedSignupRequestDto,
WhatsAppEmbeddedSignupResponseDto,
} from './dtos/whatsapp-embedded-signup.dto';
import { AutoConfigureIntegrationCommand } from './usecases/auto-configure-integration/auto-configure-integration.command';
import { AutoConfigureIntegration } from './usecases/auto-configure-integration/auto-configure-integration.usecase';
import { AzureSetupOauthCallbackCommand } from './usecases/azure-setup-oauth-callback/azure-setup-oauth-callback.command';
Expand Down Expand Up @@ -107,6 +111,8 @@ import { UpdateIntegrationCommand } from './usecases/update-integration/update-i
import { UpdateIntegration } from './usecases/update-integration/update-integration.usecase';
import { WhatsAppValidateTokenCommand } from './usecases/whatsapp/whatsapp-validate-token.command';
import { WhatsAppValidateToken } from './usecases/whatsapp/whatsapp-validate-token.usecase';
import { WhatsAppEmbeddedSignupCommand } from './usecases/whatsapp/whatsapp-embedded-signup.command';
import { WhatsAppEmbeddedSignup } from './usecases/whatsapp/whatsapp-embedded-signup.usecase';

@ApiCommonResponses()
@Controller('/integrations')
Expand Down Expand Up @@ -137,6 +143,7 @@ export class IntegrationsController {
private azureSetupOauthCallbackUsecase: AzureSetupOauthCallback,
private msTeamsHealthCheckUsecase: MsTeamsHealthCheck,
private whatsAppValidateTokenUsecase: WhatsAppValidateToken,
private whatsAppEmbeddedSignupUsecase: WhatsAppEmbeddedSignup,
private issueIntegrationStoreTelegramMobileLinkUsecase: IssueIntegrationStoreTelegramMobileLink,
private logger: PinoLogger
) {
Comment thread
scopsy marked this conversation as resolved.
Expand Down Expand Up @@ -825,6 +832,35 @@ export class IntegrationsController {
);
}

@Post('/whatsapp/embedded-signup')
@ApiResponse(WhatsAppEmbeddedSignupResponseDto, 200)
@ApiOperation({
summary: 'Complete WhatsApp Embedded Signup',
description:
'Exchanges a Meta Embedded Signup authorization code for a business integration token, saves WhatsApp credentials on the integration, registers the phone number when possible, and configures the agent webhook with Meta.',
})
@ApiExcludeEndpoint()
@RequireAuthentication()
@RequirePermissions(PermissionsEnum.INTEGRATION_WRITE)
@HttpCode(HttpStatus.OK)
async completeWhatsAppEmbeddedSignup(
@UserSession() user: UserSessionData,
@Body() body: WhatsAppEmbeddedSignupRequestDto
): Promise<WhatsAppEmbeddedSignupResponseDto> {
return this.whatsAppEmbeddedSignupUsecase.execute(
WhatsAppEmbeddedSignupCommand.create({
userId: user._id,
environmentId: user.environmentId,
organizationId: user.organizationId,
code: body.code,
wabaId: body.wabaId,
phoneNumberId: body.phoneNumberId,
integrationIdentifier: body.integrationIdentifier,
agentIdentifier: body.agentIdentifier,
})
);
}

@Post('/telegram/mobile-link')
@ApiResponse(IssueIntegrationStoreTelegramMobileLinkResponseDto, 200)
@ApiOperation({
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/app/integrations/integrations.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from '@novu/application-generic';
import { CommunityOrganizationRepository, CommunityUserRepository } from '@novu/dal';
import { TelegramMobileLinkTokenService } from '../agents/channels/telegram-linking/telegram-mobile-link-token.service';
import { AgentsModule } from '../agents/agents.module';
import { AuthModule } from '../auth/auth.module';
import { ChannelConnectionsModule } from '../channel-connections/channel-connections.module';
import { ChannelEndpointsModule } from '../channel-endpoints/channel-endpoints.module';
Expand All @@ -29,7 +30,7 @@ const PROVIDERS = [
];

@Module({
imports: [SharedModule, forwardRef(() => AuthModule), ChannelConnectionsModule, ChannelEndpointsModule],
imports: [SharedModule, forwardRef(() => AuthModule), ChannelConnectionsModule, ChannelEndpointsModule, forwardRef(() => AgentsModule)],
controllers: [IntegrationsController, IntegrationsPublicController],
providers: [
...USE_CASES,
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/app/integrations/usecases/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { SetIntegrationAsPrimary } from './set-integration-as-primary/set-integr
import { SlackQuickSetup } from './slack-quick-setup/slack-quick-setup.usecase';
import { UpdateIntegration } from './update-integration/update-integration.usecase';
import { WhatsAppValidateToken } from './whatsapp/whatsapp-validate-token.usecase';
import { WhatsAppEmbeddedSignup } from './whatsapp/whatsapp-embedded-signup.usecase';

export const USE_CASES = [
GetInAppActivated,
Expand Down Expand Up @@ -69,6 +70,7 @@ export const USE_CASES = [
MsTeamsHealthCheck,
SlackQuickSetup,
WhatsAppValidateToken,
WhatsAppEmbeddedSignup,
IssueIntegrationStoreTelegramMobileLink,
GetIntegrationStoreTelegramMobileLinkStatus,
ConsumeIntegrationStoreTelegramMobileLink,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,28 @@
import { randomUUID } from 'node:crypto';
import { ChatProviderIdEnum, type ICredentials } from '@novu/shared';

export function resolveWhatsAppAppSecret(credentials: ICredentials): string | undefined {
if (credentials.isNovuManaged === true) {
const platformSecret = process.env.NOVU_WHATSAPP_APP_SECRET?.trim();

return platformSecret || undefined;
}

const storedSecret = typeof credentials.secretKey === 'string' ? credentials.secretKey.trim() : '';

return storedSecret || undefined;
}

export function resolveWhatsAppAppId(credentials: ICredentials): string | undefined {
if (credentials.isNovuManaged === true) {
const platformAppId = process.env.NOVU_WHATSAPP_APP_ID?.trim();

return platformAppId || undefined;
}

return undefined;
}
Comment on lines +16 to +24

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 | 🟠 Major | ⚡ Quick win

Preserve manual-credentials app ID fallback in resolveWhatsAppAppId.

resolveWhatsAppAppId currently returns undefined for all non-Novu-managed integrations. That drops existing stored app-id credentials and can break non-embedded WhatsApp paths.

Suggested diff
 export function resolveWhatsAppAppId(credentials: ICredentials): string | undefined {
   if (credentials.isNovuManaged === true) {
     const platformAppId = process.env.NOVU_WHATSAPP_APP_ID?.trim();

     return platformAppId || undefined;
   }

-  return undefined;
+  const storedAppId = typeof credentials.appID === 'string' ? credentials.appID.trim() : '';
+
+  return storedAppId || undefined;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function resolveWhatsAppAppId(credentials: ICredentials): string | undefined {
if (credentials.isNovuManaged === true) {
const platformAppId = process.env.NOVU_WHATSAPP_APP_ID?.trim();
return platformAppId || undefined;
}
return undefined;
}
export function resolveWhatsAppAppId(credentials: ICredentials): string | undefined {
if (credentials.isNovuManaged === true) {
const platformAppId = process.env.NOVU_WHATSAPP_APP_ID?.trim();
return platformAppId || undefined;
}
const storedAppId = typeof credentials.appID === 'string' ? credentials.appID.trim() : '';
return storedAppId || undefined;
}
🤖 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/app/integrations/usecases/whatsapp/whatsapp-credentials.utils.ts`
around lines 16 - 24, The resolveWhatsAppAppId function currently returns
undefined for all non-Novu-managed integrations, which discards existing stored
app ID credentials. Modify the function to preserve the app ID fallback by
returning the app ID from the credentials object itself when
credentials.isNovuManaged is not true. This ensures that manually managed
WhatsApp integrations retain their configured app ID instead of losing it to
undefined.


/**
* For WhatsApp Business agent integrations Novu manages the webhook Verify
* Token automatically: it's just a shared secret echoed back to Meta during

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.

🔒 Agentic Security Review
Severity: HIGH
isNovuManaged is used as a trust switch to select deployment-level Meta app credentials from environment variables. Because this flag is persisted in integration credentials and treated as authoritative here, any path that can set that credential key can escalate to platform-managed app context.

Impact: tenant-triggered flows can execute Meta app subscription/configuration logic with shared platform credentials, expanding blast radius beyond the tenant-scoped credential model.

Fix in Cursor Fix in Web

Reviewed by Cursor Security Reviewer for commit 5d2ec6f. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@cursoragent review this comment

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.

Taking a look!

Open in Web Open in Cursor 

Expand Down
Loading
Loading