code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / plivo

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:

text
+-----------------+      +---------------------+      +-------------+      +------------+
| 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:

  1. Node.js and npm/yarn: Install the LTS version on your system
  2. NestJS CLI: Install globally: npm install -g @nestjs/cli
  3. Plivo Account: Sign up here
  4. Plivo Auth ID and Auth Token: Find these on your Plivo Console dashboard homepage
  5. 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
  6. Approved WhatsApp Templates: Business-initiated conversations require pre-approved templates. Create and submit these via the Plivo console or WhatsApp Manager
  7. (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:

bash
nest new plivo-whatsapp-integration
cd plivo-whatsapp-integration

This creates a standard NestJS project structure.

Step 2: Install Dependencies

Install the Plivo Node.js SDK and NestJS configuration module:

bash
npm install plivo @nestjs/config dotenv
npm install --save-dev @types/node # Ensure latest Node types
  • plivo: Official Plivo SDK for Node.js
  • @nestjs/config: Environment variables and configuration management
  • dotenv: Loads environment variables from .env into process.env

Step 3: Configure Environment Variables

Create a .env file in the project root directory:

env
#.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:3000

Important:

  • Replace YOUR_PLIVO_AUTH_ID and YOUR_PLIVO_AUTH_TOKEN with your actual Plivo console credentials
  • Replace +14155551234 with your WhatsApp-enabled Plivo number in E.164 format
  • Security: Never commit .env to version control. Add .env to your .gitignore file

Step 4: Setup Configuration Module

Modify src/app.module.ts to load and manage environment variables using @nestjs/config:

typescript
// 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 .env file and makes configuration accessible throughout the application via ConfigService.

Project Structure (Initial):

Your project structure should now look something like this:

text
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.json

2. 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:

bash
nest generate module plivo
nest generate service plivo --no-spec # --no-spec skips test file generation for now

This 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:

typescript
// 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 ConfigService to access environment variables
  • OnModuleInit initializes the Plivo client when the module loads
  • Retrieve PLIVO_AUTH_ID, PLIVO_AUTH_TOKEN, and PLIVO_WHATSAPP_SENDER_NUMBER from 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() and getSenderNumber() 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:

typescript
// 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

bash
nest generate module whatsapp
nest generate controller whatsapp --no-spec

This 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:

typescript
// 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.

bash
npm install class-validator class-transformer

Step 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:

typescript
// 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: true when creating the application. This is required for Plivo webhook signature validation using plivo.validateV3Signature(). See NestJS raw body documentation for details
  • Global ValidationPipe automatically validates incoming data against DTOs
  • CORS is enabled via app.enableCors()
  • Port is set from environment variable PORT or 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:

typescript
// 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
}
typescript
// 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;
}
typescript
// 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;
}
typescript
// 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.

typescript
// 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.

typescript
// 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):

  1. 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.

  2. Authentication Templates: Used for one-time passwords (OTP) and two-factor authentication. These must include security disclaimers and are typically the lowest cost option.

  3. 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 parameters array
  • 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:

  1. Per-Message Billing: Each template message delivered is charged based on:

    • Message category (Marketing, Utility, Authentication)
    • Recipient's country code
  2. 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)
  3. 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).

  4. No More Free Tier: The previous 1,000 free conversations per month no longer exists.

Pricing Examples (USD, effective October 2025):

CountryMarketingUtilityAuthentication
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

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.