code examples

Sent logo
Sent TeamMar 8, 2026 / code examples / NestJS

Handle Plivo SMS Delivery Status Callbacks in NestJS | Complete Guide

Build a production-ready NestJS application to receive, validate, and store Plivo SMS delivery status callbacks. Learn V2 signature verification, webhook security, and database integration with Prisma.

Track the delivery status of every SMS message you send. Know whether your message reached its destination, failed, or was rejected – enabling robust error handling, accurate reporting, and responsive user feedback. This matters most for:

  • Financial alerts: Confirm transaction notifications reached customers
  • Two-factor authentication: Detect timeout scenarios when codes don't arrive
  • Critical notifications: Retry failed deliveries for appointment reminders or emergency alerts
  • Compliance reporting: Document message delivery for regulatory requirements

Plivo sends delivery status updates via webhooks – real-time notifications delivered to a URL you specify.

This guide walks you through building a production-ready system in Node.js using NestJS to receive, validate, process, and store Plivo SMS delivery status callbacks. You'll master project setup, secure callback handling, data storage, testing, and deployment considerations.

By the end, you'll have a functional NestJS application that:

  1. Sends SMS messages via the Plivo API.
  2. Receives delivery status callbacks from Plivo at a dedicated endpoint.
  3. Securely validates incoming callbacks using Plivo's signature verification.
  4. Parses callback data.
  5. Stores delivery status information in a database (using Prisma and SQLite for this example).
  6. Logs relevant information for monitoring and debugging.

Technologies you'll use:

  • Node.js: v22 LTS recommended for January 2025 – active support until October 2025, maintenance until April 2027.
  • NestJS: v11.1.6 as of January 2025, featuring improved startup performance and enhanced JSON logging. Its modular architecture and built-in features (validation pipes, configuration management) make it ideal for this task.
  • Plivo Node.js SDK: Simplifies interaction with the Plivo API for sending messages and validating callbacks. Install using npm install plivo (use the actively maintained "plivo" package, not the deprecated "plivo-node").
  • Prisma: v6.16.0 as of January 2025, now Rust-free with improved performance and easier deployment. You'll use it for database schema management, migrations, and type-safe database access.
  • SQLite: A file-based database suitable for development and demonstration. Production deployments typically use PostgreSQL or MySQL.
  • @nestjs/config: For managing environment variables securely.
  • ngrok: (For local development) Exposes your local server to the internet so you can receive Plivo webhooks during testing.

System Architecture:

mermaid
graph LR
    A[Your Application / User] --> B(NestJS API);
    B -- Send SMS Request --> C(Plivo API);
    C -- Sends SMS --> D(End User Mobile);
    C -- Delivery Status Callback (POST Request) --> E(NestJS Callback Endpoint);
    E -- Validate Signature --> F(Signature Validation Logic);
    F -- Valid --> G(Process Callback Data);
    G -- Store Status --> H(Database);
    F -- Invalid --> I(Reject Request / Log Error);

    subgraph NestJS Application
        B
        E
        F
        G
    end

    subgraph External Services
        C
        D
        H
    end

    style H fill:#f9f,stroke:#333,stroke-width:2px

Prerequisites:

  • Node.js and npm/yarn: Install Node.js v22 LTS (recommended for January 2025, minimum v18 required for NestJS 11) and npm or yarn.
  • NestJS CLI: Install globally: npm install -g @nestjs/cli
  • Plivo Account: Sign up for a Plivo account with Auth ID, Auth Token, and a Plivo phone number capable of sending SMS. Free trials are available.
  • ngrok (optional but recommended for local testing): Download and install ngrok to expose your local development server.
  • Basic TypeScript knowledge: Familiarity with TypeScript syntax, decorators, and async/await patterns.
  • REST API and webhook concepts: Understanding of HTTP methods, request/response cycles, and webhook architecture.

Time requirement: Approximately 1–2 hours for initial setup and testing. Additional time needed for production deployment and customization.

1. Set up your NestJS project

Create a new NestJS project and navigate into the directory.

bash
# Create a new NestJS project
nest new plivo-callbacks-app

# Change into the project directory
cd plivo-callbacks-app

# Install necessary dependencies
npm install plivo @nestjs/config class-validator class-transformer
npm install prisma @prisma/client --save-dev
  • plivo: The official Plivo Node.js SDK.
  • @nestjs/config: For handling environment variables.
  • class-validator, class-transformer: Used by NestJS for request validation via DTOs.
  • prisma, @prisma/client: The Prisma CLI and Client for database interactions.

Common installation issues:

IssueSolution
npm ERR! peer dependency warningsInstall the specific version suggested or use npm install --legacy-peer-deps
EACCES permission errorsUse nvm to install Node.js without sudo, or prefix commands with npx
TypeScript version conflictsEnsure your project uses TypeScript 5.x: npm install typescript@~5.3.0 --save-dev

Project structure:

NestJS provides a standard structure. You'll add modules for specific features like messaging and callbacks.

plaintext
plivo-callbacks-app/
├── prisma/             # Prisma schema and migrations
│   └── schema.prisma
├── src/
│   ├── app.module.ts   # Root module
│   ├── main.ts         # Application entry point
│   ├── config/         # Configuration setup (optional, using global ConfigModule here)
│   ├── core/           # Core utilities (e.g., PrismaService)
│   ├── messaging/      # Module for sending messages
│   ├── callbacks/      # Module for handling Plivo callbacks
│   └── utils/          # Utility functions (e.g., Plivo validation)
├── .env                # Environment variables (ignored by git)
├── .gitignore
├── nest-cli.json
├── package.json
├── README.md
├── tsconfig.build.json
└── tsconfig.json

