code examples
code examples
Plivo WhatsApp Integration with NestJS: Complete TypeScript Guide (2025)
Learn how to integrate Plivo WhatsApp API with NestJS in 2025. Complete guide covering WhatsApp Business messages, webhook validation, interactive buttons, template messages, and the 24-hour window policy with real Node.js code examples.
Learn how to integrate Plivo WhatsApp API with NestJS to build a production-ready messaging backend. This comprehensive tutorial covers everything from sending WhatsApp Business messages to handling incoming webhooks with signature validation.
You'll build a fully functional NestJS WhatsApp integration that:
- Sends templated WhatsApp messages to initiate business conversations
- Sends free-form text and media messages within the 24-hour conversation window
- Implements interactive WhatsApp messages with lists, buttons, and call-to-action URLs
- Receives and validates incoming messages via Plivo webhooks with HMAC-SHA256 signature verification
- Follows TypeScript best practices with DTOs, dependency injection, and error handling
This guide assumes you're familiar with Node.js and basic NestJS concepts.
How to Integrate Plivo WhatsApp with NestJS: Project Overview
Goal: Build a reliable NestJS backend service that sends and receives WhatsApp messages via the Plivo API. This integration works seamlessly with customer support platforms, notification systems, chatbots, and other business applications requiring WhatsApp Business API functionality.
Problem Solved: Manage WhatsApp communications programmatically in a structured, scalable, and maintainable way. The service abstracts Plivo API complexities within a dedicated NestJS module.
Technologies Used:
- Node.js: JavaScript runtime environment
- NestJS: Progressive Node.js framework for building efficient, scalable server-side applications. Provides modular architecture, dependency injection, and built-in configuration and validation support
- Plivo Node.js SDK: Simplifies interaction with the Plivo REST API
- Plivo Communications Platform: API infrastructure for sending and receiving WhatsApp messages
- dotenv / @nestjs/config: Secure environment variable management
- class-validator / class-transformer: Robust request validation using Data Transfer Objects (DTOs)
- (Optional) ngrok: Exposes your local development server to the internet for testing incoming webhooks
System Architecture:
+-----------------+ +---------------------+ +-------------+ +------------+
| User/Client App |----->| NestJS Backend App |----->| Plivo API |----->| WhatsApp |
| (e.g., Web/Mobile)| | (Controller, Service) | | | | Network |
+-----------------+ +----------^----------+ +-------------+ +-----^------+
| |
+-----------------+ | Webhook Notification | User's Phone
| Plivo Webhook |<-----------------+ (Incoming Message) |
+-----------------+Prerequisites:
- Node.js and npm/yarn: Install the LTS version on your system
- NestJS CLI: Install globally:
npm install -g @nestjs/cli - Plivo Account: Sign up here
- Plivo Auth ID and Auth Token: Find these on your Plivo Console dashboard homepage
- WhatsApp-Enabled Plivo Number: Purchase a Plivo number and enable it for WhatsApp in the Plivo console (Messaging → WhatsApp → Senders). Link your WhatsApp Business Account (WABA) following Plivo's onboarding process
- Approved WhatsApp Templates: Business-initiated conversations require pre-approved templates. Create and submit these via the Plivo console or WhatsApp Manager
- (Optional) ngrok: Download here for testing incoming webhooks locally
1. Setting Up Your NestJS Project for Plivo WhatsApp
Initialize your NestJS project and install the Plivo SDK and required dependencies to enable WhatsApp messaging functionality.
Step 1: Create a New NestJS Project
Open your terminal and run:
nest new plivo-whatsapp-integration
cd plivo-whatsapp-integrationThis creates a standard NestJS project structure.
Step 2: Install Dependencies
Install the Plivo Node.js SDK and NestJS configuration module:
npm install plivo @nestjs/config dotenv
npm install --save-dev @types/node # Ensure latest Node typesplivo: Official Plivo SDK for Node.js@nestjs/config: Environment variables and configuration managementdotenv: Loads environment variables from.envintoprocess.env
Step 3: Configure Environment Variables
Create a .env file in the project root directory:
#.env
PLIVO_AUTH_ID=YOUR_PLIVO_AUTH_ID
PLIVO_AUTH_TOKEN=YOUR_PLIVO_AUTH_TOKEN
PLIVO_WHATSAPP_SENDER_NUMBER=+14155551234 # Your WhatsApp-enabled Plivo number
# Optional: Base URL for exposing webhook (useful with ngrok)
BASE_URL=http://localhost:3000Important:
- Replace
YOUR_PLIVO_AUTH_IDandYOUR_PLIVO_AUTH_TOKENwith your actual Plivo console credentials - Replace
+14155551234with your WhatsApp-enabled Plivo number in E.164 format - Security: Never commit
.envto version control. Add.envto your.gitignorefile
Step 4: Setup Configuration Module
Modify src/app.module.ts to load and manage environment variables using @nestjs/config:
// src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule } from '@nestjs/config';
import { PlivoModule } from './plivo/plivo.module'; // We will create this next
import { WhatsappModule } from './whatsapp/whatsapp.module'; // We will create this next
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // Makes ConfigModule available globally
envFilePath: '.env',
}),
PlivoModule, // Import Plivo Module
WhatsappModule, // Import WhatsApp Module
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env' }): Loads the.envfile and makes configuration accessible throughout the application viaConfigService.
Project Structure (Initial):
Your project structure should now look something like this:
plivo-whatsapp-integration/
├── node_modules/
├── src/
│ ├── app.controller.spec.ts
│ ├── app.controller.ts
│ ├── app.module.ts
│ ├── app.service.ts
│ ├── main.ts
│ ├── plivo/ <-- To be created
│ └── whatsapp/ <-- To be created
├── test/
├── .env <-- Your credentials
├── .eslintrc.js
├── .gitignore
├── .prettierrc
├── nest-cli.json
├── package.json
├── package-lock.json
├── README.md
├── tsconfig.build.json
└── tsconfig.json2. Creating the Plivo WhatsApp Service in NestJS
Create a dedicated NestJS module and service to encapsulate all Plivo WhatsApp API interactions. This modular approach promotes code reusability and follows NestJS best practices for dependency injection.
Step 1: Generate the Plivo Module and Service
Use the NestJS CLI to generate the module and service files:
nest generate module plivo
nest generate service plivo --no-spec # --no-spec skips test file generation for nowThis creates src/plivo/plivo.module.ts and src/plivo/plivo.service.ts.
Step 2: Implement the Plivo Service
Open src/plivo/plivo.service.ts and implement the Plivo client initialization:
// src/plivo/plivo.service.ts
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as plivo from 'plivo';
@Injectable()
export class PlivoService implements OnModuleInit {
private client: plivo.Client;
private readonly logger = new Logger(PlivoService.name);
private senderNumber: string;
constructor(private configService: ConfigService) {}
onModuleInit() {
const authId = this.configService.get<string>('PLIVO_AUTH_ID');
const authToken = this.configService.get<string>('PLIVO_AUTH_TOKEN');
this.senderNumber = this.configService.get<string>('PLIVO_WHATSAPP_SENDER_NUMBER');
if (!authId || !authToken || !this.senderNumber) {
this.logger.error('Plivo Auth ID, Auth Token, or Sender Number is missing in environment variables. Plivo integration will not function.');
// Throw an error to prevent application startup if Plivo is critical
throw new Error('Plivo credentials or sender number missing. Application cannot start.');
// Alternatively, allow startup but log error (less safe if Plivo is essential):
// return;
}
try {
this.client = new plivo.Client(authId, authToken);
this.logger.log('Plivo client initialized successfully.');
} catch (error) {
this.logger.error('Failed to initialize Plivo client:', error);
// Consider throwing here as well, as the app might be non-functional
throw new Error(`Plivo client initialization failed: ${error.message}`);
}
}
// Expose the client for advanced use cases if needed, or create specific methods
getPlivoClient(): plivo.Client {
if (!this.client) {
// This should ideally not happen if onModuleInit throws on failure
this.logger.error('Attempted to get Plivo client, but it was not initialized. Check configuration and startup logs.');
throw new Error('Plivo client is not initialized. Check configuration.');
}
return this.client;
}
getSenderNumber(): string {
if (!this.senderNumber) {
// This should ideally not happen if onModuleInit throws on failure
throw new Error('Plivo sender number is not configured.');
}
return this.senderNumber;
}
// We will add methods here to send different types of messages
}- Inject
ConfigServiceto access environment variables OnModuleInitinitializes the Plivo client when the module loads- Retrieve
PLIVO_AUTH_ID,PLIVO_AUTH_TOKEN, andPLIVO_WHATSAPP_SENDER_NUMBERfrom configuration - Crucially: The service throws an error if credentials or the sender number are missing, preventing the application from starting in a non-functional state
- Error handling covers initialization failures
- Getters
getPlivoClient()andgetSenderNumber()provide access to the initialized client and sender number with availability checks
Step 3: Configure the Plivo Module
Open src/plivo/plivo.module.ts and ensure the service is provided and exported:
// src/plivo/plivo.module.ts
import { Module } from '@nestjs/common';
import { PlivoService } from './plivo.service';
import { ConfigModule } from '@nestjs/config'; // Import ConfigModule if not global
@Module({
imports: [ConfigModule], // Import ConfigModule here if it's not set globally in app.module
providers: [PlivoService],
exports: [PlivoService], // Export the service so other modules can use it
})
export class PlivoModule {}Remember we already imported PlivoModule into AppModule in the previous section.
3. Sending WhatsApp Messages with Plivo in NestJS
Implement a controller and service methods to send various types of WhatsApp Business messages including templates, text, media, and interactive messages through the Plivo API.
Step 1: Generate the WhatsApp Module and Controller
nest generate module whatsapp
nest generate controller whatsapp --no-specThis creates src/whatsapp/whatsapp.module.ts and src/whatsapp/whatsapp.controller.ts.
Step 2: Configure the WhatsApp Module
Open src/whatsapp/whatsapp.module.ts and import the PlivoModule:
// src/whatsapp/whatsapp.module.ts
import { Module } from '@nestjs/common';
import { WhatsappController } from './whatsapp.controller';
import { PlivoModule } from '../plivo/plivo.module'; // Import PlivoModule
import { ConfigModule } from '@nestjs/config'; // Import if needed
@Module({
imports: [PlivoModule, ConfigModule], // Make PlivoService available
controllers: [WhatsappController],
})
export class WhatsappModule {}Remember we already imported WhatsappModule into AppModule.
Step 3: Install Validation Dependencies
Use DTOs (Data Transfer Objects) with decorators to validate incoming request bodies.
npm install class-validator class-transformerStep 4: Enable Global Validation Pipe and Raw Body Support
Enable automatic validation for all incoming requests and configure raw body access for webhook signature validation in src/main.ts:
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { NestExpressApplication } from '@nestjs/platform-express';
async function bootstrap() {
// Enable rawBody option for webhook signature validation
// See: https://docs.nestjs.com/faq/raw-body
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
rawBody: true, // Required for Plivo webhook signature validation
});
const logger = new Logger('Bootstrap');
// Enable CORS if your frontend is on a different domain
app.enableCors();
// Global Validation Pipe
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // Strip properties that don't have decorators
forbidNonWhitelisted: true, // Throw errors for non-whitelisted properties
transform: true, // Automatically transform payloads to DTO instances
transformOptions: {
enableImplicitConversion: true, // Allow basic type conversions
},
}));
const configService = app.get(ConfigService);
const port = configService.get<number>('PORT') || 3000;
await app.listen(port);
logger.log(`Application listening on port ${port}`);
const baseUrl = configService.get<string>('BASE_URL') || `http://localhost:${port}`;
logger.log(`Expected WhatsApp Webhook URL for Plivo: ${baseUrl}/whatsapp/webhook/incoming`);
}
bootstrap();- Critical: Set
rawBody: truewhen creating the application. This is required for Plivo webhook signature validation usingplivo.validateV3Signature(). See NestJS raw body documentation for details - Global
ValidationPipeautomatically validates incoming data against DTOs - CORS is enabled via
app.enableCors() - Port is set from environment variable
PORTor defaults to 3000 - Expected webhook URL is logged for convenience
Step 5: Create DTOs for Sending Messages
Create a directory src/whatsapp/dto and add the following DTO files:
// src/whatsapp/dto/send-template.dto.ts
import { IsNotEmpty, IsString, IsPhoneNumber, IsObject, IsOptional, IsUrl, ValidateNested, IsArray } from 'class-validator';
import { Type } from 'class-transformer';
// NOTE: Define more specific component DTOs if needed, or rely on IsObject for flexibility.
// Refer to Plivo documentation for the exact structure required for template components.
class TemplateComponentHeader {
@IsString()
type: 'header'; // Example, adjust based on Plivo spec
// Add other header properties and validation
}
class TemplateComponentBody {
@IsString()
type: 'body'; // Example
// Add other body properties and validation
}
// Define ButtonComponent etc.
export class SendTemplateDto {
@IsNotEmpty()
@IsPhoneNumber(null) // Use null for generic E.164 format validation
readonly dst: string; // Destination WhatsApp number
@IsNotEmpty()
@IsString()
readonly templateName: string; // Name of the approved Plivo WhatsApp template
@IsOptional()
@IsString()
readonly language?: string = 'en_US'; // Language code (default: en_US)
@IsOptional()
@IsArray() // Ensure it's an array
@ValidateNested({ each: true }) // Validate each object in the array if you define nested DTOs
@Type(() => Object) // Basic type hint for transformation, replace Object with specific DTO if defined
// Plivo expects a specific structure for template components based on the template definition.
// Accepting a generic object array provides flexibility but less compile-time safety.
// Consider creating detailed nested DTOs for components for stricter validation if desired.
// Refer to Plivo's documentation for the required 'components' array structure.
// Example: components: [{ type: 'body', parameters: [...] }, { type: 'header', ...}]
readonly templateComponents?: Record<string, any>[]; // Expects an array of component objects
@IsOptional()
@IsUrl()
readonly callbackUrl?: string; // Optional status callback URL
}// src/whatsapp/dto/send-text.dto.ts
import { IsNotEmpty, IsString, IsPhoneNumber, IsOptional, IsUrl } from 'class-validator';
export class SendTextDto {
@IsNotEmpty()
@IsPhoneNumber(null)
readonly dst: string;
@IsNotEmpty()
@IsString()
readonly text: string;
@IsOptional()
@IsUrl()
readonly callbackUrl?: string;
}// src/whatsapp/dto/send-media.dto.ts
import { IsNotEmpty, IsString, IsPhoneNumber, IsUrl, IsArray, IsOptional } from 'class-validator';
export class SendMediaDto {
@IsNotEmpty()
@IsPhoneNumber(null)
readonly dst: string;
@IsNotEmpty()
@IsArray()
@IsUrl({}, { each: true }) // Validate each item in the array is a URL
readonly mediaUrls: string[]; // Array containing URL(s) of the media
@IsOptional()
@IsString()
readonly caption?: string; // Optional caption for the media
@IsOptional()
@IsUrl()
readonly callbackUrl?: string;
}// src/whatsapp/dto/send-interactive.dto.ts
import { IsNotEmpty, IsPhoneNumber, IsObject, IsOptional, IsUrl } from 'class-validator';
export class SendInteractiveDto {
@IsNotEmpty()
@IsPhoneNumber(null)
readonly dst: string;
@IsNotEmpty()
@IsObject()
// Plivo expects a specific structure for the interactive payload (buttons, lists, etc.).
// Accepting a generic object provides flexibility. For stricter validation, create detailed
// nested DTOs reflecting Plivo's interactive message structure.
// Refer to Plivo's documentation for the required 'interactive' object structure.
readonly interactive: Record<string, any>;
@IsOptional()
@IsUrl()
readonly callbackUrl?: string;
}Step 6: Add Sending Methods to PlivoService
Now, add the methods to src/plivo/plivo.service.ts to handle the actual API calls using the Plivo client.
// src/plivo/plivo.service.ts
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as plivo from 'plivo';
// Keep existing constructor, onModuleInit, getPlivoClient, getSenderNumber
@Injectable()
export class PlivoService implements OnModuleInit {
private client: plivo.Client;
private readonly logger = new Logger(PlivoService.name);
private senderNumber: string;
constructor(private configService: ConfigService) {}
onModuleInit() {
const authId = this.configService.get<string>('PLIVO_AUTH_ID');
const authToken = this.configService.get<string>('PLIVO_AUTH_TOKEN');
this.senderNumber = this.configService.get<string>('PLIVO_WHATSAPP_SENDER_NUMBER');
if (!authId || !authToken || !this.senderNumber) {
this.logger.error('Plivo Auth ID, Auth Token, or Sender Number is missing in environment variables. Plivo integration will not function.');
throw new Error('Plivo credentials or sender number missing. Application cannot start.');
}
try {
this.client = new plivo.Client(authId, authToken);
this.logger.log('Plivo client initialized successfully.');
} catch (error) {
this.logger.error('Failed to initialize Plivo client:', error);
throw new Error(`Plivo client initialization failed: ${error.message}`);
}
}
getPlivoClient(): plivo.Client {
if (!this.client) {
this.logger.error('Attempted to get Plivo client, but it was not initialized. Check configuration and startup logs.');
throw new Error('Plivo client is not initialized. Check configuration.');
}
return this.client;
}
getSenderNumber(): string {
if (!this.senderNumber) {
throw new Error('Plivo sender number is not configured.');
}
return this.senderNumber;
}
// --- MESSAGE SENDING METHODS ---
async sendWhatsAppTemplate(
dst: string,
templateName: string,
language: string = 'en_US',
components?: Record<string, any>[], // Expecting array of components
callbackUrl?: string,
): Promise<any> {
const client = this.getPlivoClient();
const src = this.getSenderNumber();
this.logger.log(`Sending template '${templateName}' to ${dst} from ${src}`);
// Construct the template object per Plivo SDK specification
// See: https://www.plivo.com/docs/messaging/api/message/send-a-message
const templatePayload = {
name: templateName,
language: language,
...(components && { components: components }), // Only add components if provided
};
try {
const response = await client.messages.create({
src: src,
dst: dst,
type: 'whatsapp', // Ensure type is set for WhatsApp
template: templatePayload,
...(callbackUrl && { url: callbackUrl }), // Add callback URL if provided
});
this.logger.log(`Template message queued successfully: ${response.messageUuid[0]}`);
return response;
} catch (error) {
this.logger.error(`Failed to send template message to ${dst}:`, error.message || error);
throw error;
}
}
async sendWhatsAppText(
dst: string,
text: string,
callbackUrl?: string,
): Promise<any> {
const client = this.getPlivoClient();
const src = this.getSenderNumber();
this.logger.log(`Sending text message to ${dst} from ${src}`);
// IMPORTANT: Free-form messages only allowed within 24-hour customer service window
// Text messages may be up to 4,096 characters per Plivo documentation
try {
const response = await client.messages.create({
src: src,
dst: dst,
type: 'whatsapp',
text: text,
...(callbackUrl && { url: callbackUrl }),
});
this.logger.log(`Text message queued successfully: ${response.messageUuid[0]}`);
return response;
} catch (error) {
this.logger.error(`Failed to send text message to ${dst}:`, error.message || error);
throw error;
}
}
async sendWhatsAppMedia(
dst: string,
mediaUrls: string[],
caption?: string,
callbackUrl?: string,
): Promise<any> {
const client = this.getPlivoClient();
const src = this.getSenderNumber();
this.logger.log(`Sending media message to ${dst} from ${src}`);
// IMPORTANT: Free-form messages only allowed within 24-hour customer service window
// Plivo supports single media URL per WhatsApp message (image, video, document, audio)
// Caption limited to 1,024 characters when sent with media
try {
const response = await client.messages.create({
src: src,
dst: dst,
type: 'whatsapp',
media_urls: mediaUrls, // Plivo SDK uses 'media_urls' for WhatsApp
...(caption && { text: caption }), // Caption goes in 'text' field for media messages
...(callbackUrl && { url: callbackUrl }),
});
this.logger.log(`Media message queued successfully: ${response.messageUuid[0]}`);
return response;
} catch (error) {
this.logger.error(`Failed to send media message to ${dst}:`, error.message || error);
throw error;
}
}
async sendWhatsAppInteractive(
dst: string,
interactivePayload: Record<string, any>, // Expects the structured interactive object
callbackUrl?: string,
): Promise<any> {
const client = this.getPlivoClient();
const src = this.getSenderNumber();
this.logger.log(`Sending interactive message to ${dst} from ${src}`);
// IMPORTANT: Free-form messages only allowed within 24-hour customer service window
// Interactive types: 'list', 'reply', 'cta_url'
// See: https://www.plivo.com/docs/messaging/api/message/send-a-message
try {
const response = await client.messages.create({
src: src,
dst: dst,
type: 'whatsapp',
interactive: interactivePayload, // Pass the interactive object directly
...(callbackUrl && { url: callbackUrl }),
});
this.logger.log(`Interactive message queued successfully: ${response.messageUuid[0]}`);
return response;
} catch (error) {
this.logger.error(`Failed to send interactive message to ${dst}:`, error.message || error);
throw error;
}
}
// Add methods for other message types (Location, Contact, etc.) as needed
// Example: sendWhatsAppLocation(...)
}- Each method takes necessary parameters (destination, content, optional callback URL)
- Retrieves the initialized Plivo client and sender number
- Constructs the payload specific to the message type per the Plivo Node.js SDK structure. Always verify against the latest Plivo SDK documentation
- Calls the appropriate
client.messages.create()method - Includes logging and error handling via
try...catch - Important: Free-form messages (text, media, interactive) are only allowed as replies within the 24-hour customer service window. Business-initiated messages require approved templates
Step 7: Implement Controller Endpoints
Open src/whatsapp/whatsapp.controller.ts and define the API endpoints that will use the PlivoService.
// src/whatsapp/whatsapp.controller.ts
import { Controller, Post, Body, HttpCode, HttpStatus, Logger, UseFilters, Req, UnauthorizedException, RawBodyRequest } from '@nestjs/common';
import { PlivoService } from '../plivo/plivo.service';
import { SendTemplateDto } from './dto/send-template.dto';
import { SendTextDto } from './dto/send-text.dto';
import { SendMediaDto } from './dto/send-media.dto';
import { SendInteractiveDto } from './dto/send-interactive.dto';
import { ConfigService } from '@nestjs/config';
import * as plivo from 'plivo';
import { Request } from 'express'; // Import Request for webhook signature validation
// Import an exception filter if you create one (See Section 5)
// import { AllExceptionsFilter } from '../common/filters/all-exceptions.filter';
@Controller('whatsapp')
// @UseFilters(new AllExceptionsFilter()) // Apply custom filter if created (Update path if needed)
export class WhatsappController {
private readonly logger = new Logger(WhatsappController.name);
constructor(
private readonly plivoService: PlivoService,
private readonly configService: ConfigService // Inject ConfigService for Auth Token
) {}
@Post('/send/template')
@HttpCode(HttpStatus.ACCEPTED) // Use 202 Accepted as sending is asynchronous
async sendTemplateMessage(@Body() sendTemplateDto: SendTemplateDto) {
this.logger.log(`Received request to send template: ${sendTemplateDto.templateName} to ${sendTemplateDto.dst}`);
try {
const result = await this.plivoService.sendWhatsAppTemplate(
sendTemplateDto.dst,
sendTemplateDto.templateName,
sendTemplateDto.language,
sendTemplateDto.templateComponents,
sendTemplateDto.callbackUrl,
);
// Return minimal confirmation, status updates via webhook are better
return { messageId: result.messageUuid[0], status: 'queued' };
} catch (error) {
// Error is logged in the service, controller can re-throw or return specific HTTP error
// Consider using an Exception Filter for cleaner error handling (Section 5)
this.logger.error(`Error in /send/template endpoint: ${error.message}`, error.stack);
throw error; // Let NestJS handle the error (default 500 or specific if thrown/filtered)
}
}
@Post('/send/text')
@HttpCode(HttpStatus.ACCEPTED)
async sendTextMessage(@Body() sendTextDto: SendTextDto) {
this.logger.log(`Received request to send text to ${sendTextDto.dst}`);
try {
const result = await this.plivoService.sendWhatsAppText(
sendTextDto.dst,
sendTextDto.text,
sendTextDto.callbackUrl,
);
return { messageId: result.messageUuid[0], status: 'queued' };
} catch (error) {
this.logger.error(`Error in /send/text endpoint: ${error.message}`, error.stack);
throw error;
}
}
@Post('/send/media')
@HttpCode(HttpStatus.ACCEPTED)
async sendMediaMessage(@Body() sendMediaDto: SendMediaDto) {
this.logger.log(`Received request to send media to ${sendMediaDto.dst}`);
try {
const result = await this.plivoService.sendWhatsAppMedia(
sendMediaDto.dst,
sendMediaDto.mediaUrls,
sendMediaDto.caption,
sendMediaDto.callbackUrl,
);
return { messageId: result.messageUuid[0], status: 'queued' };
} catch (error) {
this.logger.error(`Error in /send/media endpoint: ${error.message}`, error.stack);
throw error;
}
}
@Post('/send/interactive')
@HttpCode(HttpStatus.ACCEPTED)
async sendInteractiveMessage(@Body() sendInteractiveDto: SendInteractiveDto) {
this.logger.log(`Received request to send interactive message to ${sendInteractiveDto.dst}`);
try {
const result = await this.plivoService.sendWhatsAppInteractive(
sendInteractiveDto.dst,
sendInteractiveDto.interactive,
sendInteractiveDto.callbackUrl,
);
return { messageId: result.messageUuid[0], status: 'queued' };
} catch (error) {
this.logger.error(`Error in /send/interactive endpoint: ${error.message}`, error.stack);
throw error;
}
}
// --- INCOMING WEBHOOK HANDLER ---
@Post('/webhook/incoming')
@HttpCode(HttpStatus.OK) // Plivo expects a 200 OK for webhooks
handleIncomingMessage(@Req() req: RawBodyRequest<Request>, @Body() payload: any) {
this.logger.log('Received incoming WhatsApp message webhook request.');
// --- Webhook Signature Validation ---
// Plivo uses V3 signature validation with HMAC-SHA256
// See: https://www.plivo.com/docs/voice/concepts/signature-validation
const signature = req.headers['x-plivo-signature-v3'] as string;
const nonce = req.headers['x-plivo-signature-v3-nonce'] as string;
const url = req.protocol + '://' + req.get('host') + req.originalUrl;
const authToken = this.configService.get<string>('PLIVO_AUTH_TOKEN');
// IMPORTANT: Plivo's validateV3Signature requires the raw body.
// Ensure rawBody: true is set in main.ts NestFactory.create() options
const rawBody = req.rawBody;
if (!rawBody) {
this.logger.error('Raw body not available for webhook signature validation. Ensure rawBody: true is configured in main.ts.');
throw new Error('Webhook validation configuration error: Raw body missing.');
}
if (!signature || !nonce) {
this.logger.warn('Missing Plivo signature headers. Rejecting request.');
throw new UnauthorizedException('Missing signature');
}
try {
const valid = plivo.validateV3Signature(url, nonce, signature, rawBody.toString('utf8'), authToken);
if (!valid) {
this.logger.warn('Invalid Plivo signature. Rejecting request.');
throw new UnauthorizedException('Invalid signature');
}
this.logger.log('Plivo webhook signature validated successfully.');
} catch (error) {
this.logger.error(`Error during signature validation: ${error.message}`);
throw new UnauthorizedException('Signature validation failed');
}
// --- Process Payload (Only if signature is valid) ---
this.logger.log('Processing incoming WhatsApp message payload:');
this.logger.debug(`Payload: ${JSON.stringify(payload, null, 2)}`);
// Basic Payload Parsing (Consult Plivo docs for definitive structure)
const fromNumber = payload.From;
const toNumber = payload.To;
const messageUuid = payload.MessageUUID;
const type = payload.Type; // e.g., 'text', 'media', 'location'
const eventType = payload.EventType; // e.g., 'message_delivered', 'message_read', 'interactive'
this.logger.log(`Incoming event ${eventType || type} (${messageUuid}) from ${fromNumber} to ${toNumber}`);
// Handle Different Message Types/Events (Add your business logic)
if (type === 'text') {
const text = payload.Text;
this.logger.log(`Text received: "${text}"`);
// ** ACTION: Implement your business logic here **
// Example: Route to a chatbot, save to DB, trigger a reply
} else if (type === 'media') {
const mediaUrl = payload.MediaUrl;
const mediaContentType = payload.MediaContentType;
const caption = payload.Text; // Caption might be in Text field
this.logger.log(`Media received: ${mediaContentType} at ${mediaUrl}, Caption: "${caption || ''}"`);
// ** ACTION: Implement logic to handle media (e.g., download, analyze) **
} else if (type === 'location') {
const latitude = payload.Latitude;
const longitude = payload.Longitude;
this.logger.log(`Location received: Lat ${latitude}, Lon ${longitude}`);
// ** ACTION: Handle location data **
} else if (eventType === 'interactive' && payload.Interactive) {
// Handle replies from interactive messages (buttons, lists)
const interactiveData = payload.Interactive;
const interactiveType = interactiveData.type; // 'button_reply' or 'list_reply'
this.logger.log(`Interactive reply received: Type - ${interactiveType}`);
if (interactiveType === 'button_reply') {
const buttonId = interactiveData.button_reply?.id;
const buttonTitle = interactiveData.button_reply?.title;
this.logger.log(`Button Clicked: ID='${buttonId}', Title='${buttonTitle}'`);
// ** ACTION: Handle button click based on ID **
} else if (interactiveType === 'list_reply') {
const listItemId = interactiveData.list_reply?.id;
const listItemTitle = interactiveData.list_reply?.title;
const listItemDescription = interactiveData.list_reply?.description;
this.logger.log(`List Item Selected: ID='${listItemId}', Title='${listItemTitle}', Desc='${listItemDescription}'`);
// ** ACTION: Handle list selection based on ID **
}
} else if (eventType && eventType.startsWith('message_')) {
// Handle status updates (delivered, read, failed, etc.)
this.logger.log(`Message status update: ${eventType} for UUID ${messageUuid}`);
// ** ACTION: Update message status in your database if tracking **
} else {
this.logger.log(`Received unhandled message type '${type}' or event type '${eventType}'.`);
}
// ** IMPORTANT: Always return 200 OK quickly to Plivo **
// Perform time-consuming actions asynchronously (e.g., using queues or background jobs)
// If you don't return 200 OK promptly, Plivo might retry the webhook.
}
}WhatsApp Business Message Templates: A Complete Guide
WhatsApp Business API requires pre-approved message templates for business-initiated conversations outside the 24-hour customer service window. Understanding these templates is essential for proper implementation and cost optimization.
Meta categorizes templates into three types, each with different pricing and use cases:
Template Categories (Meta documentation):
-
Utility Templates: Used for specific transaction or account updates (order confirmations, shipping notifications, appointment reminders, account alerts). These messages provide important information users have opted to receive.
-
Authentication Templates: Used for one-time passwords (OTP) and two-factor authentication. These must include security disclaimers and are typically the lowest cost option.
-
Marketing Templates: Used for promotional content, offers, announcements, newsletters, and other marketing communications. These have higher pricing and stricter approval requirements.
Template Creation: Create templates via WhatsApp Manager or the Plivo console. Meta must approve templates before use – this typically takes a few hours but can take up to 24 hours.
Important Notes:
- Template names and language codes are case-sensitive
- Components (header, body, buttons) support dynamic variables via the
parametersarray - Incorrectly categorized templates may be rejected during the approval process
WhatsApp Pricing Model (Updated July 2025)
As of July 1, 2025, WhatsApp transitioned from conversation-based pricing to a per-message pricing model. This significantly changed how businesses are charged for WhatsApp communications.
Key Pricing Changes:
-
Per-Message Billing: Each template message delivered is charged based on:
- Message category (Marketing, Utility, Authentication)
- Recipient's country code
-
24-Hour Customer Service Window: When a customer messages your business, a 24-hour window opens. During this period:
- Free: All free-form messages (text, media replies, bot responses)
- Free: Utility template messages (newly made free within this window)
- Charged: Marketing and Authentication templates (always charged)
-
72-Hour Free Entry Point: Messages from Click-to-WhatsApp Ads or Facebook Page buttons open a 72-hour free window for all message types (you must respond within 24 hours for the window to activate).
-
No More Free Tier: The previous 1,000 free conversations per month no longer exists.
Pricing Examples (USD, effective October 2025):
| Country | Marketing | Utility | Authentication |
|---|---|---|---|
| United States | $0.0288 | $0.0046 | $0.0046 |
| India | $0.0123 | $0.0016 | $0.0016 |
| United Kingdom | $0.0608 | $0.0253 | $0.0253 |
| Brazil | $0.0719 | $0.0078 | $0.0078 |
| Germany | $0.1570 | $0.0633 | $0.0633 |
Source: Gallabox Per-Message Pricing Documentation
Cost Optimization Best Practices:
- Use the 24-hour customer service window for follow-up messages
- Choose utility templates instead of marketing templates when appropriate
- Categorize templates correctly during creation for proper pricing
- Respond promptly to user-initiated messages to maximize the free window
Plivo WhatsApp NestJS Integration: Frequently Asked Questions
How do I send WhatsApp messages with Plivo in NestJS?
To send WhatsApp messages with Plivo in NestJS: Install the Plivo Node.js SDK (npm install plivo), create a PlivoService that initializes the client with your Auth ID and Token, then use client.messages.create() with type: 'whatsapp' parameter. Inject the PlivoService into your controller using NestJS dependency injection and call your service methods from API endpoints.
What is the WhatsApp 24-hour conversation window and how does it work?
The WhatsApp 24-hour conversation window is a policy that allows businesses to send free-form messages (text, media, interactive) only within 24 hours after a user initiates contact or replies. Outside this window, you must use pre-approved message templates to start new conversations. As of July 2025, utility templates are free within this window, while marketing and authentication templates are always charged.
How do I validate Plivo webhook signatures in NestJS for WhatsApp messages?
To validate Plivo webhook signatures in NestJS, call Plivo's validateV3Signature() function with the raw request body, nonce from X-Plivo-Signature-V3-Nonce header, signature from X-Plivo-Signature-V3 header, full webhook URL, and your Auth Token. You must preserve the raw body by setting rawBody: true in NestFactory.create() options in main.ts. This HMAC-SHA256 validation ensures webhooks are authentic and from Plivo. See the NestJS raw body documentation for implementation details.
What types of WhatsApp messages can I send with Plivo API?
Plivo WhatsApp API supports multiple message types including: templated messages for business-initiated conversations, plain text messages, media messages (images, videos, documents, audio files), interactive messages with quick reply buttons, selection lists, and call-to-action URLs, plus location messages. All message types work through the WhatsApp Business API and require proper authentication.
Do I need a WhatsApp Business Account to use Plivo WhatsApp API?
Yes, you need a WhatsApp Business Account (WABA) to use Plivo WhatsApp API. First, purchase a Plivo phone number, then enable it for WhatsApp messaging in the Plivo console under Messaging → WhatsApp → Senders. Link this number to your WhatsApp Business Account following Plivo's onboarding process. You must also create and get Meta approval for message templates before sending business-initiated messages outside the 24-hour window.
How do I handle incoming WhatsApp messages in NestJS with Plivo?
To handle incoming WhatsApp messages in NestJS: Create a webhook endpoint (e.g., /whatsapp/webhook/incoming) decorated with @Post() that accepts POST requests from Plivo. Validate the webhook signature using plivo.validateV3Signature() with the raw request body, parse the payload to extract message content and type (text, media, interactive reply, location), then implement your business logic to process and respond to messages. Always return HTTP 200 OK quickly to prevent webhook retries.
What's the difference between template and free-form WhatsApp messages?
Template messages use pre-approved formats with placeholders and initiate conversations outside the 24-hour window. Free-form messages allow custom text, media, and interactive elements but can only be sent as replies within active conversation windows.
How much does it cost to send WhatsApp messages through Plivo?
As of July 1, 2025, WhatsApp charges per message delivered rather than per conversation. Messages within the 24-hour customer service window are free (including utility templates). Template pricing varies by country and category (utility, authentication, marketing), ranging from $0.0016 to $0.1570+ per message. See the pricing section for detailed rates.
What are the character limits for WhatsApp messages?
According to Plivo's documentation:
- Text messages: Up to 4,096 characters for free-form messages
- Text with media (caption): Up to 1,024 characters
- Interactive message body: Up to 1,024 characters
- Interactive message header: Up to 60 characters
- Interactive message footer: Up to 60 characters
Related Resources
Frequently Asked Questions
How to send WhatsApp message with NestJS?
Integrate the Plivo Node.js SDK and use the provided methods within the Plivo service to send various WhatsApp messages. You'll need a Plivo account, WhatsApp-enabled number, and approved templates for business-initiated conversations. The code examples demonstrate sending templated, text, media, and interactive messages via function calls handling the Plivo API interaction details.
What is Plivo Node.js SDK used for?
The Plivo Node.js SDK simplifies interaction with the Plivo communications API. It provides convenient functions for sending messages, making API calls, and managing other communication tasks within your NestJS application, abstracting away low-level details.
Why does WhatsApp require pre-approved templates?
WhatsApp enforces pre-approved message templates for business-initiated conversations to prevent spam and unwanted messages. Businesses must submit templates for review and approval by WhatsApp before sending initial outbound messages to customers. Text, media, and interactive messages are allowed only in the 24-hour response window following a customer-initiated message.
When should I use a WhatsApp template message?
WhatsApp template messages are required for any business-initiated conversations outside the 24-hour customer service window. They are essential for sending notifications, alerts, or initiating contact where the user hasn't messaged your business first.
How to receive WhatsApp messages in NestJS?
Set up a webhook endpoint in your NestJS application to receive incoming WhatsApp messages. Configure Plivo to send webhook notifications to this endpoint. Then implement request handling logic to process incoming message data.
How to set up Plivo WhatsApp integration in Node.js?
Obtain Plivo credentials (Auth ID and Auth Token) from the Plivo console, install the Plivo Node.js SDK (`npm install plivo`), initialize a Plivo client, and use the client's methods to send and receive WhatsApp messages via the API.
What is the project structure for Plivo integration?
The example project creates a modular structure with a dedicated `plivo` module containing a service for Plivo interactions and a `whatsapp` module with a controller for handling endpoints. This design promotes code organization and separation of concerns.
How to manage Plivo configuration in NestJS?
Use NestJS's `ConfigModule` along with the `dotenv` package to load environment variables. Store sensitive credentials like your Plivo Auth ID, Auth Token, and WhatsApp Sender Number in a `.env` file, ensuring this file is not committed to version control.
How to handle incoming webhook notifications securely?
Validate the Plivo webhook signature to ensure requests are genuinely from Plivo. Use `plivo.validateV3Signature` to validate against the signature and nonce provided in the `X-Plivo-Signature-V3` and `X-Plivo-Signature-V3-Nonce` headers of the webhook requests.
Can I send different types of WhatsApp messages?
Yes, the Plivo API supports sending various WhatsApp message types, including templated messages, free-form text and media messages (images, videos, documents), and interactive messages with buttons, lists, and call-to-actions. The provided tutorial shows how to send different message types using functions like `sendWhatsAppTemplate`, `sendWhatsAppText`, `sendWhatsAppMedia`, and `sendWhatsAppInteractive`.
How to handle errors in WhatsApp messaging?
Implement proper error handling using try-catch blocks around Plivo API calls. Handle Plivo-specific errors, such as invalid numbers or unapproved templates. Consider creating custom exception filters in NestJS to handle and format error responses consistently.
How to send free-form text messages via WhatsApp?
Use the `sendWhatsAppText` function after initializing the Plivo client. Ensure free-form messages are sent only as replies within a 24-hour window initiated by the user. Provide the destination number and message text as parameters.
What are the system architectural components of a WhatsApp integration?
The key components are the user/client application, the NestJS backend application, the Plivo API, and the WhatsApp network. Data flows from the user to your backend, then to Plivo, and finally to WhatsApp. Webhooks from Plivo notify your backend about message status updates and incoming messages.
How to set up a local development environment for WhatsApp?
Use a tool like `ngrok` to create a secure tunnel that exposes your local development server to the internet. This allows Plivo webhooks to reach your application during testing. Configure the `BASE_URL` in your `.env` file to match the ngrok URL.