This guide provides a complete walkthrough for building a robust WhatsApp messaging service using Node.js with the NestJS framework and the Vonage Messages API. You will learn how to set up your environment, send messages, receive incoming messages via webhooks, handle message status updates, and implement essential production considerations like security, error handling, and deployment.
By the end of this tutorial, you will have a functional NestJS application capable of:
- Sending WhatsApp messages programmatically via the Vonage Messages API.
- Receiving incoming WhatsApp messages through secure webhooks.
- Processing message status updates (e.g., delivered, read).
- Handling configuration securely and effectively.
This guide assumes you have a basic understanding of Node.js, TypeScript, and REST APIs. We use NestJS for its structured architecture, dependency injection, and modularity, which simplifies building scalable applications. Vonage provides the communication infrastructure for sending and receiving WhatsApp messages.
Prerequisites:
- Node.js (v18 or higher recommended)
- 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: ngrok is used here 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
GitHub Repository:
A complete, working example of the code in this guide is available on GitHub.
Project Architecture
The system consists of the following components:
- NestJS Application: The core backend service built with NestJS. It exposes endpoints to potentially trigger outgoing messages (though this guide focuses on receiving and replying) 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
Let's initialize a new NestJS project and install the necessary dependencies.
1.1. Install NestJS CLI (if you haven't already)
npm install -g @nestjs/cli
1.2. Create a New NestJS Project
nest new vonage-whatsapp-nestjs
cd vonage-whatsapp-nestjs
Choose your preferred package manager (npm or yarn) when prompted. This creates a standard NestJS project structure.
1.3. Install Required Dependencies
We need the Vonage Server SDK, Messages SDK, JWT SDK for verifying webhook signatures, the NestJS config module for environment variables, and dotenv
to load them from a file.
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
1.4. Set Up Environment Variables
Create a .env
file in the root of your project. This file will securely store your Vonage credentials and other configuration. Never commit this file to version control. Add a .gitignore
entry for .env
if it's not already there.
# .env
# Vonage API Credentials & Application Details
VONAGE_API_KEY=YOUR_VONAGE_API_KEY
VONAGE_API_SECRET=YOUR_VONAGE_API_SECRET
VONAGE_APPLICATION_ID=YOUR_VONAGE_APPLICATION_ID
VONAGE_PRIVATE_KEY_PATH=./private.key # Path relative to project root
VONAGE_API_SIGNATURE_SECRET=YOUR_VONAGE_SIGNATURE_SECRET # 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)
ConfigModule
1.5. Configure NestJS Modify your main application module (src/app.module.ts
) to load and make environment variables accessible throughout the application using @nestjs/config
.
// 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
Before proceeding, you need to configure your Vonage account and application.
- Log in to the Vonage API Dashboard.
- Find API Key and Secret: Your
VONAGE_API_KEY
andVONAGE_API_SECRET
are available on the main dashboard page.VONAGE_API_KEY
: Copy from Dashboard.VONAGE_API_SECRET
: Copy from Dashboard.
- Get Signature Secret: Navigate to Settings. Find the
API key signature secret
and copy it.VONAGE_API_SIGNATURE_SECRET
: Copy from Settings.
- Create a Vonage Application:
- Go to Applications -> Create a new application.
- Give it a name (e.g., ""NestJS WhatsApp Service"").
- Click Generate public and private key. A
private.key
file will be downloaded. Save this file in the root of your NestJS project directory. UpdateVONAGE_PRIVATE_KEY_PATH
in your.env
file if you save it elsewhere. - Enable the Messages capability. You'll need webhook URLs for this, which we'll get from ngrok next. Leave these blank for now, or enter dummy HTTPS URLs (e.g.,
https://example.com/inbound
,https://example.com/status
). We will update these later. - Click Generate new application.
- Copy the Application ID displayed.
VONAGE_APPLICATION_ID
: Copy from the Application details page.
- Set Up WhatsApp Sandbox (for Testing):
- Navigate to Messages API Sandbox in the dashboard sidebar.
- Follow the 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.
- Note the Vonage Sandbox WhatsApp number provided.
VONAGE_WHATSAPP_NUMBER
: Enter the sandbox number (e.g.,14157386102
) into your.env
file. For production, you'll replace this with your purchased Vonage virtual number linked to your WhatsApp Business Account.
- Configure the Sandbox Webhooks: Similar to the application, you'll need webhook URLs. Leave blank or use dummy URLs for now. We'll update these with ngrok URLs.
1.7. Start ngrok
Open a new terminal window and run ngrok to expose the port your NestJS app will run on (default is 3000).
ngrok http 3000
ngrok will display a forwarding URL (e.g., https://<unique-subdomain>.ngrok.io
or https://<unique-subdomain>.ngrok-free.app
). Copy this HTTPS URL.
1.8. Update Vonage Webhook URLs
Now, go back to your Vonage dashboard:
- Application Webhooks:
- Navigate to Applications -> Your Application (""NestJS WhatsApp Service"").
- Edit the Messages capability URLs:
- Inbound URL:
YOUR_NGROK_HTTPS_URL/webhooks/inbound
- Status URL:
YOUR_NGROK_HTTPS_URL/webhooks/status
- Inbound URL:
- Save the changes.
- Sandbox Webhooks:
- Navigate to Messages API Sandbox.
- Edit the Webhook URLs:
- Inbound URL:
YOUR_NGROK_HTTPS_URL/webhooks/inbound
- Status URL:
YOUR_NGROK_HTTPS_URL/webhooks/status
- Inbound URL:
- Click Save webhooks.
Now, Vonage knows where to send incoming messages and status updates for your application and the sandbox environment.
2. Implementing Core Functionality (Vonage Service)
Let's 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 now
This creates src/vonage/vonage.module.ts
and src/vonage/vonage.service.ts
.
VonageModule
2.2. Configure We don't need complex configuration here yet, but ensure it's set up correctly.
// src/vonage/vonage.module.ts
import { Module } from '@nestjs/common';
import { VonageService } from './vonage.service';
import { ConfigModule } from '@nestjs/config'; // Import ConfigModule if needed directly
@Module({
imports: [ConfigModule], // Ensure ConfigService is available if needed directly
providers: [VonageService],
exports: [VonageService], // Export the service so other modules can use it
})
export class VonageModule {}
VonageService
2.3. Implement This service will initialize the Vonage SDK client using the credentials from our environment variables via ConfigService
. It will also contain the method 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;
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);
// Depending on your app's needs, you might want to throw the error
// or handle it differently to prevent the app from starting incorrectly.
throw new Error(`Vonage initialization failed: ${error.message}`);
}
}
/**
* Sends a WhatsApp text message.
* @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.
*/
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}`);
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) {
this.logger.error(`Error sending WhatsApp message to ${to}:`, error);
// Log more detailed error info if available
if (error.response?.data) {
this.logger.error('Vonage API Error details:', error.response.data);
}
// Re-throw a more specific error or a generic one
throw new Error(
`Failed to send WhatsApp message: ${
error.response?.data?.title || error.message
}`,
);
}
}
// Add methods for other message types (images, templates, etc.) as needed
}
Explanation:
@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 theonModuleInit
method runs once the host module has been initialized. This is where we set up theVonage
client.- Initialization Logic: Reads credentials from
.env
, validates them, reads the private key file, and creates theVonage
instance. It includes error handling for missing configuration or files. It now explicitly checks ifVONAGE_API_HOST
is set and logs whether it's using the sandbox or defaulting to production. sendWhatsAppTextMessage
: Anasync
method that takes the recipient (to
) number and messagetext
. It uses the initializedvonageClient.messages.send
method with aWhatsAppText
object. It includes logging and robust error handling, extracting details from the Vonage API response if possible. The comment for theto
parameter clarifies the expected format.
3. Building the Webhook Handler
Now, let's create the module, controller, and potentially a service to handle incoming webhook requests from Vonage.
3.1. Generate Webhooks Module and Controller
nest g module webhooks
nest g controller webhooks --no-spec
# Optional: nest g service webhooks --no-spec (if logic becomes complex)
WebhooksModule
3.2. Configure This module needs access to our VonageService
(to potentially send replies) and potentially its own service if logic grows.
// 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 if needed here
VonageModule, // Make VonageService available
],
controllers: [WebhooksController],
// providers: [WebhooksService], // Add if you create a service
})
export class WebhooksModule {}
WebhooksController
3.3. Implement This controller defines the /webhooks/inbound
and /webhooks/status
endpoints that Vonage will call. It needs to handle POST requests, parse the JSON body, and verify the JWT signature for security.
// src/webhooks/webhooks.controller.ts
import {
Controller,
Post,
Body,
Headers,
Req,
Res,
HttpStatus,
Logger,
BadRequestException,
InternalServerErrorException,
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 DTO
import { StatusUpdateDto } from './dto/status-update.dto'; // Import 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;
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');
// Handle this critical configuration error appropriately
throw new InternalServerErrorException(
'Server configuration error: Missing signature secret.',
);
}
}
// --- Helper Method for Signature Verification ---
private verifyVonageSignature(req: Request): boolean {
try {
// NestJS might lowercase headers, be cautious
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;
}
// --- PRODUCTION SECURITY REQUIREMENT ---
// The following logic relies on accessing the RAW request body *before*
// it's parsed by NestJS's bodyParser. This is configured in `main.ts`
// using `{ rawBody: true }` and detailed in Section 5.2.
// The `verifySignature` function MUST use this raw body buffer.
const rawBody = (req as any).rawBody; // Cast to access rawBody (requires setup in main.ts)
if (!rawBody) {
this.logger.error(
'Raw request body not available for signature verification. ' +
'Ensure rawBody is enabled in main.ts (Section 5.2) and this check runs before body parsing if custom middleware is used.'
);
// Fail verification if raw body is missing
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')
handleInboundMessage(
@Body() body: InboundMessageDto, // Use DTO for validation
@Req() req: Request,
@Res() res: Response,
) {
this.logger.log('Received inbound message webhook');
// Body is already validated by ValidationPipe (setup in main.ts)
// Log the validated body for debugging if needed (be mindful of sensitive data)
// this.logger.debug('Validated Inbound Payload:', JSON.stringify(body, null, 2));
// 1. Verify Signature (CRITICAL FOR SECURITY)
// IMPORTANT: This requires enabling raw body parsing (Section 5.2)
// and ensuring the verifyVonageSignature helper uses the raw body.
if (!this.verifyVonageSignature(req)) {
this.logger.error('Unauthorized inbound request: Invalid signature');
return res.status(HttpStatus.UNAUTHORIZED).send('Invalid signature');
}
// 2. Process the message (using validated DTO properties)
const messageType = body.message_type;
const fromNumber = body.from.number;
const messageContent = body.message?.content?.text; // Text is optional based on message type
if (messageType === 'text' && fromNumber && messageContent) {
this.logger.log(
`Received text message ""${messageContent}"" from ${fromNumber}`,
);
// Example: Simple echo reply
const replyText = `You sent: ""${messageContent}""`;
this.vonageService
.sendWhatsAppTextMessage(fromNumber, replyText)
.then((messageUuid) => {
this.logger.log(
`Reply sent to ${fromNumber}. Message UUID: ${messageUuid}`,
);
})
.catch((error) => {
this.logger.error(`Failed to send reply to ${fromNumber}:`, error);
// Decide if you still want to send 200 OK to Vonage
});
} else {
this.logger.warn(
`Received non-text message or unexpected structure: Type=${messageType}, From=${fromNumber}`,
);
// Handle other message types (image, audio, location, etc.) or errors
}
// 3. Respond to Vonage (CRITICAL)
// Always send a 200 OK quickly to acknowledge receipt,
// otherwise Vonage will retry the webhook.
res.status(HttpStatus.OK).send();
}
// --- Message Status Webhook ---
@Post('status')
handleMessageStatus(
@Body() body: StatusUpdateDto, // Use DTO for validation
@Req() req: Request,
@Res() res: Response,
) {
this.logger.log('Received message status webhook');
// Body is validated by ValidationPipe
// this.logger.debug('Validated Status Payload:', JSON.stringify(body, null, 2));
// 1. Verify Signature (CRITICAL FOR SECURITY)
// IMPORTANT: This requires enabling raw body parsing (Section 5.2)
// and ensuring the verifyVonageSignature helper uses the raw body.
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 (using validated DTO properties)
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.
// Example: Log specific statuses
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 // Log full error object if present
);
break;
// Add cases for other statuses like 'submitted', 'undeliverable' if needed
default:
this.logger.log(`Message ${messageUuid} status: ${status}`);
}
// 3. Respond to Vonage (CRITICAL)
res.status(HttpStatus.OK).send();
}
}
Explanation:
@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 request body (now typed with DTOs), the Express request object, and the Express response object.- Signature Verification (
verifyVonageSignature
):- Retrieves the
Authorization: Bearer <token>
header. - Extracts the JWT token.
- Crucially, it now directly attempts to use the
req.rawBody
(which requires setup inmain.ts
as described in Section 5.2). - Includes a check and error log if
rawBody
is missing. - Uses
verifySignature(token, secret, rawBody)
with the actual raw buffer. - Logs success or failure and returns a boolean.
- Retrieves the
- Webhook Handler Logic:
- Uses the imported DTOs (
InboundMessageDto
,StatusUpdateDto
) in the@Body()
decorator. Validation is handled by the globalValidationPipe
. - Calls the
verifyVonageSignature
method. This check is now enabled by default. It relies on the raw body setup (Section 5.2) being correctly implemented. Sends401 Unauthorized
if verification fails. - Accesses payload data safely through the validated DTO properties (e.g.,
body.from.number
,body.message_uuid
). - Inbound: Handles text messages and sends an echo reply using
VonageService
. - Status: Logs status details, including specific handling for common statuses like
delivered
,read
, andfailed
/rejected
(logging the error reason). - Sends
200 OK
: Both handlers must end by sending a200 OK
response usingres.status(HttpStatus.OK).send()
.
- Uses the imported DTOs (
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:
npm run start:dev
# Or using yarn:
# yarn start:dev
This command starts the NestJS application in watch mode. Look for output indicating the Vonage client initialized, the server is listening (usually on port 3000), and potentially warnings about raw body if not yet configured. Ensure VONAGE_API_SIGNATURE_SECRET
is correctly set in your .env
file.
[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
... more modules ...
[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. # Or sandbox host if configured
[Nest] 12345 - 04/20/2025, 10:00:03 AM LOG [NestApplication] Nest application successfully started
# You might see errors related to rawBody or signature verification until Section 5.2 is fully implemented.
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
If signature verification fails (because raw body isn't set up yet), you'll see Unauthorized inbound request: Invalid signature
and potentially Raw request body not available
. If it passes (or if you temporarily disable the check for initial testing only), you should see logs like:
[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 # If verification succeeds
[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
Similarly, you'll see status webhook logs. If signature verification fails, you'll get 401s. If it passes:
[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 # If verification succeeds
[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 # If verification succeeds
[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.
5. Enhancements for Production Readiness
The current setup works, but production applications require more robustness.
5.1. Implement Request Validation (DTOs)
Instead of using any
for webhook bodies, define Data Transfer Objects (DTOs) with validation using class-validator
and class-transformer
.
- Install dependencies:
npm install class-validator class-transformer
- Enable ValidationPipe globally (in
src/main.ts
):// src/main.ts import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { Logger, ValidationPipe } from '@nestjs/common'; // Import ValidationPipe import { ConfigService } from '@nestjs/config'; import * as express from 'express'; // Import express if using manual body parser below async function bootstrap() { // Enable rawBody parsing for webhook signature verification (See Section 5.2) const app = await NestFactory.create(AppModule, { rawBody: true, // <<< Enable Raw Body }); const configService = app.get(ConfigService); const port = configService.get<number>('PORT', 3000); // Default to 3000 if not set // 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();
- Create DTOs (e.g.,
src/webhooks/dto/inbound-message.dto.ts
andsrc/webhooks/dto/status-update.dto.ts
): You would define classes here using decorators like@IsString()
,@IsNotEmpty()
,@ValidateNested()
, etc., based on the expected Vonage webhook payload structure. (Detailed DTO implementation is omitted here for brevity but is crucial for production).