2. Configure your Plivo account and application

Configure your Plivo account to send callbacks to your application.

  1. Get credentials: Log in to your Plivo Console. Find your Auth ID and Auth Token on the dashboard overview page – you need these to authenticate API requests and validate callbacks.
  2. Get a Plivo number: Navigate to Phone NumbersBuy Numbers and purchase a number capable of sending SMS messages in your desired region. Note this number.
  3. Create a Plivo application:
    • Go to MessagingXML Applications.
    • Click Add New Application.
    • Give it a name (e.g., NestJS Callback App).
    • Locate the Delivery Report URL (sometimes labeled Message URL or DLR URL) – this is where Plivo sends status updates.
      • For now, enter a placeholder like http://example.com/callbacks/delivery-status. You'll update this later with your actual endpoint URL (likely an ngrok URL during development).
    • Set the Method to POST.
    • Leave other fields blank or default unless you need specific inbound message handling.
    • Click Create Application. Note the App ID generated (though you won't use it directly when specifying callback URLs per message).
  4. Link number to application (optional but recommended): If you want all messages sent from a specific number to use this application's settings by default (instead of specifying the callback URL per message), go to Phone NumbersYour Numbers, click on your number, select your newly created application from the Application Type dropdown, and update. For this guide, you'll specify the callback URL directly when sending the message. This approach offers flexibility – different messages (transactional vs. marketing) sent from the same number can use different callback endpoints or logic.

Trial account limitations:

  • Limited to verified destination numbers (typically 5–10 numbers)
  • Reduced message volume (usually 20–50 messages per day)
  • Messages may include trial account prefixes
  • Upgrade to a paid account before production deployment

3. Configure your environment

Never hardcode sensitive credentials – use environment variables.

  1. Create .env file: In your project root, create a file named .env:

    ini
    # .env
    
    # Plivo Credentials
    PLIVO_AUTH_ID=YOUR_PLIVO_AUTH_ID
    PLIVO_AUTH_TOKEN=YOUR_PLIVO_AUTH_TOKEN
    PLIVO_SENDER_NUMBER=YOUR_PLIVO_PHONE_NUMBER_INCLUDING_COUNTRY_CODE
    
    # Application Settings
    APP_PORT=3000
    # Base URL for callbacks (e.g., ngrok URL or production URL)
    # Example: https://yourapp.com OR https://your-ngrok-id.ngrok.io
    APP_BASE_URL=http://localhost:3000
    
    # Database URL (Prisma uses this)
    DATABASE_URL="file:./dev.db"
    • Replace the placeholder values with your actual Plivo credentials and number.
    • Ensure .env is listed in your .gitignore file to prevent committing secrets.
  2. Create .env.example for team documentation:

    ini
    # .env.example (commit this to git)
    PLIVO_AUTH_ID=
    PLIVO_AUTH_TOKEN=
    PLIVO_SENDER_NUMBER=
    APP_PORT=3000
    APP_BASE_URL=
    DATABASE_URL="file:./dev.db"
  3. Configure NestJS ConfigModule: Update your root AppModule to load and manage environment variables.

    typescript
    // src/app.module.ts
    import { Module } from '@nestjs/common';
    import { ConfigModule } from '@nestjs/config';
    import { CallbacksModule } from './callbacks/callbacks.module';
    import { MessagingModule } from './messaging/messaging.module';
    import { CoreModule } from './core/core.module'; // For PrismaService
    
    @Module({
      imports: [
        ConfigModule.forRoot({
          isGlobal: true, // Makes ConfigModule available globally
          envFilePath: '.env',
          validationOptions: {
            allowUnknown: true,
            abortEarly: false,
          },
        }),
        CoreModule, // Import CoreModule for PrismaService
        CallbacksModule,
        MessagingModule,
      ],
      controllers: [],
      providers: [],
    })
    export class AppModule {}

    ConfigModule.forRoot({ isGlobal: true }) makes the ConfigService available throughout your application via dependency injection without needing to import ConfigModule in every feature module.

4. Build the callback endpoint

This endpoint receives the POST requests from Plivo containing delivery status information.

  1. Generate Module, Controller, Service:

    bash
    nest g module callbacks
    nest g controller callbacks --no-spec
    nest g service callbacks --no-spec
  2. Define the DTO (Data Transfer Object): Create a DTO to define the expected structure of the callback payload and enable automatic validation using class-validator.

    typescript
    // src/callbacks/dto/plivo-status-callback.dto.ts
    import { IsString, IsOptional, IsEnum, IsNotEmpty } from 'class-validator';
    
    // Define possible Plivo message statuses
    // Ref: https://www.plivo.com/docs/sms/api/message#delivery-status-values
    export enum PlivoMessageStatus {
      QUEUED = 'queued',
      SENT = 'sent',
      FAILED = 'failed',
      DELIVERED = 'delivered',
      UNDELIVERED = 'undelivered',
      REJECTED = 'rejected',
    }
    
    export class PlivoStatusCallbackDto {
      @IsString()
      @IsNotEmpty()
      MessageUUID: string;
    
      @IsEnum(PlivoMessageStatus)
      @IsNotEmpty()
      Status: PlivoMessageStatus;
    
      @IsString()
      @IsOptional() // Optional, might not be present for all statuses
      ErrorCode?: string;
    
      @IsString()
      @IsOptional()
      MessageTime?: string; // Timestamp from Plivo
    
      @IsString()
      @IsOptional()
      MessageDirection?: string; // Should be 'outbound' for delivery reports
    
      @IsString()
      @IsOptional()
      MessageType?: string; // e.g., 'sms'
    
      @IsString()
      @IsOptional()
      To?: string; // Recipient number
    
      @IsString()
      @IsOptional()
      From?: string; // Sender number (Plivo number)
    
      @IsString()
      @IsOptional()
      Units?: string; // Number of message segments
    
      @IsString()
      @IsOptional()
      TotalRate?: string; // Cost of the message
    
      @IsString()
      @IsOptional()
      TotalAmount?: string; // Cost of the message
    
      // Add any other fields you expect from the callback if needed
    }

    Why DTOs? They provide clear contracts for your API endpoints, enable automatic request body validation using decorators, and improve type safety in your TypeScript code.

    Handling unexpected fields: NestJS validation pipes strip properties not defined in your DTO when whitelist: true is enabled (configured in step 4.3). Plivo may add new fields over time – store the complete raw payload in your database to avoid losing data during API updates.

  3. Implement the Controller: Set up the route to listen for POST requests.

    typescript
    // src/callbacks/callbacks.controller.ts
    import {
      Controller,
      Post,
      Body,
      HttpCode,
      HttpStatus,
      UseGuards,
      Logger,
    } from '@nestjs/common';
    import { CallbacksService } from './callbacks.service';
    import { PlivoStatusCallbackDto } from './dto/plivo-status-callback.dto';
    import { PlivoSignatureGuard } from './guards/plivo-signature.guard'; // We'll create this guard
    
    @Controller('callbacks') // Route prefix: /callbacks
    export class CallbacksController {
      private readonly logger = new Logger(CallbacksController.name);
    
      constructor(private readonly callbacksService: CallbacksService) {}
    
      @Post('delivery-status') // Full route: POST /callbacks/delivery-status
      @UseGuards(PlivoSignatureGuard) // Apply the signature validation guard
      @HttpCode(HttpStatus.NO_CONTENT) // Respond with 204 No Content on success
      async handleDeliveryStatus(
        @Body() payload: PlivoStatusCallbackDto, // Automatically validates based on DTO
      ): Promise<void> {
        // Signature validation is handled by the PlivoSignatureGuard
    
        this.logger.log(
          `Received Plivo delivery status for ${payload.MessageUUID}: ${payload.Status}`,
        );
    
        // The guard has already validated the request authenticity.
        // Now, process the validated payload.
        try {
          await this.callbacksService.processStatusUpdate(payload);
          // No explicit return needed due to @HttpCode(HttpStatus.NO_CONTENT)
          // Plivo expects a 2xx response to acknowledge receipt. 204 is appropriate.
        } catch (error) {
          this.logger.error(
            `Error processing callback for ${payload.MessageUUID}: ${error.message}`,
            error.stack,
          );
          // Even if processing fails internally, acknowledge receipt to Plivo.
          // Log the error for investigation. Consider implementing retries or dead-letter queues.
          // Do not throw an HTTP exception here unless you want Plivo to potentially retry.
        }
      }
    }

    Why @HttpCode(HttpStatus.NO_CONTENT)? Plivo needs acknowledgment (a 2xx status code) that you received the callback. Sending back content isn't necessary, and 204 No Content is semantically correct.

    Plivo retry behavior: If your endpoint returns a non-2xx status (4xx or 5xx), Plivo retries the webhook with exponential backoff. Return 204 after receiving the callback to prevent unnecessary retries, even if internal processing encounters errors. Handle failures asynchronously using retry queues or error monitoring.

  4. Implement the Service: Define the business logic for handling the status update.

    typescript
    // src/callbacks/callbacks.service.ts
    import { Injectable, Logger } from '@nestjs/common';
    import { PlivoStatusCallbackDto, PlivoMessageStatus } from './dto/plivo-status-callback.dto';
    import { PrismaService } from '../core/prisma.service'; // Import PrismaService
    
    @Injectable()
    export class CallbacksService {
      private readonly logger = new Logger(CallbacksService.name);
    
      constructor(private readonly prisma: PrismaService) {} // Inject PrismaService
    
      async processStatusUpdate(
        payload: PlivoStatusCallbackDto,
      ): Promise<void> {
        this.logger.log(
          `Processing status update for MessageUUID: ${payload.MessageUUID}, Status: ${payload.Status}`,
        );
    
        // --- Database Interaction ---
        // Use Prisma to create or update the record
        // Using upsert is good practice if you might receive multiple updates
        // for the same MessageUUID (e.g., sent → delivered)
        try {
          const statusData = {
            status: payload.Status,
            errorCode: payload.ErrorCode,
            plivoTimestamp: payload.MessageTime ? new Date(payload.MessageTime) : null,
            recipient: payload.To,
            sender: payload.From,
            rawPayload: JSON.stringify(payload), // Store the full payload for debugging
            processedAt: new Date(),
          };
    
          await this.prisma.deliveryStatus.upsert({
            where: { messageUuid: payload.MessageUUID },
            update: statusData,
            create: {
              messageUuid: payload.MessageUUID,
              ...statusData,
            },
          });
    
          this.logger.log(`Successfully stored/updated status for ${payload.MessageUUID}`);
    
          // Trigger downstream actions based on status
          await this.handleStatusSpecificActions(payload);
        } catch (error) {
          this.logger.error(`Database error processing status for ${payload.MessageUUID}: ${error.message}`, error.stack);
          // Re-throwing allows centralized error handling if needed, but ensures 204 isn't sent on DB error
          throw error;
        }
      }
    
      private async handleStatusSpecificActions(payload: PlivoStatusCallbackDto): Promise<void> {
        // Implement business logic based on delivery status
        switch (payload.Status) {
          case PlivoMessageStatus.FAILED:
          case PlivoMessageStatus.UNDELIVERED:
          case PlivoMessageStatus.REJECTED:
            this.logger.warn(`Message ${payload.MessageUUID} failed with code ${payload.ErrorCode}`);
            // Example: Trigger alert, add to retry queue, or notify user
            break;
          case PlivoMessageStatus.DELIVERED:
            this.logger.log(`Message ${payload.MessageUUID} successfully delivered`);
            // Example: Update user dashboard, mark notification as confirmed
            break;
        }
      }
    }

    Handling out-of-order delivery: Callbacks may arrive out of sequence (e.g., "delivered" before "sent"). Store timestamps and use the most recent status by comparing plivoTimestamp values during upsert operations. Consider adding a status priority system if your application logic depends on status progression.

5. Security: Validate Plivo signatures

Verify that incoming webhook requests actually originate from Plivo. Plivo provides signatures for this purpose.

Important: Plivo uses different signature versions for different services:

  • SMS callbacks use X-Plivo-Signature-V2 header
  • Voice callbacks use X-Plivo-Signature-V3 and X-Plivo-Signature-V3-Nonce headers

This guide focuses on SMS delivery status callbacks, which use V2 signature validation. The validation process involves verifying the HMAC-SHA256 signature using your Plivo Auth Token.

Security best practices:

PracticeImplementation
Rate limitingUse @nestjs/throttler to limit requests: @Throttle(100, 60) (100 requests per minute)
IP whitelistingConfigure your firewall to allow only Plivo's IP ranges (check Plivo documentation)
TLS/HTTPS onlyRequire HTTPS for all webhook endpoints in production
Monitor failuresAlert on repeated signature validation failures (may indicate attack attempts)
  1. Create Signature Validation Helper: Based on Plivo's documentation, create a utility function.

    typescript
    // src/utils/plivo-validation.util.ts
    import * as crypto from 'crypto';
    import { URL } from 'url'; // Use Node's built-in URL parser
    
    /**
     * Validates Plivo V2 Webhook Signatures for SMS callbacks.
     * Note: Voice callbacks use V3 signatures with nonce. This function is for SMS delivery status callbacks.
     * Ref: https://www.plivo.com/docs/messaging/api/message/message-status-callbacks
     * @param requestUrl The full URL Plivo sent the request to (including scheme, host, path, and query params). **Must match exactly what Plivo used.**
     * @param signature The value from the 'X-Plivo-Signature-V2' header.
     * @param authToken Your Plivo Auth Token.
     * @param postParams The raw request body as a string (if it's a POST request).
     * @returns True if the signature is valid, false otherwise.
     */
    export function validatePlivoSignature(
      requestUrl: string,
      signature: string,
      authToken: string,
      postParams: string, // Raw request body as a string
    ): boolean {
      try {
        // For V2 (SMS): Construct the base string: URL + RawBody
        // V3 (Voice) additionally includes a nonce, but SMS callbacks use V2
        const baseString = `${requestUrl}${postParams}`;
    
        const expectedSignature = crypto
          .createHmac('sha256', authToken)
          .update(baseString)
          .digest('base64');
    
        // Securely compare the signatures using timingSafeEqual
        return crypto.timingSafeEqual(
          Buffer.from(signature, 'base64'), // Ensure comparison buffers use same encoding
          Buffer.from(expectedSignature, 'base64'),
        );
      } catch (error) {
        console.error('Error during Plivo signature validation:', error);
        return false;
      }
    }

    Important: Plivo's V2 signature for SMS includes the full URL (scheme, host, path, query) and the raw request body concatenated. Ensure you reconstruct this correctly. Getting the raw body requires specific configuration in NestJS.

    Common validation failures:

    • Mismatched URL: Your reconstructed URL doesn't match what Plivo sent (check protocol, host, port)
    • Proxy headers: Load balancers modify the host header – see step 5.3 for X-Forwarded-* handling
    • Character encoding: Raw body encoding mismatch – always use UTF-8
    • Missing signature: Check header name case-sensitivity: x-plivo-signature-v2 vs X-Plivo-Signature-V2
  2. Enable Raw Body Parsing: Modify main.ts to access the raw request body.

    typescript
    // src/main.ts
    import { NestFactory } from '@nestjs/core';
    import { AppModule } from './app.module';
    import { ValidationPipe, RawBodyRequest } from '@nestjs/common'; // Import RawBodyRequest
    import { ConfigService } from '@nestjs/config';
    import { NestExpressApplication } from '@nestjs/platform-express'; // Use NestExpressApplication for rawBody
    import * as bodyParser from 'body-parser'; // Import body-parser
    
    async function bootstrap() {
      // Use NestExpressApplication for Express-specific features like rawBody
      const app = await NestFactory.create<NestExpressApplication>(AppModule, {
        // Enable raw body parsing alongside JSON parsing
        rawBody: true, // Crucial: Makes the raw request body available via request.rawBody
      });
    
      // Ensure standard body parsing middleware (JSON, urlencoded) is active
      // NestJS enables this by default when using Express, but explicit use can ensure it.
      // Use bodyParser directly for more control if needed, or rely on NestJS defaults.
      app.use(bodyParser.json({ limit: '1mb' })); // Example: Adjust limit if needed
      app.use(bodyParser.urlencoded({ extended: true, limit: '1mb' })); // Example: Adjust limit if needed
    
      const configService = app.get(ConfigService);
      const port = configService.get<number>('APP_PORT', 3000);
      const appBaseUrl = configService.get<string>('APP_BASE_URL'); // Get base URL
    
      // 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 extra properties are sent
        }),
      );
    
      await app.listen(port);
      console.log(`Application is running on: http://localhost:${port}`);
      console.log(`Callback Base URL configured as: ${appBaseUrl}`);
      console.log(`Plivo callbacks expected at: ${appBaseUrl}/callbacks/delivery-status`);
    }
    bootstrap();
  3. Create a NestJS Guard: Implement the signature validation logic within a guard.

    typescript
    // src/callbacks/guards/plivo-signature.guard.ts
    import {
      Injectable,
      CanActivate,
      ExecutionContext,
      UnauthorizedException,
      Logger,
      RawBodyRequest, // Import RawBodyRequest
      InternalServerErrorException, // Import for config errors
    } from '@nestjs/common';
    import { ConfigService } from '@nestjs/config';
    import { Request } from 'express'; // Use Express Request type
    import { validatePlivoSignature } from '../../utils/plivo-validation.util';
    
    @Injectable()
    export class PlivoSignatureGuard implements CanActivate {
      private readonly logger = new Logger(PlivoSignatureGuard.name);
    
      constructor(private readonly configService: ConfigService) {}
    
      async canActivate(context: ExecutionContext): Promise<boolean> {
        const request = context.switchToHttp().getRequest<RawBodyRequest<Request>>();
        const signature = request.headers['x-plivo-signature-v2'] as string;
    
        if (!signature) {
          this.logger.warn('Missing Plivo signature header (X-Plivo-Signature-V2)');
          throw new UnauthorizedException('Missing Plivo signature header');
        }
    
        // Check if rawBody is available (depends on main.ts setup)
        if (!request.rawBody) {
          this.logger.error('Raw request body not available. Ensure rawBody: true is set in NestFactory and using NestExpressApplication.');
          // Throw internal server error as this is a config issue
          throw new InternalServerErrorException('Server configuration error: Raw body parsing not enabled.');
        }
    
        const authToken = this.configService.get<string>('PLIVO_AUTH_TOKEN');
        if (!authToken) {
          this.logger.error('PLIVO_AUTH_TOKEN is not configured.');
          // Throw internal server error, don't expose specifics
          throw new InternalServerErrorException('Server configuration error: Auth token missing.');
        }
    
        // Construct the full URL Plivo would have used.
        // IMPORTANT: If behind a reverse proxy/load balancer, ensure 'host' and 'protocol'
        // reflect the public-facing URL, not internal ones. Headers like
        // 'X-Forwarded-Proto' and 'X-Forwarded-Host' might need to be considered.
        const protocol = request.headers['x-forwarded-proto'] as string || request.protocol;
        const host = request.headers['x-forwarded-host'] as string || request.get('host');
        const originalUrl = request.originalUrl; // e.g., '/callbacks/delivery-status?query=param'
        const fullUrl = `${protocol}://${host}${originalUrl}`;
    
        this.logger.debug(`Validating V2 signature for URL: ${fullUrl}`);
    
        const isValid = validatePlivoSignature(
          fullUrl,
          signature,
          authToken,
          request.rawBody.toString('utf-8'), // Pass raw body as string
        );
    
        if (!isValid) {
          this.logger.warn(`Invalid Plivo V2 signature received for URL: ${fullUrl}`);
          throw new UnauthorizedException('Invalid Plivo signature');
        }
    
        this.logger.log(`Valid Plivo V2 signature verified for URL: ${fullUrl}`);
        return true; // Allow request processing
      }
    }

    Handling reverse proxies: When your NestJS app runs behind nginx, AWS ALB, or similar proxies, the protocol and host values may reflect internal routing instead of the public URL. Configure your proxy to forward original headers:

    nginx
    # nginx configuration example
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    Update your NestJS app to trust proxy headers:

    typescript
    // In main.ts, after creating the app
    app.set('trust proxy', 1); // Trust first proxy
  4. Apply the Guard: Add @UseGuards(PlivoSignatureGuard) to the handleDeliveryStatus method in CallbacksController, as shown earlier. Ensure the CallbacksModule imports ConfigModule if it's not global, or provides ConfigService appropriately.

