Build WhatsApp Integration with Vonage and NestJS: Complete Guide
Complete tutorial for building WhatsApp messaging with Vonage Messages API and NestJS. Includes webhooks, status updates, security, and 2025 production requirements.
This guide walks you through building a robust WhatsApp messaging service using Node.js with the NestJS framework and the Vonage Messages API (Application Programming Interface). You'll learn to set up your environment, send messages, receive incoming messages via webhooks, handle message status updates, and implement production considerations like security, error handling, and deployment.
By completing this tutorial, you'll have a functional NestJS application that can:
Send WhatsApp messages programmatically via the Vonage Messages API.
Receive incoming WhatsApp messages through secure webhooks.
Process message status updates (e.g., delivered, read).
Handle configuration securely and effectively.
This guide assumes basic knowledge of Node.js, TypeScript, and REST APIs (Representational State Transfer Application Programming Interfaces). NestJS provides structured architecture, dependency injection, and modularity that simplify building scalable applications. Vonage provides the communication infrastructure for sending and receiving WhatsApp messages.
Prerequisites:
Node.js (v20 LTS or v22 LTS recommended for 2025 – v22 "Jod" is Active LTS through October 2025, then Maintenance LTS through April 2027. v20 "Iron" is in Maintenance LTS through April 2026. v18 reached end-of-life in April 2025.)
npm or yarn package manager
A Vonage API Account (Sign up for free credit if you don't have one)
ngrok installed (A free account is sufficient). Note: Use ngrok for local development testing only, allowing Vonage webhooks to reach your machine. Production deployments require a stable, publicly accessible HTTPS endpoint for your webhook handlers.
Access to a WhatsApp-enabled device for testing
Verify Your Installation:
Confirm your Node.js version meets requirements:
Bash
node --version # Should show v20.x.x or v22.x.x
npm --version # Verify npm is installed
ngrok version # Confirm ngrok is available
GitHub Repository:
Find a complete, working example of the code in this guide at [repository URL placeholder – update with actual URL].
Project Architecture
The system consists of the following components:
NestJS Application: The core backend service built with NestJS. It exposes endpoints to trigger outgoing messages and handles incoming webhooks from Vonage.
Vonage Messages API: The third-party service used to send and receive WhatsApp messages.
WhatsApp Platform: Where end-users interact via their WhatsApp client.
ngrok (Development): A tool to expose your local NestJS application to the internet so Vonage webhooks can reach it during development.
sequenceDiagram
participant User as WhatsApp User
participant WhatsApp
participant Vonage as Vonage Messages API
participant Ngrok as ngrok (Dev Only)
participant NestJS as NestJS Application
User->>+WhatsApp: Sends message
WhatsApp->>+Vonage: Delivers inbound message
Vonage->>+Ngrok: Forwards message to webhook URL
Ngrok->>+NestJS: Delivers message payload to /webhooks/inbound
NestJS-->>-Ngrok: Responds 200 OK
Ngrok-->>-Vonage: Forwards 200 OK
Note right of NestJS: Process inbound message (e.g., log, reply)
NestJS->>+Vonage: Sends reply message via API
Vonage->>+WhatsApp: Delivers reply message
WhatsApp->>-User: Shows reply message
Note over Vonage, NestJS: Status Updates
Vonage->>+Ngrok: Forwards status update to webhook URL
Ngrok->>+NestJS: Delivers status payload to /webhooks/status
NestJS-->>-Ngrok: Responds 200 OK
Ngrok-->>-Vonage: Forwards 200 OK
Note right of NestJS: Process status update (e.g., log)
1. Setting Up the NestJS Project
Initialize a new NestJS project and install the necessary dependencies.
1.1. Install NestJS CLI
Bash
npm install -g @nestjs/cli
1.2. Create a New NestJS Project
Bash
nest new vonage-whatsapp-nestjs
cd vonage-whatsapp-nestjs
Choose your preferred package manager (npm or yarn) when prompted.
1.3. Install Required Dependencies
Install the Vonage SDK packages and configuration modules:
Bash
npm install @vonage/server-sdk @vonage/messages @vonage/jwt @nestjs/config dotenv
# Or using yarn:# yarn add @vonage/server-sdk @vonage/messages @vonage/jwt @nestjs/config dotenv
What Each Package Does:
@vonage/server-sdk – Core Vonage SDK for Node.js
@vonage/messages – Messages API client for sending WhatsApp, SMS, and other messages
@vonage/jwt – JWT verification for webhook signature validation
@nestjs/config – NestJS module for managing environment variables
dotenv – Loads environment variables from .env files
Package Compatibility:
Package
Minimum Version
Notes
@vonage/server-sdk
3.15.0+
Supports latest Messages API features
@vonage/messages
2.1.0+
Required for WhatsApp support
@vonage/jwt
1.2.0+
Required for signature verification
@nestjs/config
3.0.0+
Compatible with NestJS 10+
1.4. Set Up Environment Variables
Create a .env file in the root of your project. This file stores your Vonage credentials and configuration. Never commit this file to version control.
.env
# .env
# Vonage API Credentials & Application Details
VONAGE_API_KEY=YOUR_API_KEY_HERE
VONAGE_API_SECRET=YOUR_API_SECRET_HERE
VONAGE_APPLICATION_ID=YOUR_APPLICATION_ID_HERE
VONAGE_PRIVATE_KEY_PATH=./private.key # Path relative to project root
VONAGE_API_SIGNATURE_SECRET=YOUR_SIGNATURE_SECRET_HERE # For webhook verification
# Vonage WhatsApp Number (Sandbox or Purchased)
VONAGE_WHATSAPP_NUMBER=VONAGE_PROVIDED_WHATSAPP_NUMBER
# Application Port
PORT=3000 # Default NestJS port
# Vonage API Host (Optional: Use sandbox for testing initially)
VONAGE_API_HOST=https://messages-sandbox.nexmo.com # For production, remove this line to default to the production URL (api.nexmo.com)
Find your VONAGE_API_KEY and VONAGE_API_SECRET on the main dashboard page.
Navigate to Settings to find the "API key signature secret" and copy your VONAGE_API_SIGNATURE_SECRET.
Step 2: Create a Vonage Application
Go to Applications → Create a new application.
Name your application (e.g., "NestJS WhatsApp Service").
Click Generate public and private key. A private.key file downloads automatically. Save this file in your NestJS project root directory. Update VONAGE_PRIVATE_KEY_PATH in your .env file if you save it elsewhere.
Enable the Messages capability. Leave webhook URLs blank for now (we'll add ngrok URLs next).
Click Generate new application.
Copy the Application ID and save it as VONAGE_APPLICATION_ID in your .env file.
Step 3: Set Up WhatsApp Sandbox for Testing
Navigate to Messages API Sandbox in the dashboard sidebar.
Follow instructions to activate the sandbox by sending a specific message from your WhatsApp number to the provided Vonage sandbox number. This allowlists your number for testing.
Copy the Vonage Sandbox WhatsApp number (e.g., 14157386102) and save it as VONAGE_WHATSAPP_NUMBER in your .env file.
Leave Sandbox Webhooks blank for now. We'll update these with ngrok URLs.
Account Verification Requirements:
Activation Time: Sandbox access is immediate. Production WhatsApp Business API accounts require business verification (typically 1–3 business days).
Phone Verification: You must verify ownership of the phone number you'll use in production.
Business Profile: Complete your business profile information before going live.
Production WhatsApp Business API Requirements:
Phone Number Restrictions: The phone number cannot already be used on WhatsApp (personal or business app). Each business phone number can associate with only one API/BSP (Business Solution Provider) at a time. Meta limits businesses to 2 business phone numbers across all WhatsApp Business Accounts (WABAs) during initial registration.
Messaging Limits: Initially, you can send business-initiated conversations to 250 unique customers in a rolling 24-hour period. This scales automatically based on phone number status, quality rating, and conversation frequency. Customer-initiated conversations (24-hour messaging windows) are unlimited.
Business Verification: Optional but recommended to increase messaging limits. You can start messaging without completing business verification or display name reviews.
Facebook Business Manager: Required for production onboarding. You need access to your business's Facebook Business Manager Account.
Geographic Restrictions: WhatsApp Business Platform cannot send/receive messages to/from Ukraine (Crimea, Donetsk, Luhansk), Cuba, Iran, North Korea, or Syria.
Pricing Model Change (2025): Meta is transitioning from Conversation Based Pricing (CBP) to Per Message Price (PMP) effective July 1, 2025. Vonage Platform Fees updated October 1, 2025.
Cost Estimation:
Category
Cost Estimate
Notes
Sandbox Testing
Free
Includes 10 EUR test credit
Customer-Initiated Messages
USD 0.005–0.04/message
Varies by country
Business-Initiated Messages
USD 0.01–0.15/message
Higher cost, country-dependent
Template Message Reviews
Free
1–3 business day approval
1.7. Start ngrok
Open a new terminal window and run ngrok to expose port 3000:
Create a dedicated module and service to handle interactions with the Vonage SDK.
2.1. Generate Vonage Module and Service
Use the NestJS CLI to generate the necessary files:
Bash
nest g module vonage
nest g service vonage --no-spec # --no-spec skips generating a test file for now
This creates src/vonage/vonage.module.ts and src/vonage/vonage.service.ts.
2.2. Configure VonageModule
Set up the module to export the service:
TypeScript
// src/vonage/vonage.module.tsimport { Module } from'@nestjs/common';
import { VonageService } from'./vonage.service';
import { ConfigModule } from'@nestjs/config';
@Module({
imports: [ConfigModule], // Ensure ConfigService is availableproviders: [VonageService],
exports: [VonageService], // Export the service so other modules can use it
})
exportclassVonageModule {}
2.3. Implement VonageService
This service initializes the Vonage SDK client using credentials from environment variables and provides methods to send WhatsApp messages.
TypeScript
// src/vonage/vonage.service.tsimport { Injectable, Logger, OnModuleInit } from'@nestjs/common';
import { ConfigService } from'@nestjs/config';
import { Vonage } from'@vonage/server-sdk';
import { WhatsAppText } from'@vonage/messages';
import * as fs from'fs';
@Injectable()
exportclassVonageServiceimplementsOnModuleInit {
privatereadonly logger = newLogger(VonageService.name);
privatevonageClient: Vonage;
privatevonageWhatsAppNumber: string;
privatereadonlyMAX_RETRIES = 3;
privatereadonlyRETRY_DELAY_MS = 1000;
constructor(privateconfigService: ConfigService) {}
// Initialize the client when the module loadsonModuleInit() {
try {
const apiKey = this.configService.get<string>('VONAGE_API_KEY');
const apiSecret = this.configService.get<string>('VONAGE_API_SECRET');
const applicationId = this.configService.get<string>(
'VONAGE_APPLICATION_ID',
);
const privateKeyPath = this.configService.get<string>(
'VONAGE_PRIVATE_KEY_PATH',
);
// Check for optional sandbox host, default to production if not setconst apiHost = this.configService.get<string>('VONAGE_API_HOST');
this.vonageWhatsAppNumber = this.configService.get<string>(
'VONAGE_WHATSAPP_NUMBER',
);
// Validate required configif (
!apiKey ||
!apiSecret ||
!applicationId ||
!privateKeyPath ||
!this.vonageWhatsAppNumber
) {
thrownewError('Missing required Vonage configuration in .env file');
}
// Ensure private key file existsif (!fs.existsSync(privateKeyPath)) {
thrownewError(`Private key file not found at: ${privateKeyPath}`);
}
const privateKey = fs.readFileSync(privateKeyPath);
this.vonageClient = newVonage(
{
apiKey: apiKey,
apiSecret: apiSecret,
applicationId: applicationId,
privateKey: privateKey,
},
// Optional: Specify API host for sandbox or specific region// If VONAGE_API_HOST is not set in .env, it defaults to production
apiHost ? { apiHost: apiHost } : {},
);
this.logger.log('Vonage client initialized successfully.');
if (apiHost) {
this.logger.warn(`Vonage client using API host: ${apiHost}`);
} else {
this.logger.log('Vonage client using default production API host.');
}
} catch (error) {
this.logger.error('Failed to initialize Vonage client:', error.message);
thrownewError(`Vonage initialization failed: ${error.message}`);
}
}
/**
* Sends a WhatsApp text message with automatic retry logic.
* @param to The recipient's phone number (E.164 format, without leading '+').
* @param text The message content.
* @returns The message UUID on success.
* @throws Error if sending fails after retries.
*/asyncsendWhatsAppTextMessage(to: string, text: string): Promise<string> {
if (!this.vonageClient) {
this.logger.error(
'Vonage client not initialized. Cannot send message.',
);
thrownewError('Vonage client not available.');
}
this.logger.log(`Attempting to send WhatsApp message to ${to}`);
for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) {
try {
const response = awaitthis.vonageClient.messages.send(
newWhatsAppText({
from: this.vonageWhatsAppNumber,
to: to, // E.164 format without '+'text: text,
}),
);
this.logger.log(
`Message sent successfully to ${to}. Message UUID: ${response.messageUuid}`,
);
return response.messageUuid;
} catch (error) {
// Check for rate limitingif (error.response?.status === 429) {
const retryAfter = error.response.headers['retry-after'];
const waitTime = retryAfter ? parseInt(retryAfter) * 1000 : this.RETRY_DELAY_MS * attempt;
this.logger.warn(
`Rate limited. Attempt ${attempt}/${this.MAX_RETRIES}. Waiting ${waitTime}ms before retry.`,
);
if (attempt < this.MAX_RETRIES) {
awaitthis.sleep(waitTime);
continue;
}
}
this.logger.error(`Error sending WhatsApp message to ${to}:`, error);
// Log detailed error info if availableif (error.response?.data) {
this.logger.error('Vonage API Error details:', error.response.data);
}
// Re-throw on final attemptif (attempt === this.MAX_RETRIES) {
thrownewError(
`Failed to send WhatsApp message after ${this.MAX_RETRIES} attempts: ${
error.response?.data?.title || error.message
}`,
);
}
// Wait before retryawaitthis.sleep(this.RETRY_DELAY_MS * attempt);
}
}
}
/**
* Sends a WhatsApp message with media attachment.
* @param to The recipient's phone number (E.164 format, without leading '+').
* @param mediaUrl The URL of the media to send (must be publicly accessible).
* @param caption Optional caption for the media.
* @returns The message UUID on success.
*/asyncsendWhatsAppMediaMessage(
to: string,
mediaUrl: string,
caption?: string,
): Promise<string> {
if (!this.vonageClient) {
thrownewError('Vonage client not available.');
}
this.logger.log(`Sending WhatsApp media message to ${to}`);
try {
const response = awaitthis.vonageClient.messages.send({
channel: 'whatsapp',
message_type: 'image',
to: to,
from: this.vonageWhatsAppNumber,
image: {
url: mediaUrl,
caption: caption,
},
});
this.logger.log(
`Media message sent successfully to ${to}. Message UUID: ${response.messageUuid}`,
);
return response.messageUuid;
} catch (error) {
this.logger.error(`Error sending media message to ${to}:`, error);
thrownewError(
`Failed to send media message: ${
error.response?.data?.title || error.message
}`,
);
}
}
/**
* Sends a WhatsApp template message.
* @param to The recipient's phone number (E.164 format, without leading '+').
* @param templateName The name of the approved template.
* @param parameters Template parameters (if required).
* @returns The message UUID on success.
*/asyncsendWhatsAppTemplateMessage(
to: string,
templateName: string,
parameters: string[] = [],
): Promise<string> {
if (!this.vonageClient) {
thrownewError('Vonage client not available.');
}
this.logger.log(`Sending WhatsApp template "${templateName}" to ${to}`);
try {
const response = awaitthis.vonageClient.messages.send({
channel: 'whatsapp',
message_type: 'template',
to: to,
from: this.vonageWhatsAppNumber,
template: {
name: templateName,
parameters: parameters,
},
});
this.logger.log(
`Template message sent successfully to ${to}. Message UUID: ${response.messageUuid}`,
);
return response.messageUuid;
} catch (error) {
this.logger.error(`Error sending template message to ${to}:`, error);
thrownewError(
`Failed to send template message: ${
error.response?.data?.title || error.message
}`,
);
}
}
privatesleep(ms: number): Promise<void> {
returnnewPromise(resolve =>setTimeout(resolve, ms));
}
}
Key Features:
@Injectable(): Marks the class for NestJS dependency injection.
Logger: NestJS built-in logger for informative console output.
ConfigService: Injected to access environment variables safely.
OnModuleInit: Interface ensuring the onModuleInit method runs once the host module initializes. This sets up the Vonage client.
Initialization Logic: Reads credentials from .env, validates them, reads the private key file, and creates the Vonage instance. Includes error handling for missing configuration or files. Explicitly checks if VONAGE_API_HOST is set and logs whether it's using the sandbox or defaulting to production.
sendWhatsAppTextMessage: An async method that takes the recipient (to) number and message text. Uses the initialized vonageClient.messages.send method with a WhatsAppText object. Includes retry logic for rate limiting and robust error handling.
Media and Template Support: Additional methods for sending images, documents, and template messages.
3. Building the Webhook Handler
Create the module and controller to handle incoming webhook requests from Vonage.
3.1. Generate Webhooks Module and Controller
Bash
nest g module webhooks
nest g controller webhooks --no-spec
3.2. Configure WebhooksModule
This module needs access to VonageService to send replies:
This controller defines the /webhooks/inbound and /webhooks/status endpoints that Vonage calls. It handles POST requests, parses the JSON body, and verifies the JWT signature for security.
TypeScript
// src/webhooks/webhooks.controller.tsimport {
Controller,
Post,
Body,
Req,
Res,
HttpStatus,
Logger,
UnauthorizedException,
} from'@nestjs/common';
import { ConfigService } from'@nestjs/config';
import { Request, Response } from'express';
import { verifySignature } from'@vonage/jwt';
import { VonageService } from'../vonage/vonage.service';
import { InboundMessageDto } from'./dto/inbound-message.dto';
import { StatusUpdateDto } from'./dto/status-update.dto';
@Controller('webhooks') // Base path for all routes in this controllerexportclassWebhooksController {
privatereadonly logger = newLogger(WebhooksController.name);
privatereadonlysignatureSecret: string;
privatereadonly processedMessages = newSet<string>(); // Simple idempotency trackingconstructor(privateconfigService: ConfigService,
privatevonageService: VonageService, // Inject VonageService) {
this.signatureSecret = this.configService.get<string>(
'VONAGE_API_SIGNATURE_SECRET',
);
if (!this.signatureSecret) {
this.logger.error('VONAGE_API_SIGNATURE_SECRET is not set in .env');
thrownewError('Server configuration error: Missing signature secret.');
}
}
// --- Helper Method for Signature Verification ---privateverifyVonageSignature(req: Request): boolean {
try {
const authorizationHeader =
req.headers['authorization'] || req.headers['Authorization'];
if (!authorizationHeader || !authorizationHeader.startsWith('Bearer ')) {
this.logger.warn('Missing or invalid Authorization header');
returnfalse;
}
const token = authorizationHeader.split(' ')[1];
if (!token) {
this.logger.warn('Bearer token missing in Authorization header');
returnfalse;
}
const rawBody = (req asany).rawBody;
if (!rawBody) {
this.logger.error(
'Raw request body not available for signature verification. ' +
'Ensure rawBody is enabled in main.ts (Section 5.2).',
);
returnfalse;
}
// Verify the JWT signature against the raw request body bufferconst isSignatureValid = verifySignature(
token,
this.signatureSecret,
rawBody,
);
if (!isSignatureValid) {
this.logger.warn('Invalid JWT signature received');
returnfalse;
}
this.logger.log('Valid JWT signature verified using raw body');
returntrue;
} catch (error) {
this.logger.error('Error during signature verification:', error);
returnfalse;
}
}
// --- Inbound Message Webhook ---@Post('inbound')
asynchandleInboundMessage(@Body() body: InboundMessageDto,
@Req() req: Request,
@Res() res: Response,
) {
this.logger.log('Received inbound message webhook');
// 1. Verify Signature (CRITICAL FOR SECURITY)if (!this.verifyVonageSignature(req)) {
this.logger.error('Unauthorized inbound request: Invalid signature');
return res.status(HttpStatus.UNAUTHORIZED).send('Invalid signature');
}
// 2. Check for duplicate (idempotency)if (this.processedMessages.has(body.message_uuid)) {
this.logger.warn(
`Duplicate message received: ${body.message_uuid}. Ignoring.`,
);
return res.status(HttpStatus.OK).send(); // Still return 200 OK
}
// Mark as processedthis.processedMessages.add(body.message_uuid);
// Clean up old entries (simple approach for demo – use Redis in production)if (this.processedMessages.size > 10000) {
const iterator = this.processedMessages.values();
for (let i = 0; i < 1000; i++) {
this.processedMessages.delete(iterator.next().value);
}
}
// 3. Process the messageconst messageType = body.message_type;
const fromNumber = body.from.number;
const messageContent = body.message?.content?.text;
try {
switch (messageType) {
case'text':
if (fromNumber && messageContent) {
this.logger.log(
`Received text message "${messageContent}" from ${fromNumber}`,
);
// Example: Simple echo replyconst replyText = `You sent: "${messageContent}"`;
const messageUuid = awaitthis.vonageService.sendWhatsAppTextMessage(
fromNumber,
replyText,
);
this.logger.log(
`Reply sent to ${fromNumber}. Message UUID: ${messageUuid}`,
);
}
break;
case'image':
const imageUrl = body.message?.content?.image;
const caption = body.message?.content?.caption;
this.logger.log(
`Received image from ${fromNumber}: ${imageUrl} (Caption: ${caption || 'none'})`,
);
// Handle image processing herebreak;
case'location':
this.logger.log(`Received location share from ${fromNumber}`);
// Handle location data herebreak;
default:
this.logger.warn(
`Received unsupported message type: ${messageType} from ${fromNumber}`,
);
}
} catch (error) {
this.logger.error(`Failed to process message from ${fromNumber}:`, error);
// Still return 200 OK to prevent retries
}
// 4. Respond to Vonage (CRITICAL)// Always send a 200 OK quickly to acknowledge receipt
res.status(HttpStatus.OK).send();
}
// --- Message Status Webhook ---@Post('status')
handleMessageStatus(@Body() body: StatusUpdateDto,
@Req() req: Request,
@Res() res: Response,
) {
this.logger.log('Received message status webhook');
// 1. Verify Signature (CRITICAL FOR SECURITY)if (!this.verifyVonageSignature(req)) {
this.logger.error('Unauthorized status request: Invalid signature');
return res.status(HttpStatus.UNAUTHORIZED).send('Invalid signature');
}
// 2. Process the status updateconst messageUuid = body.message_uuid;
const status = body.status;
const timestamp = body.timestamp;
const toNumber = body.to.number;
this.logger.log(
`Status update for message ${messageUuid} to ${toNumber}: ${status} at ${timestamp}`,
);
// Add logic here: update message status in a database, trigger notifications, etc.switch (status) {
case'delivered':
this.logger.log(`Message ${messageUuid} delivered successfully.`);
break;
case'read':
this.logger.log(`Message ${messageUuid} read by recipient.`);
break;
case'failed':
case'rejected':
this.logger.error(
`Message ${messageUuid}${status}. Reason: ${
body.error?.reason || body.error?.code || 'Unknown'
}`,
body.error,
);
break;
default:
this.logger.log(`Message ${messageUuid} status: ${status}`);
}
// 3. Respond to Vonage (CRITICAL)
res.status(HttpStatus.OK).send();
}
}
Key Features:
@Controller('webhooks'): Defines the base route path.
@Post('inbound') / @Post('status'): Decorators to handle POST requests to /webhooks/inbound and /webhooks/status.
@Body(), @Req(), @Res(): Decorators to inject the validated request body (typed with DTOs), Express request object, and Express response object.
Signature Verification: Retrieves the Authorization: Bearer <token> header, extracts the JWT token, uses req.rawBody (configured in Section 5.2), and verifies with verifySignature(token, secret, rawBody).
Idempotency: Tracks processed message UUIDs to prevent duplicate processing.
Message Type Handling: Switch statement handles different message types (text, image, location).
Status Processing: Logs and processes different status types with specific handling for failures.
200 OK Response: Both handlers always send 200 OK to prevent Vonage from retrying.
4. Running and Testing the Application
4.1. Ensure ngrok is Running
Keep the ngrok http 3000 terminal window open.
4.2. Start the NestJS Application
In your main project terminal:
Bash
npm run start:dev
# Or using yarn:# yarn start:dev
Look for output indicating the Vonage client initialized and the server is listening:
log
[Nest] 12345 - 04/20/2025, 10:00:00 AM LOG [NestFactory] Starting Nest application…
[Nest] 12345 - 04/20/2025, 10:00:01 AM LOG [InstanceLoader] ConfigModule dependencies initialized
[Nest] 12345 - 04/20/2025, 10:00:02 AM LOG [VonageService] Vonage client initialized successfully.
[Nest] 12345 - 04/20/2025, 10:00:02 AM LOG [VonageService] Vonage client using default production API host.
[Nest] 12345 - 04/20/2025, 10:00:03 AM LOG [NestApplication] Nest application successfully started
4.3. Test Sending an Inbound Message
Open WhatsApp on the phone number you allowlisted in the Vonage Sandbox.
Send a message (e.g., "Hello NestJS!") to the Vonage Sandbox WhatsApp number (VONAGE_WHATSAPP_NUMBER from your .env).
4.4. Check Application Logs
You should see logs like:
log
[Nest] 12345 - 04/20/2025, 10:05:00 AM LOG [WebhooksController] Received inbound message webhook
[Nest] 12345 - 04/20/2025, 10:05:00 AM LOG [WebhooksController] Valid JWT signature verified using raw body
[Nest] 12345 - 04/20/2025, 10:05:00 AM LOG [WebhooksController] Received text message "Hello NestJS!" from 1xxxxxxxxxx
[Nest] 12345 - 04/20/2025, 10:05:00 AM LOG [VonageService] Attempting to send WhatsApp message to 1xxxxxxxxxx
[Nest] 12345 - 04/20/2025, 10:05:01 AM LOG [VonageService] Message sent successfully to 1xxxxxxxxxx. Message UUID: a1b2c3d4-e5f6-7890-abcd-ef1234567890
[Nest] 12345 - 04/20/2025, 10:05:01 AM LOG [WebhooksController] Reply sent to 1xxxxxxxxxx. Message UUID: a1b2c3d4-e5f6-7890-abcd-ef1234567890
4.5. Check Your WhatsApp
You should receive the reply message: "You sent: "Hello NestJS!""
4.6. Check Status Updates
You'll see status webhook logs:
log
[Nest] 12345 - 04/20/2025, 10:05:05 AM LOG [WebhooksController] Received message status webhook
[Nest] 12345 - 04/20/2025, 10:05:05 AM LOG [WebhooksController] Valid JWT signature verified using raw body
[Nest] 12345 - 04/20/2025, 10:05:05 AM LOG [WebhooksController] Status update for message a1b2c3d4-e5f6-7890-abcd-ef1234567890 to 1xxxxxxxxxx: submitted at …
[Nest] 12345 - 04/20/2025, 10:05:06 AM LOG [WebhooksController] Received message status webhook
[Nest] 12345 - 04/20/2025, 10:05:06 AM LOG [WebhooksController] Valid JWT signature verified using raw body
[Nest] 12345 - 04/20/2025, 10:05:06 AM LOG [WebhooksController] Status update for message a1b2c3d4-e5f6-7890-abcd-ef1234567890 to 1xxxxxxxxxx: delivered at …
[Nest] 12345 - 04/20/2025, 10:05:07 AM LOG [WebhooksController] Message a1b2c3d4-e5f6-7890-abcd-ef1234567890 delivered successfully.
Troubleshooting Common Issues:
Issue
Cause
Solution
"Unauthorized: Invalid signature"
Raw body not configured
Complete Section 5.2
"Private key file not found"
Incorrect path in .env
Verify VONAGE_PRIVATE_KEY_PATH points to private.key
No webhook received
ngrok URL not updated
Update webhook URLs in Vonage dashboard
Rate limit errors
Too many requests
Implement exponential backoff (already in service)
"Vonage client not available"
Initialization failed
Check .env variables and logs
5. Enhancements for Production Readiness
The current setup works for development, but production applications require additional robustness.
5.1. Enable Request Validation with ValidationPipe
Configure NestJS to automatically validate webhook payloads using the DTOs we created.
Update your src/main.ts file:
TypeScript
// src/main.tsimport { NestFactory } from'@nestjs/core';
import { AppModule } from'./app.module';
import { Logger, ValidationPipe } from'@nestjs/common';
import { ConfigService } from'@nestjs/config';
asyncfunctionbootstrap() {
// Enable rawBody parsing for webhook signature verificationconst app = awaitNestFactory.create(AppModule, {
rawBody: true, // <<< Enable Raw Body
});
const configService = app.get(ConfigService);
const port = configService.get<number>('PORT', 3000);
// Enable global validation pipe
app.useGlobalPipes(
newValidationPipe({
whitelist: true, // Strip properties not in DTOtransform: true, // Automatically transform payloads to DTO instancesforbidNonWhitelisted: true, // Throw error if unknown properties are receivedtransformOptions: {
enableImplicitConversion: true, // Allow basic type conversions
},
}),
);
// Optional: Enable CORS if your setup requires it// app.enableCors();await app.listen(port);
Logger.log(`Application is running on: http://localhost:${port}`);
}
bootstrap();
What This Does:
rawBody: true: Preserves the raw request body buffer needed for JWT signature verification.
whitelist: true: Removes properties not defined in your DTOs, protecting against injection attacks.
transform: true: Converts plain JavaScript objects to DTO class instances.
forbidNonWhitelisted: true: Rejects requests with unexpected properties.
5.2. Configure Raw Body for Signature Verification
The rawBody: true option in NestFactory.create() automatically makes the raw body available at req.rawBody. This is required for the verifyVonageSignature method to work correctly.
Verification:
Add this log in your WebhooksController constructor to verify raw body is available:
TypeScript
constructor(privateconfigService: ConfigService,
privatevonageService: VonageService,
) {
this.signatureSecret = this.configService.get<string>(
'VONAGE_API_SIGNATURE_SECRET',
);
if (!this.signatureSecret) {
this.logger.error('VONAGE_API_SIGNATURE_SECRET is not set in .env');
thrownewError('Server configuration error: Missing signature secret.');
}
// Verification: This will log when the controller initializesthis.logger.log('WebhooksController initialized with signature verification enabled');
}
5.3. Implement Database Persistence
For production, persist message data and status updates to a database.
✓ Enable webhook signature verification (already implemented)
✓ Use HTTPS in production (remove ngrok)
✓ Validate all input with DTOs
✓ Implement rate limiting
✓ Use environment variables for secrets
✓ Enable CORS with specific origins only
✓ Implement request timeout limits
✓ Add helmet for security headers:
Use the Vonage Messages API with a Node.js framework like NestJS. This setup allows you to send WhatsApp messages programmatically by initializing the Vonage Node.js SDK and calling the messages.send method with a properly formatted payload including the recipient's number and your message content. This guide provides a step-by-step tutorial on how to implement such a service.
The Vonage Messages API is a service provided by Vonage (formerly Nexmo) for sending and receiving messages across various channels, including WhatsApp. It handles the complexities of communicating with the WhatsApp platform, providing a simplified interface for developers to integrate messaging into their applications. You can send different types of messages, including text, images, files, and templates.
NestJS offers benefits like structured architecture via modules, dependency injection, and tools for building scalable Node.js applications. These features make the WhatsApp service easier to organize, test, and maintain, especially as the project grows more complex.
You will need Node.js version 18 or higher, a package manager (npm or yarn), a Vonage API account, ngrok for local development, and a WhatsApp-enabled device for testing. The Vonage API account is required for utilizing their service, ngrok creates a public URL for your local server during testing, and a device is needed for end-to-end verification.
Obtain your API key and secret from the Vonage API Dashboard, generate a private key when creating a Vonage application, and create an API key signature secret in Settings for webhook security. These credentials should be stored securely, such as in environment variables (.env file) and never exposed in code repositories.
Set up webhooks in your Vonage application dashboard and handle inbound messages in NestJS. Vonage forwards incoming messages to your specified endpoint, which you can handle using a controller and service within your application's logic.
ngrok creates a temporary public URL that tunnels to your locally running NestJS server, allowing Vonage webhooks to reach your development environment. This is important because Vonage needs a public HTTPS endpoint to send webhook requests.
Vonage uses JWT signatures to ensure webhooks originate from them. Verify this signature using @vonage/jwt package to prevent unauthorized requests from reaching your webhook endpoints. This is critical to prevent security vulnerabilities.
Use the /webhooks/status endpoint to receive updates on message delivery, read status, or failures. By processing status updates, you gain valuable insights into the message lifecycle, allowing you to keep your application informed of successes or issues.
Log into the Vonage API Dashboard, go to 'Applications', and click 'Create a new application'. Provide a name, generate public and private keys (download and securely store the private key), enable the Messages capability, and set the webhook URLs for inbound messages and status updates.
Install 'class-validator' and 'class-transformer' packages, define DTO classes with validation decorators, and enable a global ValidationPipe in your NestJS application. DTOs enhance data integrity and security by ensuring webhook data conforms to your expected structure.
The .env file stores sensitive information like API keys, secrets, and application IDs, allowing you to keep these values out of your codebase. It's important for security best practices and should be added to .gitignore to prevent it from being accidentally committed to version control.
The complete, working project code from this tutorial is available on a public GitHub repository (linked in the article). You can refer to it as a reference implementation or use it as a starting point for your own WhatsApp project.
This guide walks you through building a robust WhatsApp messaging service using Node.js with the NestJS framework and the Vonage Messages API (Application Programming Interface). You'll learn to set up your environment, send messages, receive incoming messages via webhooks, handle message status updates, and implement production considerations like security, error handling, and deployment.
By completing this tutorial, you'll have a functional NestJS application that can:
This guide assumes basic knowledge of Node.js, TypeScript, and REST APIs (Representational State Transfer Application Programming Interfaces). NestJS provides structured architecture, dependency injection, and modularity that simplify building scalable applications. Vonage provides the communication infrastructure for sending and receiving WhatsApp messages.
Prerequisites:
Verify Your Installation:
Confirm your Node.js version meets requirements:
node --version # Should show v20.x.x or v22.x.x npm --version # Verify npm is installed ngrok version # Confirm ngrok is availableGitHub Repository:
Find a complete, working example of the code in this guide at [repository URL placeholder – update with actual URL].
Project Architecture
The system consists of the following components:
1. Setting Up the NestJS Project
Initialize a new NestJS project and install the necessary dependencies.
1.1. Install NestJS CLI
1.2. Create a New NestJS Project
nest new vonage-whatsapp-nestjs cd vonage-whatsapp-nestjsChoose your preferred package manager (npm or yarn) when prompted.
1.3. Install Required Dependencies
Install the Vonage SDK packages and configuration modules:
npm install @vonage/server-sdk @vonage/messages @vonage/jwt @nestjs/config dotenv # Or using yarn: # yarn add @vonage/server-sdk @vonage/messages @vonage/jwt @nestjs/config dotenvWhat Each Package Does:
@vonage/server-sdk– Core Vonage SDK for Node.js@vonage/messages– Messages API client for sending WhatsApp, SMS, and other messages@vonage/jwt– JWT verification for webhook signature validation@nestjs/config– NestJS module for managing environment variablesdotenv– Loads environment variables from.envfilesPackage Compatibility:
1.4. Set Up Environment Variables
Create a
.envfile in the root of your project. This file stores your Vonage credentials and configuration. Never commit this file to version control.Secure Your Credentials:
Add these entries to your
.gitignorefile:1.5. Configure NestJS
ConfigModuleModify your main application module (
src/app.module.ts) to load environment variables:// src/app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { VonageModule } from './vonage/vonage.module'; // We will create this import { WebhooksModule } from './webhooks/webhooks.module'; // We will create this @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, // Make ConfigModule available globally envFilePath: '.env', // Load variables from .env file }), VonageModule, // Add Vonage module WebhooksModule, // Add Webhooks module ], controllers: [AppController], // Default controller providers: [AppService], // Default service }) export class AppModule {}1.6. Vonage Account and Application Setup
Configure your Vonage account and application before proceeding.
Step 1: Access Your API Credentials
VONAGE_API_KEYandVONAGE_API_SECRETon the main dashboard page.VONAGE_API_SIGNATURE_SECRET.Step 2: Create a Vonage Application
private.keyfile downloads automatically. Save this file in your NestJS project root directory. UpdateVONAGE_PRIVATE_KEY_PATHin your.envfile if you save it elsewhere.VONAGE_APPLICATION_IDin your.envfile.Step 3: Set Up WhatsApp Sandbox for Testing
14157386102) and save it asVONAGE_WHATSAPP_NUMBERin your.envfile.Account Verification Requirements:
Production WhatsApp Business API Requirements:
Cost Estimation:
1.7. Start ngrok
Open a new terminal window and run ngrok to expose port 3000:
Authenticate ngrok (First-Time Setup):
If you haven't authenticated ngrok:
ngrok config add-authtoken YOUR_TOKEN_HEREngrok displays a forwarding URL (e.g.,
https://<unique-subdomain>.ngrok.ioorhttps://<unique-subdomain>.ngrok-free.app). Copy this HTTPS URL.Production Alternatives:
For production deployments, replace ngrok with:
1.8. Update Vonage Webhook URLs
Return to your Vonage dashboard and configure webhook endpoints:
Application Webhooks:
YOUR_NGROK_HTTPS_URL/webhooks/inboundYOUR_NGROK_HTTPS_URL/webhooks/statusSandbox Webhooks:
YOUR_NGROK_HTTPS_URL/webhooks/inboundYOUR_NGROK_HTTPS_URL/webhooks/statusVonage now knows where to send incoming messages and status updates for your application and sandbox environment.
2. Implementing Core Functionality (Vonage Service)
Create a dedicated module and service to handle interactions with the Vonage SDK.
2.1. Generate Vonage Module and Service
Use the NestJS CLI to generate the necessary files:
nest g module vonage nest g service vonage --no-spec # --no-spec skips generating a test file for nowThis creates
src/vonage/vonage.module.tsandsrc/vonage/vonage.service.ts.2.2. Configure
VonageModuleSet up the module to export the service:
// src/vonage/vonage.module.ts import { Module } from '@nestjs/common'; import { VonageService } from './vonage.service'; import { ConfigModule } from '@nestjs/config'; @Module({ imports: [ConfigModule], // Ensure ConfigService is available providers: [VonageService], exports: [VonageService], // Export the service so other modules can use it }) export class VonageModule {}2.3. Implement
VonageServiceThis service initializes the Vonage SDK client using credentials from environment variables and provides methods to send WhatsApp messages.
// src/vonage/vonage.service.ts import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Vonage } from '@vonage/server-sdk'; import { WhatsAppText } from '@vonage/messages'; import * as fs from 'fs'; @Injectable() export class VonageService implements OnModuleInit { private readonly logger = new Logger(VonageService.name); private vonageClient: Vonage; private vonageWhatsAppNumber: string; private readonly MAX_RETRIES = 3; private readonly RETRY_DELAY_MS = 1000; constructor(private configService: ConfigService) {} // Initialize the client when the module loads onModuleInit() { try { const apiKey = this.configService.get<string>('VONAGE_API_KEY'); const apiSecret = this.configService.get<string>('VONAGE_API_SECRET'); const applicationId = this.configService.get<string>( 'VONAGE_APPLICATION_ID', ); const privateKeyPath = this.configService.get<string>( 'VONAGE_PRIVATE_KEY_PATH', ); // Check for optional sandbox host, default to production if not set const apiHost = this.configService.get<string>('VONAGE_API_HOST'); this.vonageWhatsAppNumber = this.configService.get<string>( 'VONAGE_WHATSAPP_NUMBER', ); // Validate required config if ( !apiKey || !apiSecret || !applicationId || !privateKeyPath || !this.vonageWhatsAppNumber ) { throw new Error('Missing required Vonage configuration in .env file'); } // Ensure private key file exists if (!fs.existsSync(privateKeyPath)) { throw new Error(`Private key file not found at: ${privateKeyPath}`); } const privateKey = fs.readFileSync(privateKeyPath); this.vonageClient = new Vonage( { apiKey: apiKey, apiSecret: apiSecret, applicationId: applicationId, privateKey: privateKey, }, // Optional: Specify API host for sandbox or specific region // If VONAGE_API_HOST is not set in .env, it defaults to production apiHost ? { apiHost: apiHost } : {}, ); this.logger.log('Vonage client initialized successfully.'); if (apiHost) { this.logger.warn(`Vonage client using API host: ${apiHost}`); } else { this.logger.log('Vonage client using default production API host.'); } } catch (error) { this.logger.error('Failed to initialize Vonage client:', error.message); throw new Error(`Vonage initialization failed: ${error.message}`); } } /** * Sends a WhatsApp text message with automatic retry logic. * @param to The recipient's phone number (E.164 format, without leading '+'). * @param text The message content. * @returns The message UUID on success. * @throws Error if sending fails after retries. */ async sendWhatsAppTextMessage(to: string, text: string): Promise<string> { if (!this.vonageClient) { this.logger.error( 'Vonage client not initialized. Cannot send message.', ); throw new Error('Vonage client not available.'); } this.logger.log(`Attempting to send WhatsApp message to ${to}`); for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) { try { const response = await this.vonageClient.messages.send( new WhatsAppText({ from: this.vonageWhatsAppNumber, to: to, // E.164 format without '+' text: text, }), ); this.logger.log( `Message sent successfully to ${to}. Message UUID: ${response.messageUuid}`, ); return response.messageUuid; } catch (error) { // Check for rate limiting if (error.response?.status === 429) { const retryAfter = error.response.headers['retry-after']; const waitTime = retryAfter ? parseInt(retryAfter) * 1000 : this.RETRY_DELAY_MS * attempt; this.logger.warn( `Rate limited. Attempt ${attempt}/${this.MAX_RETRIES}. Waiting ${waitTime}ms before retry.`, ); if (attempt < this.MAX_RETRIES) { await this.sleep(waitTime); continue; } } this.logger.error(`Error sending WhatsApp message to ${to}:`, error); // Log detailed error info if available if (error.response?.data) { this.logger.error('Vonage API Error details:', error.response.data); } // Re-throw on final attempt if (attempt === this.MAX_RETRIES) { throw new Error( `Failed to send WhatsApp message after ${this.MAX_RETRIES} attempts: ${ error.response?.data?.title || error.message }`, ); } // Wait before retry await this.sleep(this.RETRY_DELAY_MS * attempt); } } } /** * Sends a WhatsApp message with media attachment. * @param to The recipient's phone number (E.164 format, without leading '+'). * @param mediaUrl The URL of the media to send (must be publicly accessible). * @param caption Optional caption for the media. * @returns The message UUID on success. */ async sendWhatsAppMediaMessage( to: string, mediaUrl: string, caption?: string, ): Promise<string> { if (!this.vonageClient) { throw new Error('Vonage client not available.'); } this.logger.log(`Sending WhatsApp media message to ${to}`); try { const response = await this.vonageClient.messages.send({ channel: 'whatsapp', message_type: 'image', to: to, from: this.vonageWhatsAppNumber, image: { url: mediaUrl, caption: caption, }, }); this.logger.log( `Media message sent successfully to ${to}. Message UUID: ${response.messageUuid}`, ); return response.messageUuid; } catch (error) { this.logger.error(`Error sending media message to ${to}:`, error); throw new Error( `Failed to send media message: ${ error.response?.data?.title || error.message }`, ); } } /** * Sends a WhatsApp template message. * @param to The recipient's phone number (E.164 format, without leading '+'). * @param templateName The name of the approved template. * @param parameters Template parameters (if required). * @returns The message UUID on success. */ async sendWhatsAppTemplateMessage( to: string, templateName: string, parameters: string[] = [], ): Promise<string> { if (!this.vonageClient) { throw new Error('Vonage client not available.'); } this.logger.log(`Sending WhatsApp template "${templateName}" to ${to}`); try { const response = await this.vonageClient.messages.send({ channel: 'whatsapp', message_type: 'template', to: to, from: this.vonageWhatsAppNumber, template: { name: templateName, parameters: parameters, }, }); this.logger.log( `Template message sent successfully to ${to}. Message UUID: ${response.messageUuid}`, ); return response.messageUuid; } catch (error) { this.logger.error(`Error sending template message to ${to}:`, error); throw new Error( `Failed to send template message: ${ error.response?.data?.title || error.message }`, ); } } private sleep(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms)); } }Key Features:
@Injectable(): Marks the class for NestJS dependency injection.Logger: NestJS built-in logger for informative console output.ConfigService: Injected to access environment variables safely.OnModuleInit: Interface ensuring theonModuleInitmethod runs once the host module initializes. This sets up theVonageclient..env, validates them, reads the private key file, and creates theVonageinstance. Includes error handling for missing configuration or files. Explicitly checks ifVONAGE_API_HOSTis set and logs whether it's using the sandbox or defaulting to production.sendWhatsAppTextMessage: Anasyncmethod that takes the recipient (to) number and messagetext. Uses the initializedvonageClient.messages.sendmethod with aWhatsAppTextobject. Includes retry logic for rate limiting and robust error handling.3. Building the Webhook Handler
Create the module and controller to handle incoming webhook requests from Vonage.
3.1. Generate Webhooks Module and Controller
3.2. Configure
WebhooksModuleThis module needs access to
VonageServiceto send replies:// src/webhooks/webhooks.module.ts import { Module } from '@nestjs/common'; import { WebhooksController } from './webhooks.controller'; import { ConfigModule } from '@nestjs/config'; import { VonageModule } from '../vonage/vonage.module'; // Import VonageModule @Module({ imports: [ ConfigModule, // For accessing signature secret VonageModule, // Make VonageService available ], controllers: [WebhooksController], }) export class WebhooksModule {}3.3. Create DTO Classes for Validation
Create Data Transfer Objects (DTOs) to validate webhook payloads:
Inbound Message DTO:
// src/webhooks/dto/inbound-message.dto.ts import { IsString, IsNotEmpty, IsOptional, ValidateNested, IsEnum, } from 'class-validator'; import { Type } from 'class-transformer'; class FromDto { @IsString() @IsNotEmpty() type: string; @IsString() @IsNotEmpty() number: string; } class MessageContentDto { @IsString() @IsOptional() text?: string; @IsString() @IsOptional() image?: string; @IsString() @IsOptional() caption?: string; } class MessageDto { @ValidateNested() @Type(() => MessageContentDto) @IsOptional() content?: MessageContentDto; } export class InboundMessageDto { @IsString() @IsNotEmpty() message_uuid: string; @IsString() @IsNotEmpty() @IsEnum(['text', 'image', 'audio', 'video', 'file', 'location', 'button', 'list']) message_type: string; @IsString() @IsNotEmpty() timestamp: string; @ValidateNested() @Type(() => FromDto) from: FromDto; @ValidateNested() @Type(() => MessageDto) @IsOptional() message?: MessageDto; @IsString() @IsOptional() channel?: string; }Status Update DTO:
// src/webhooks/dto/status-update.dto.ts import { IsString, IsNotEmpty, IsOptional, ValidateNested, IsEnum, } from 'class-validator'; import { Type } from 'class-transformer'; class ToDto { @IsString() @IsNotEmpty() type: string; @IsString() @IsNotEmpty() number: string; } class ErrorDto { @IsString() @IsOptional() code?: string; @IsString() @IsOptional() reason?: string; } export class StatusUpdateDto { @IsString() @IsNotEmpty() message_uuid: string; @IsString() @IsNotEmpty() @IsEnum(['submitted', 'delivered', 'read', 'failed', 'rejected', 'undeliverable']) status: string; @IsString() @IsNotEmpty() timestamp: string; @ValidateNested() @Type(() => ToDto) to: ToDto; @ValidateNested() @Type(() => ErrorDto) @IsOptional() error?: ErrorDto; @IsString() @IsOptional() channel?: string; }3.4. Implement
WebhooksControllerThis controller defines the
/webhooks/inboundand/webhooks/statusendpoints that Vonage calls. It handles POST requests, parses the JSON body, and verifies the JWT signature for security.// src/webhooks/webhooks.controller.ts import { Controller, Post, Body, Req, Res, HttpStatus, Logger, UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Request, Response } from 'express'; import { verifySignature } from '@vonage/jwt'; import { VonageService } from '../vonage/vonage.service'; import { InboundMessageDto } from './dto/inbound-message.dto'; import { StatusUpdateDto } from './dto/status-update.dto'; @Controller('webhooks') // Base path for all routes in this controller export class WebhooksController { private readonly logger = new Logger(WebhooksController.name); private readonly signatureSecret: string; private readonly processedMessages = new Set<string>(); // Simple idempotency tracking constructor( private configService: ConfigService, private vonageService: VonageService, // Inject VonageService ) { this.signatureSecret = this.configService.get<string>( 'VONAGE_API_SIGNATURE_SECRET', ); if (!this.signatureSecret) { this.logger.error('VONAGE_API_SIGNATURE_SECRET is not set in .env'); throw new Error('Server configuration error: Missing signature secret.'); } } // --- Helper Method for Signature Verification --- private verifyVonageSignature(req: Request): boolean { try { const authorizationHeader = req.headers['authorization'] || req.headers['Authorization']; if (!authorizationHeader || !authorizationHeader.startsWith('Bearer ')) { this.logger.warn('Missing or invalid Authorization header'); return false; } const token = authorizationHeader.split(' ')[1]; if (!token) { this.logger.warn('Bearer token missing in Authorization header'); return false; } const rawBody = (req as any).rawBody; if (!rawBody) { this.logger.error( 'Raw request body not available for signature verification. ' + 'Ensure rawBody is enabled in main.ts (Section 5.2).', ); return false; } // Verify the JWT signature against the raw request body buffer const isSignatureValid = verifySignature( token, this.signatureSecret, rawBody, ); if (!isSignatureValid) { this.logger.warn('Invalid JWT signature received'); return false; } this.logger.log('Valid JWT signature verified using raw body'); return true; } catch (error) { this.logger.error('Error during signature verification:', error); return false; } } // --- Inbound Message Webhook --- @Post('inbound') async handleInboundMessage( @Body() body: InboundMessageDto, @Req() req: Request, @Res() res: Response, ) { this.logger.log('Received inbound message webhook'); // 1. Verify Signature (CRITICAL FOR SECURITY) if (!this.verifyVonageSignature(req)) { this.logger.error('Unauthorized inbound request: Invalid signature'); return res.status(HttpStatus.UNAUTHORIZED).send('Invalid signature'); } // 2. Check for duplicate (idempotency) if (this.processedMessages.has(body.message_uuid)) { this.logger.warn( `Duplicate message received: ${body.message_uuid}. Ignoring.`, ); return res.status(HttpStatus.OK).send(); // Still return 200 OK } // Mark as processed this.processedMessages.add(body.message_uuid); // Clean up old entries (simple approach for demo – use Redis in production) if (this.processedMessages.size > 10000) { const iterator = this.processedMessages.values(); for (let i = 0; i < 1000; i++) { this.processedMessages.delete(iterator.next().value); } } // 3. Process the message const messageType = body.message_type; const fromNumber = body.from.number; const messageContent = body.message?.content?.text; try { switch (messageType) { case 'text': if (fromNumber && messageContent) { this.logger.log( `Received text message "${messageContent}" from ${fromNumber}`, ); // Example: Simple echo reply const replyText = `You sent: "${messageContent}"`; const messageUuid = await this.vonageService.sendWhatsAppTextMessage( fromNumber, replyText, ); this.logger.log( `Reply sent to ${fromNumber}. Message UUID: ${messageUuid}`, ); } break; case 'image': const imageUrl = body.message?.content?.image; const caption = body.message?.content?.caption; this.logger.log( `Received image from ${fromNumber}: ${imageUrl} (Caption: ${caption || 'none'})`, ); // Handle image processing here break; case 'location': this.logger.log(`Received location share from ${fromNumber}`); // Handle location data here break; default: this.logger.warn( `Received unsupported message type: ${messageType} from ${fromNumber}`, ); } } catch (error) { this.logger.error(`Failed to process message from ${fromNumber}:`, error); // Still return 200 OK to prevent retries } // 4. Respond to Vonage (CRITICAL) // Always send a 200 OK quickly to acknowledge receipt res.status(HttpStatus.OK).send(); } // --- Message Status Webhook --- @Post('status') handleMessageStatus( @Body() body: StatusUpdateDto, @Req() req: Request, @Res() res: Response, ) { this.logger.log('Received message status webhook'); // 1. Verify Signature (CRITICAL FOR SECURITY) if (!this.verifyVonageSignature(req)) { this.logger.error('Unauthorized status request: Invalid signature'); return res.status(HttpStatus.UNAUTHORIZED).send('Invalid signature'); } // 2. Process the status update const messageUuid = body.message_uuid; const status = body.status; const timestamp = body.timestamp; const toNumber = body.to.number; this.logger.log( `Status update for message ${messageUuid} to ${toNumber}: ${status} at ${timestamp}`, ); // Add logic here: update message status in a database, trigger notifications, etc. switch (status) { case 'delivered': this.logger.log(`Message ${messageUuid} delivered successfully.`); break; case 'read': this.logger.log(`Message ${messageUuid} read by recipient.`); break; case 'failed': case 'rejected': this.logger.error( `Message ${messageUuid} ${status}. Reason: ${ body.error?.reason || body.error?.code || 'Unknown' }`, body.error, ); break; default: this.logger.log(`Message ${messageUuid} status: ${status}`); } // 3. Respond to Vonage (CRITICAL) res.status(HttpStatus.OK).send(); } }Key Features:
@Controller('webhooks'): Defines the base route path.@Post('inbound')/@Post('status'): Decorators to handle POST requests to/webhooks/inboundand/webhooks/status.@Body(),@Req(),@Res(): Decorators to inject the validated request body (typed with DTOs), Express request object, and Express response object.Authorization: Bearer <token>header, extracts the JWT token, usesreq.rawBody(configured in Section 5.2), and verifies withverifySignature(token, secret, rawBody).200 OKto prevent Vonage from retrying.4. Running and Testing the Application
4.1. Ensure ngrok is Running
Keep the
ngrok http 3000terminal window open.4.2. Start the NestJS Application
In your main project terminal:
npm run start:dev # Or using yarn: # yarn start:devLook for output indicating the Vonage client initialized and the server is listening:
4.3. Test Sending an Inbound Message
VONAGE_WHATSAPP_NUMBERfrom your.env).4.4. Check Application Logs
You should see logs like:
4.5. Check Your WhatsApp
You should receive the reply message: "You sent: "Hello NestJS!""
4.6. Check Status Updates
You'll see status webhook logs:
Troubleshooting Common Issues:
.envVONAGE_PRIVATE_KEY_PATHpoints toprivate.key.envvariables and logs5. Enhancements for Production Readiness
The current setup works for development, but production applications require additional robustness.
5.1. Enable Request Validation with ValidationPipe
Configure NestJS to automatically validate webhook payloads using the DTOs we created.
Update your
src/main.tsfile:// src/main.ts import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { Logger, ValidationPipe } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; async function bootstrap() { // Enable rawBody parsing for webhook signature verification const app = await NestFactory.create(AppModule, { rawBody: true, // <<< Enable Raw Body }); const configService = app.get(ConfigService); const port = configService.get<number>('PORT', 3000); // Enable global validation pipe app.useGlobalPipes( new ValidationPipe({ whitelist: true, // Strip properties not in DTO transform: true, // Automatically transform payloads to DTO instances forbidNonWhitelisted: true, // Throw error if unknown properties are received transformOptions: { enableImplicitConversion: true, // Allow basic type conversions }, }), ); // Optional: Enable CORS if your setup requires it // app.enableCors(); await app.listen(port); Logger.log(`Application is running on: http://localhost:${port}`); } bootstrap();What This Does:
rawBody: true: Preserves the raw request body buffer needed for JWT signature verification.whitelist: true: Removes properties not defined in your DTOs, protecting against injection attacks.transform: true: Converts plain JavaScript objects to DTO class instances.forbidNonWhitelisted: true: Rejects requests with unexpected properties.5.2. Configure Raw Body for Signature Verification
The
rawBody: trueoption inNestFactory.create()automatically makes the raw body available atreq.rawBody. This is required for theverifyVonageSignaturemethod to work correctly.Verification:
Add this log in your
WebhooksControllerconstructor to verify raw body is available:constructor( private configService: ConfigService, private vonageService: VonageService, ) { this.signatureSecret = this.configService.get<string>( 'VONAGE_API_SIGNATURE_SECRET', ); if (!this.signatureSecret) { this.logger.error('VONAGE_API_SIGNATURE_SECRET is not set in .env'); throw new Error('Server configuration error: Missing signature secret.'); } // Verification: This will log when the controller initializes this.logger.log('WebhooksController initialized with signature verification enabled'); }5.3. Implement Database Persistence
For production, persist message data and status updates to a database.
Install TypeORM and PostgreSQL Driver:
Create Message Entity:
// src/entities/message.entity.ts import { Entity, Column, PrimaryColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; @Entity('messages') export class Message { @PrimaryColumn() messageUuid: string; @Column() from: string; @Column() to: string; @Column() messageType: string; @Column('text', { nullable: true }) content: string; @Column({ default: 'submitted' }) status: string; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; @Column('jsonb', { nullable: true }) metadata: any; }Configure TypeORM in
app.module.ts:import { TypeOrmModule } from '@nestjs/typeorm'; import { Message } from './entities/message.entity'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env', }), TypeOrmModule.forRoot({ type: 'postgres', host: process.env.DB_HOST || 'localhost', port: parseInt(process.env.DB_PORT) || 5432, username: process.env.DB_USERNAME, password: process.env.DB_PASSWORD, database: process.env.DB_DATABASE, entities: [Message], synchronize: process.env.NODE_ENV !== 'production', // Disable in production }), TypeOrmModule.forFeature([Message]), VonageModule, WebhooksModule, ], // … }) export class AppModule {}5.4. Implement Queue-Based Processing
For high-volume applications, process webhooks asynchronously using a message queue.
Install Bull Queue:
Configure Bull Module:
// app.module.ts import { BullModule } from '@nestjs/bull'; @Module({ imports: [ // … BullModule.forRoot({ redis: { host: process.env.REDIS_HOST || 'localhost', port: parseInt(process.env.REDIS_PORT) || 6379, }, }), BullModule.registerQueue({ name: 'messages', }), // … ], }) export class AppModule {}Create Queue Processor:
// src/webhooks/message.processor.ts import { Processor, Process } from '@nestjs/bull'; import { Job } from 'bull'; import { Logger } from '@nestjs/common'; import { VonageService } from '../vonage/vonage.service'; @Processor('messages') export class MessageProcessor { private readonly logger = new Logger(MessageProcessor.name); constructor(private vonageService: VonageService) {} @Process('inbound') async handleInboundMessage(job: Job) { const { from, content } = job.data; this.logger.log(`Processing inbound message from ${from}`); try { const replyText = `You sent: "${content}"`; await this.vonageService.sendWhatsAppTextMessage(from, replyText); this.logger.log(`Reply sent to ${from}`); } catch (error) { this.logger.error(`Failed to process message from ${from}:`, error); throw error; // Bull will retry } } }5.5. Add Structured Logging
Replace
Loggerwith a structured logging solution like Winston or Pino.Install Winston:
Configure Winston:
// src/main.ts import { WinstonModule } from 'nest-winston'; import * as winston from 'winston'; async function bootstrap() { const app = await NestFactory.create(AppModule, { rawBody: true, logger: WinstonModule.createLogger({ transports: [ new winston.transports.Console({ format: winston.format.combine( winston.format.timestamp(), winston.format.json(), ), }), new winston.transports.File({ filename: 'logs/error.log', level: 'error', format: winston.format.json(), }), new winston.transports.File({ filename: 'logs/combined.log', format: winston.format.json(), }), ], }), }); // … }5.6. Implement Rate Limiting
Protect your API from abuse with rate limiting.
Install Rate Limiter:
Configure Throttler:
// app.module.ts import { ThrottlerModule } from '@nestjs/throttler'; @Module({ imports: [ ThrottlerModule.forRoot([{ ttl: 60000, // 60 seconds limit: 10, // 10 requests per minute }]), // … ], }) export class AppModule {}Apply to Webhooks:
// webhooks.controller.ts import { Throttle } from '@nestjs/throttler'; @Controller('webhooks') export class WebhooksController { @Throttle({ default: { limit: 100, ttl: 60000 } }) // 100 requests per minute @Post('inbound') async handleInboundMessage(/* … */) { // … } }5.7. Deployment Configuration
Environment Variables for Production:
Add these to your
.envfor production:Docker Configuration:
Create
Dockerfile:Docker Compose:
version: '3.8' services: app: build: . ports: - "3000:3000" env_file: - .env depends_on: - postgres - redis postgres: image: postgres:16-alpine environment: POSTGRES_DB: vonage_whatsapp POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres volumes: - postgres_data:/var/lib/postgresql/data redis: image: redis:7-alpine ports: - "6379:6379" volumes: postgres_data:5.8. Health Checks and Monitoring
Add Health Check Endpoint:
Create Health Controller:
// src/health/health.controller.ts import { Controller, Get } from '@nestjs/common'; import { HealthCheck, HealthCheckService, TypeOrmHealthIndicator } from '@nestjs/terminus'; @Controller('health') export class HealthController { constructor( private health: HealthCheckService, private db: TypeOrmHealthIndicator, ) {} @Get() @HealthCheck() check() { return this.health.check([ () => this.db.pingCheck('database'), ]); } }5.9. Security Hardening Checklist
✓ Enable webhook signature verification (already implemented) ✓ Use HTTPS in production (remove ngrok) ✓ Validate all input with DTOs ✓ Implement rate limiting ✓ Use environment variables for secrets ✓ Enable CORS with specific origins only ✓ Implement request timeout limits ✓ Add helmet for security headers:
// main.ts import helmet from 'helmet'; async function bootstrap() { const app = await NestFactory.create(AppModule, { rawBody: true }); app.use(helmet()); // … }6. Testing Your Application
6.1. Unit Tests
Create unit tests for your services:
// src/vonage/vonage.service.spec.ts import { Test, TestingModule } from '@nestjs/testing'; import { ConfigService } from '@nestjs/config'; import { VonageService } from './vonage.service'; describe('VonageService', () => { let service: VonageService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ VonageService, { provide: ConfigService, useValue: { get: jest.fn((key: string) => { const config = { VONAGE_API_KEY: 'test_key', VONAGE_API_SECRET: 'test_secret', VONAGE_APPLICATION_ID: 'test_app_id', VONAGE_PRIVATE_KEY_PATH: './test.key', VONAGE_WHATSAPP_NUMBER: '14155551234', }; return config[key]; }), }, }, ], }).compile(); service = module.get<VonageService>(VonageService); }); it('should be defined', () => { expect(service).toBeDefined(); }); });6.2. Integration Tests
Test webhook endpoints with real payloads:
// test/webhooks.e2e-spec.ts import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import * as request from 'supertest'; import { AppModule } from './../src/app.module'; describe('WebhooksController (e2e)', () => { let app: INestApplication; beforeEach(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).compile(); app = moduleFixture.createNestApplication(); await app.init(); }); it('/webhooks/inbound (POST)', () => { return request(app.getHttpServer()) .post('/webhooks/inbound') .send({ message_uuid: 'test-uuid-123', message_type: 'text', timestamp: '2025-04-20T10:00:00.000Z', from: { type: 'whatsapp', number: '14155551234', }, message: { content: { text: 'Test message', }, }, }) .expect(200); }); });6.3. Automated Testing with Postman
Create a Postman collection for manual and automated testing:
{ "info": { "name": "Vonage WhatsApp NestJS", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "item": [ { "name": "Inbound Webhook", "request": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"message_uuid\": \"{{$guid}}\",\n \"message_type\": \"text\",\n \"timestamp\": \"{{$timestamp}}\",\n \"from\": {\n \"type\": \"whatsapp\",\n \"number\": \"14155551234\"\n },\n \"message\": {\n \"content\": {\n \"text\": \"Hello World\"\n }\n }\n}" }, "url": { "raw": "{{base_url}}/webhooks/inbound", "host": ["{{base_url}}"], "path": ["webhooks", "inbound"] } } } ] }7. Production Deployment Guide
7.1. Deploy to Heroku
Install Heroku CLI:
Deploy:
heroku login heroku create vonage-whatsapp-app heroku addons:create heroku-postgresql:mini heroku addons:create heroku-redis:mini heroku config:set VONAGE_API_KEY=your_key heroku config:set VONAGE_API_SECRET=your_secret # … set other environment variables git push heroku main7.2. Deploy to AWS (Elastic Beanstalk)
Install EB CLI:
Initialize and Deploy:
7.3. Deploy to Google Cloud Run
Build and Deploy:
gcloud builds submit --tag gcr.io/PROJECT_ID/vonage-whatsapp gcloud run deploy vonage-whatsapp \ --image gcr.io/PROJECT_ID/vonage-whatsapp \ --platform managed \ --region us-central1 \ --allow-unauthenticated \ --set-env-vars "VONAGE_API_KEY=your_key,VONAGE_API_SECRET=your_secret"7.4. CI/CD with GitHub Actions
Create
.github/workflows/deploy.yml:name: Deploy to Production on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Setup Node.js uses: actions/setup-node@v3 with: node-version: '22' - name: Install dependencies run: npm ci - name: Run tests run: npm test - name: Build run: npm run build - name: Deploy to Heroku uses: akhileshns/heroku-deploy@v3.12.14 with: heroku_api_key: ${{secrets.HEROKU_API_KEY}} heroku_app_name: "vonage-whatsapp-app" heroku_email: "your-email@example.com"Conclusion
You now have a production-ready WhatsApp integration built with Vonage and NestJS. This implementation includes:
✓ Secure webhook handling with JWT signature verification ✓ Robust error handling and retry logic ✓ Database persistence for message tracking ✓ Queue-based asynchronous processing ✓ Comprehensive logging and monitoring ✓ Rate limiting and security hardening ✓ Deployment configurations for multiple platforms ✓ Automated testing and CI/CD pipelines
Next Steps:
For questions or issues, refer to: