code examples
code examples
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:
- Sends SMS messages via the Plivo API.
- Receives delivery status callbacks from Plivo at a dedicated endpoint.
- Securely validates incoming callbacks using Plivo's signature verification.
- Parses callback data.
- Stores delivery status information in a database (using Prisma and SQLite for this example).
- 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:
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:2pxPrerequisites:
- 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 installngrokto 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.
# 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-devplivo: 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:
| Issue | Solution |
|---|---|
npm ERR! peer dependency warnings | Install the specific version suggested or use npm install --legacy-peer-deps |
EACCES permission errors | Use nvm to install Node.js without sudo, or prefix commands with npx |
| TypeScript version conflicts | Ensure 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.
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.json2. Configure your Plivo account and application
Configure your Plivo account to send callbacks to your application.
- 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.
- Get a Plivo number: Navigate to
Phone Numbers→Buy Numbersand purchase a number capable of sending SMS messages in your desired region. Note this number. - Create a Plivo application:
- Go to
Messaging→XML 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 anngrokURL during development).
- For now, enter a placeholder like
- Set the
MethodtoPOST. - Leave other fields blank or default unless you need specific inbound message handling.
- Click
Create Application. Note theApp IDgenerated (though you won't use it directly when specifying callback URLs per message).
- Go to
- 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 Numbers→Your Numbers, click on your number, select your newly created application from theApplication Typedropdown, 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.
-
Create
.envfile: 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
.envis listed in your.gitignorefile to prevent committing secrets.
-
Create
.env.examplefor 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" -
Configure NestJS
ConfigModule: Update your rootAppModuleto 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 theConfigServiceavailable throughout your application via dependency injection without needing to importConfigModulein every feature module.
4. Build the callback endpoint
This endpoint receives the POST requests from Plivo containing delivery status information.
-
Generate Module, Controller, Service:
bashnest g module callbacks nest g controller callbacks --no-spec nest g service callbacks --no-spec -
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: trueis 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. -
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.
-
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
plivoTimestampvalues 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-V2header - Voice callbacks use
X-Plivo-Signature-V3andX-Plivo-Signature-V3-Nonceheaders
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:
| Practice | Implementation |
|---|---|
| Rate limiting | Use @nestjs/throttler to limit requests: @Throttle(100, 60) (100 requests per minute) |
| IP whitelisting | Configure your firewall to allow only Plivo's IP ranges (check Plivo documentation) |
| TLS/HTTPS only | Require HTTPS for all webhook endpoints in production |
| Monitor failures | Alert on repeated signature validation failures (may indicate attack attempts) |
-
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-v2vsX-Plivo-Signature-V2
-
Enable Raw Body Parsing: Modify
main.tsto 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(); -
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
protocolandhostvalues 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 -
Apply the Guard: Add
@UseGuards(PlivoSignatureGuard)to thehandleDeliveryStatusmethod inCallbacksController, as shown earlier. Ensure theCallbacksModuleimportsConfigModuleif it's not global, or providesConfigServiceappropriately.
6. Create a database schema and data layer (Prisma)
Store delivery statuses using Prisma and SQLite.
-
Initialize Prisma:
bashnpx prisma init --datasource-provider sqliteThis creates
prisma/schema.prismaand updates.envwithDATABASE_URL="file:./dev.db". -
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 }); -
Run Migration: Apply the schema to your database.
bashnpx prisma migrate dev --name init-delivery-statusThis creates the SQLite database file (
prisma/dev.db) and generates the Prisma Client. -
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-spectypescript// 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
CoreModuleexportsPrismaServiceand is imported inAppModule.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 {} -
Inject PrismaService: The
CallbacksServiceshown in Step 4 already includes the injection and usage ofPrismaService.
7. Send a message and trigger callbacks
Implement the functionality to send an SMS and tell Plivo where to send the delivery report.
-
Generate Messaging Module/Service/Controller:
bashnest g module messaging nest g service messaging --no-spec nest g controller messaging --no-spec -
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 inclient.messages.createtells 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.
-
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.
-
Start your NestJS application:
bashnpm run start:dev -
Expose your local server with ngrok:
bashngrok http 3000ngrok will display a forwarding URL like
https://abc123.ngrok.io. -
Update your
.envfile:iniAPP_BASE_URL=https://abc123.ngrok.ioRestart your NestJS application to pick up the new URL.
-
Send a test SMS:
bashcurl -X POST http://localhost:3000/messaging/send-test \ -H "Content-Type: application/json" \ -d '{"to": "+15551234567", "text": "Test message for delivery status"}' -
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:4040to 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:
| Issue | Solution |
|---|---|
| "ngrok not found" | Install with brew install ngrok (macOS) or download from ngrok.com |
| Connection refused | Ensure your NestJS app is running before starting ngrok |
| URL changes on restart | Get a static domain with an ngrok paid plan |
| Tunnel expired | Free ngrok tunnels expire after 2 hours – restart ngrok |
9. Production deployment considerations
When deploying your NestJS Plivo webhook handler to production:
-
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).
-
Update environment variables: Replace your
APP_BASE_URLwith your production domain. -
Database migration: Switch from SQLite to PostgreSQL or MySQL for production. Update your
DATABASE_URLand Prisma schema accordingly:prismadatasource db { provider = "postgresql" url = env("DATABASE_URL") }Example production
DATABASE_URL:DATABASE_URL="postgresql://user:password@localhost:5432/plivo_db?schema=public" -
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
messageUuidas a unique identifier to handle duplicate callbacks safely. -
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
-
Rate limiting: Implement rate limiting on your webhook endpoint to protect against potential abuse:
bashnpm install @nestjs/throttlertypescript// In app.module.ts ThrottlerModule.forRoot({ ttl: 60, limit: 100, // 100 requests per minute }) -
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:
| Platform | Configuration |
|---|---|
| Heroku | Set APP_BASE_URL to https://your-app.herokuapp.com |
| AWS Elastic Beanstalk | Configure environment variables in EB console, use ALB for SSL |
| Google Cloud Run | Enable ingress for webhook traffic, set X-Forwarded-Proto trust |
| Docker | Expose 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 number10002: Sender ID not configured10003: Insufficient account balance10004: Spam detected or blocked content10006: 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:
- Add authentication: Secure your
/messaging/send-testendpoint with JWT or API key authentication - Implement retry logic: Create a queue for failed messages with automatic retry attempts
- Build analytics: Add endpoints to query delivery rates, failure patterns, and cost tracking
- Set up alerts: Configure monitoring to notify you of delivery failures or signature validation issues
- 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
TotalAmountfield - 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.