6. Create a database schema and data layer (Prisma)

Store delivery statuses using Prisma and SQLite.

  1. Initialize Prisma:

    bash
    npx prisma init --datasource-provider sqlite

    This creates prisma/schema.prisma and updates .env with DATABASE_URL="file:./dev.db".

  2. Define Schema: Edit prisma/schema.prisma.

    prisma
    // prisma/schema.prisma
    generator client {
      provider = "prisma-client-js"
    }
    
    datasource db {
      provider = "sqlite" // Or "postgresql", "mysql"
      url      = env("DATABASE_URL")
    }
    
    model DeliveryStatus {
      id             Int       @id @default(autoincrement())
      messageUuid    String    @unique // Plivo's MessageUUID
      status         String    // e.g., delivered, failed, sent
      errorCode      String?   // Plivo error code if status is failed/undelivered
      recipient      String?
      sender         String?
      plivoTimestamp DateTime? // Timestamp from Plivo callback
      receivedAt     DateTime  @default(now()) // Timestamp when we received it
      processedAt    DateTime? // Timestamp when we processed it (updated on successful processing)
      rawPayload     String?   // Store the raw JSON payload for debugging
    
      @@index([status])
      @@index([receivedAt])
    }

    Why store rawPayload? It's invaluable for debugging issues with callbacks or understanding unexpected data formats.

    Useful queries for reporting:

    typescript
    // Find all failed messages in the last 24 hours
    const failedMessages = await prisma.deliveryStatus.findMany({
      where: {
        status: { in: ['failed', 'undelivered', 'rejected'] },
        receivedAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) }
      }
    });
    
    // Calculate delivery rate
    const stats = await prisma.deliveryStatus.groupBy({
      by: ['status'],
      _count: true
    });
  3. Run Migration: Apply the schema to your database.

    bash
    npx prisma migrate dev --name init-delivery-status

    This creates the SQLite database file (prisma/dev.db) and generates the Prisma Client.

  4. Create Prisma Service: Create a reusable service for Prisma Client.

    bash
    # Create a core module if you don't have one
    nest g module core
    nest g service core/prisma --no-spec
    typescript
    // src/core/prisma.service.ts
    import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
    import { PrismaClient } from '@prisma/client';
    
    @Injectable()
    export class PrismaService
      extends PrismaClient
      implements OnModuleInit, OnModuleDestroy
    {
      constructor() {
        super({
          // Optionally configure logging, etc.
          // log: ['query', 'info', 'warn', 'error'],
        });
      }
    
      async onModuleInit() {
        await this.$connect();
      }
    
      async onModuleDestroy() {
        await this.$disconnect();
      }
    }

    Make sure CoreModule exports PrismaService and is imported in AppModule.

    typescript
    // src/core/core.module.ts
    import { Global, Module } from '@nestjs/common';
    import { PrismaService } from './prisma.service';
    
    @Global() // Make PrismaService available globally
    @Module({
      providers: [PrismaService],
      exports: [PrismaService],
    })
    export class CoreModule {}
  5. Inject PrismaService: The CallbacksService shown in Step 4 already includes the injection and usage of PrismaService.

7. Send a message and trigger callbacks

Implement the functionality to send an SMS and tell Plivo where to send the delivery report.

  1. Generate Messaging Module/Service/Controller:

    bash
    nest g module messaging
    nest g service messaging --no-spec
    nest g controller messaging --no-spec
  2. Implement Messaging Service:

    typescript
    // src/messaging/messaging.service.ts
    import { Injectable, Logger, InternalServerErrorException } from '@nestjs/common';
    import { ConfigService } from '@nestjs/config';
    import * as plivo from 'plivo'; // Use import * as plivo
    
    @Injectable()
    export class MessagingService {
      private readonly logger = new Logger(MessagingService.name);
      private plivoClient: plivo.Client;
      private senderNumber: string;
      private callbackBaseUrl: string;
    
      constructor(private readonly configService: ConfigService) {
        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_SENDER_NUMBER');
        this.callbackBaseUrl = this.configService.get<string>('APP_BASE_URL');
    
        if (!authId || !authToken || !this.senderNumber || !this.callbackBaseUrl) {
          const missing = [
            !authId && 'PLIVO_AUTH_ID',
            !authToken && 'PLIVO_AUTH_TOKEN',
            !this.senderNumber && 'PLIVO_SENDER_NUMBER',
            !this.callbackBaseUrl && 'APP_BASE_URL'
          ].filter(Boolean).join(', ');
          this.logger.error(`Plivo configuration missing: ${missing}`);
          throw new InternalServerErrorException('Server configuration error for Plivo Messaging.');
        }
    
        this.plivoClient = new plivo.Client(authId, authToken);
        this.logger.log('Plivo client initialized.');
      }
    
      async sendSms(to: string, text: string): Promise<{ messageUuid: string }> {
        // Construct the full callback URL
        const callbackUrl = `${this.callbackBaseUrl}/callbacks/delivery-status`;
    
        this.logger.log(`Sending SMS to ${to} from ${this.senderNumber} with callback URL: ${callbackUrl}`);
    
        try {
          const response = await this.plivoClient.messages.create(
            this.senderNumber, // src
            to,               // dst
            text,             // text
            {
              url: callbackUrl, // Specify the delivery report URL here
              method: 'POST',   // Ensure method matches your endpoint
            },
          );
    
          this.logger.log(`SMS submitted to Plivo. API Response:`, response);
    
          // Verify response structure and extract UUID safely
          if (!response || !Array.isArray(response.messageUuid) || response.messageUuid.length === 0) {
            this.logger.error('Plivo API response missing expected messageUuid array.', response);
            throw new Error('Plivo API did not return a valid message UUID.');
          }
    
          const messageUuid = response.messageUuid[0];
          this.logger.log(`Message UUID: ${messageUuid}`);
          return { messageUuid };
    
        } catch (error) {
          this.logger.error(`Failed to send SMS via Plivo: ${error.message}`, error.stack);
    
          // Handle rate limit errors specifically
          if (error.status === 429) {
            throw new InternalServerErrorException('Plivo rate limit exceeded. Try again later.');
          }
    
          // Rethrow a more generic error or a specific application error
          throw new InternalServerErrorException(`Plivo SMS sending failed: ${error.message}`);
        }
      }
    }

    Crucial Point: The { url: callbackUrl } parameter in client.messages.create tells Plivo where to send the delivery status for this specific message. This overrides any default URL set in the Plivo Application settings.

    Handling rate limits: Plivo enforces rate limits based on your account tier. Implement exponential backoff or use a message queue (Bull, BullMQ) to handle rate limit errors gracefully and retry failed sends.

  3. Implement Messaging Controller (for testing): Add a simple endpoint to trigger sending an SMS.

    typescript
    // src/messaging/messaging.controller.ts
    import { Controller, Post, Body, HttpCode, HttpStatus, Logger, UseGuards } from '@nestjs/common';
    import { MessagingService } from './messaging.service';
    import { IsNotEmpty, IsString, IsPhoneNumber } from 'class-validator';
    
    // DTO for the test endpoint
    class SendSmsDto {
      @IsPhoneNumber(undefined, { message: 'Recipient must be a valid phone number (e.g., +15551234567)' })
      @IsNotEmpty()
      to: string;
      // Note: For robust international validation in production, consider integrating
      // a library like 'google-libphonenumber' as class-validator's check is basic.
    
      @IsString()
      @IsNotEmpty()
      text: string;
    }
    
    @Controller('messaging')
    export class MessagingController {
      private readonly logger = new Logger(MessagingController.name);
    
      constructor(private readonly messagingService: MessagingService) {}
    
      @Post('send-test')
      @HttpCode(HttpStatus.ACCEPTED) // Accepted (202) is suitable as sending is async
      // TODO: Add authentication guard before production use
      // @UseGuards(AuthGuard)
      async sendTestSms(@Body() body: SendSmsDto) {
        this.logger.log(`Received request to send test SMS to ${body.to}`);
        try {
          const result = await this.messagingService.sendSms(body.to, body.text);
          this.logger.log(`SMS send initiated, Message UUID: ${result.messageUuid}`);
          return {
            message: 'SMS send request accepted.',
            messageUuid: result.messageUuid,
          };
        } catch (error) {
          // Error is already logged in the service, re-throw or handle as needed
          // NestJS default exception filter will catch InternalServerErrorException
          this.logger.error(`Error in sendTestSms controller: ${error.message}`);
          throw error; // Re-throw the exception caught from the service
        }
      }
    }

    Security warning: This test endpoint has no authentication. Add an API key guard, JWT authentication, or IP whitelist before deploying to production. Without protection, anyone can send SMS messages through your account and incur charges.

8. Test your webhook locally with ngrok

Before deploying to production, test your Plivo delivery status webhook locally using ngrok.

  1. Start your NestJS application:

    bash
    npm run start:dev
  2. Expose your local server with ngrok:

    bash
    ngrok http 3000

    ngrok will display a forwarding URL like https://abc123.ngrok.io.

  3. Update your .env file:

    ini
    APP_BASE_URL=https://abc123.ngrok.io

    Restart your NestJS application to pick up the new URL.

  4. Send a test SMS:

    bash
    curl -X POST http://localhost:3000/messaging/send-test \
      -H "Content-Type: application/json" \
      -d '{"to": "+15551234567", "text": "Test message for delivery status"}'
  5. Monitor the logs:

    Watch your NestJS console for incoming callback requests. You should see:

    • Signature validation messages
    • Delivery status updates (queued → sent → delivered)
    • Database storage confirmations

ngrok debugging tools:

  • Web interface: Visit http://localhost:4040 to see all HTTP requests and responses
  • Replay requests: Use the web interface to replay webhook requests for testing
  • Inspect headers: View all headers Plivo sends, including signature headers
  • Request history: See timing, status codes, and full request/response bodies

Common ngrok issues:

IssueSolution
"ngrok not found"Install with brew install ngrok (macOS) or download from ngrok.com
Connection refusedEnsure your NestJS app is running before starting ngrok
URL changes on restartGet a static domain with an ngrok paid plan
Tunnel expiredFree ngrok tunnels expire after 2 hours – restart ngrok

9. Production deployment considerations

When deploying your NestJS Plivo webhook handler to production:

  1. Use HTTPS: Plivo requires HTTPS for webhook endpoints in production. Use a reverse proxy (nginx, Caddy) or deploy to platforms with built-in SSL (Heroku, AWS, Google Cloud).

  2. Update environment variables: Replace your APP_BASE_URL with your production domain.

  3. Database migration: Switch from SQLite to PostgreSQL or MySQL for production. Update your DATABASE_URL and Prisma schema accordingly:

    prisma
    datasource db {
      provider = "postgresql"
      url      = env("DATABASE_URL")
    }

    Example production DATABASE_URL:

    DATABASE_URL="postgresql://user:password@localhost:5432/plivo_db?schema=public"
  4. Error handling and retries: Plivo retries failed webhook deliveries with exponential backoff (typically 15 minutes, 1 hour, 4 hours, 12 hours). Implement idempotent processing using the messageUuid as a unique identifier to handle duplicate callbacks safely.

  5. Monitoring and alerting: Set up application monitoring (Datadog, New Relic, Sentry) to track webhook failures and processing errors. Key metrics to monitor:

    • Signature validation failure rate
    • Callback processing time
    • Database write failures
    • Message delivery success rate by status
  6. Rate limiting: Implement rate limiting on your webhook endpoint to protect against potential abuse:

    bash
    npm install @nestjs/throttler
    typescript
    // In app.module.ts
    ThrottlerModule.forRoot({
      ttl: 60,
      limit: 100, // 100 requests per minute
    })
  7. Scaling considerations: For high-volume applications (1,000+ messages/day), use a message queue (Redis, RabbitMQ, AWS SQS) to process callbacks asynchronously. This prevents webhook timeouts and enables horizontal scaling.

Platform-specific deployment:

PlatformConfiguration
HerokuSet APP_BASE_URL to https://your-app.herokuapp.com
AWS Elastic BeanstalkConfigure environment variables in EB console, use ALB for SSL
Google Cloud RunEnable ingress for webhook traffic, set X-Forwarded-Proto trust
DockerExpose port 3000, set trust proxy in main.ts for load balancer support

Frequently Asked Questions (FAQ)

How do I handle Plivo delivery status callbacks in NestJS?

Create a dedicated NestJS controller with a POST endpoint to receive Plivo webhooks. Validate incoming requests using Plivo's V2 signature verification (X-Plivo-Signature-V2 header for SMS callbacks), parse the payload with DTOs, and store status updates in your database using Prisma or your preferred ORM.

What's the difference between Plivo V2 and V3 signature validation?

Plivo uses V2 signatures for SMS delivery status callbacks (X-Plivo-Signature-V2 header) and V3 signatures for voice callbacks (X-Plivo-Signature-V3 with nonce). V2 validates using URL + raw body, while V3 adds a nonce parameter for replay protection. For SMS webhooks, always use V2 validation.

How do I secure my Plivo webhook endpoint?

Implement signature validation using Plivo's Auth Token to verify requests originate from Plivo. Use NestJS guards to validate the X-Plivo-Signature-V2 header before processing any callback data. Never skip signature validation in production – it prevents unauthorized access and webhook spoofing.

What Node.js and NestJS versions should I use for Plivo integration?

Use Node.js v22 LTS (recommended for January 2025) with NestJS v11.1.6 or later. Node.js v22 provides active support until October 2025 and maintenance until April 2027. NestJS 11 requires minimum Node.js v18 and offers improved startup performance and enhanced JSON logging.

How do I test Plivo webhooks locally?

Use ngrok to expose your local NestJS server to the internet. Run ngrok http 3000, copy the HTTPS URL, update your APP_BASE_URL environment variable, and send a test SMS through your API. Plivo delivers callbacks to your ngrok URL, which forwards them to your local server.

What Plivo message statuses should I handle?

Handle these status values: queued (message accepted), sent (submitted to carrier), delivered (reached recipient), failed (permanent failure), undelivered (temporary failure), and rejected (invalid recipient or content). Store each status with timestamp and error code for comprehensive delivery tracking.

Common error codes:

  • 10001: Invalid destination number
  • 10002: Sender ID not configured
  • 10003: Insufficient account balance
  • 10004: Spam detected or blocked content
  • 10006: Message expired (carrier timeout)

How do I store Plivo delivery statuses in a database?

Use Prisma with SQLite for development or PostgreSQL for production. Create a DeliveryStatus model with fields for messageUuid (unique identifier), status, errorCode, recipient, sender, timestamps, and the raw payload JSON. Use Prisma's upsert operation to handle multiple status updates for the same message.

Can I use the same callback endpoint for multiple Plivo applications?

Yes. Specify the callback URL per message using the url parameter in client.messages.create() rather than relying on application-level settings. This approach offers flexibility – different message types (transactional, marketing, OTP) can use different callback URLs and processing logic while sharing the same Plivo phone number.

How do I handle Plivo webhook retries?

Plivo automatically retries failed webhook deliveries with exponential backoff. Implement idempotent processing by using messageUuid as a unique constraint in your database. Use Prisma's upsert operation to safely handle duplicate callbacks – update existing records instead of creating duplicates. Always return a 2xx status code (204 No Content) once you've received the callback, even if internal processing encounters errors.

Plivo retry schedule:

  • First retry: 15 minutes after initial failure
  • Second retry: 1 hour after first retry
  • Third retry: 4 hours after second retry
  • Fourth retry: 12 hours after third retry
  • Maximum: 5 total delivery attempts

What's the best way to debug Plivo webhook issues?

Enable detailed logging for signature validation, store the raw callback payload in your database, and monitor your application logs. Check that your APP_BASE_URL matches the URL Plivo sends requests to (including protocol and host). Use ngrok's web interface (http://localhost:4040) to inspect incoming webhook requests and responses during local development.

Conclusion

You've built a production-ready NestJS application that handles Plivo SMS delivery status callbacks with secure signature validation, persistent data storage, and comprehensive error handling. This system gives you real-time visibility into message delivery, enabling you to build reliable SMS communication features with proper monitoring and debugging capabilities.

The V2 signature validation implementation protects your webhook endpoint from unauthorized requests, while Prisma provides type-safe database access for tracking delivery statuses. Your NestJS application now handles the complete SMS lifecycle – from sending messages through the Plivo API to processing delivery confirmations and storing results for reporting and analytics.

Next steps:

  1. Add authentication: Secure your /messaging/send-test endpoint with JWT or API key authentication
  2. Implement retry logic: Create a queue for failed messages with automatic retry attempts
  3. Build analytics: Add endpoints to query delivery rates, failure patterns, and cost tracking
  4. Set up alerts: Configure monitoring to notify you of delivery failures or signature validation issues
  5. Optimize for scale: Migrate to PostgreSQL and implement message queues for high-volume scenarios

Advanced patterns to consider:

  • Message templates: Store reusable SMS templates with variable substitution
  • Batch processing: Send multiple messages efficiently using Plivo's bulk API
  • Status webhooks: Notify users in real-time when their messages are delivered
  • Cost tracking: Calculate and report per-message costs using TotalAmount field
  • A/B testing: Track delivery rates across different message content or timing

As you scale your SMS infrastructure, consider implementing message queues for asynchronous processing, migrating to PostgreSQL for production workloads, and adding monitoring tools to track webhook performance. The modular NestJS architecture you've built makes these enhancements straightforward to implement.

Ready to send your first message? Start your development server, expose it with ngrok, and test the complete webhook flow. Your delivery status tracking system is ready to power reliable SMS communication for your application.