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:
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: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.
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 Numbers and 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 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).
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 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.
Create .env file: In your project root, create a file named .env:
ini
# .env# Plivo CredentialsPLIVO_AUTH_ID=YOUR_PLIVO_AUTH_ID
PLIVO_AUTH_TOKEN=YOUR_PLIVO_AUTH_TOKEN
PLIVO_SENDER_NUMBER=YOUR_PLIVO_PHONE_NUMBER_INCLUDING_COUNTRY_CODE
# Application SettingsAPP_PORT=3000# Base URL for callbacks (e.g., ngrok URL or production URL)# Example: https://yourapp.com OR https://your-ngrok-id.ngrok.ioAPP_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.
Create .env.example for team documentation:
ini
# .env.example (commit this to git)PLIVO_AUTH_ID=
PLIVO_AUTH_TOKEN=
PLIVO_SENDER_NUMBER=
APP_PORT=3000APP_BASE_URL=
DATABASE_URL="file:./dev.db"
Configure NestJS ConfigModule: Update your root AppModule to load and manage environment variables.
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.
Generate Module, Controller, Service:
Bash
nest 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.tsimport { IsString, IsOptional, IsEnum, IsNotEmpty } from'class-validator';
// Define possible Plivo message statuses// Ref: https://www.plivo.com/docs/sms/api/message#delivery-status-valuesexportenumPlivoMessageStatus {
QUEUED = 'queued',
SENT = 'sent',
FAILED = 'failed',
DELIVERED = 'delivered',
UNDELIVERED = 'undelivered',
REJECTED = 'rejected',
}
exportclassPlivoStatusCallbackDto {
@IsString()
@IsNotEmpty()
MessageUUID: string;
@IsEnum(PlivoMessageStatus)
@IsNotEmpty()
Status: PlivoMessageStatus;
@IsString()
@IsOptional() // Optional, might not be present for all statusesErrorCode?: 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.
Implement the Controller: Set up the route to listen for POST requests.
TypeScript
// src/callbacks/callbacks.controller.tsimport {
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: /callbacksexportclassCallbacksController {
privatereadonly logger = newLogger(CallbacksController.name);
constructor(privatereadonlycallbacksService: 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 successasynchandleDeliveryStatus(
@Body() payload: PlivoStatusCallbackDto, // Automatically validates based on DTO
): Promise<void> {
// Signature validation is handled by the PlivoSignatureGuardthis.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 {
awaitthis.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.tsimport { Injectable, Logger } from'@nestjs/common';
import { PlivoStatusCallbackDto, PlivoMessageStatus } from'./dto/plivo-status-callback.dto';
import { PrismaService } from'../core/prisma.service'; // Import PrismaService@Injectable()
exportclassCallbacksService {
privatereadonly logger = newLogger(CallbacksService.name);
constructor(privatereadonlyprisma: PrismaService) {} // Inject PrismaServiceasyncprocessStatusUpdate(
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 ? newDate(payload.MessageTime) : null,
recipient: payload.To,
sender: payload.From,
rawPayload: JSON.stringify(payload), // Store the full payload for debuggingprocessedAt: newDate(),
};
awaitthis.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 statusawaitthis.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 errorthrow error;
}
}
privateasynchandleStatusSpecificActions(payload: PlivoStatusCallbackDto): Promise<void> {
// Implement business logic based on delivery statusswitch (payload.Status) {
casePlivoMessageStatus.FAILED:
casePlivoMessageStatus.UNDELIVERED:
casePlivoMessageStatus.REJECTED:
this.logger.warn(`Message ${payload.MessageUUID} failed with code ${payload.ErrorCode}`);
// Example: Trigger alert, add to retry queue, or notify userbreak;
casePlivoMessageStatus.DELIVERED:
this.logger.log(`Message ${payload.MessageUUID} successfully delivered`);
// Example: Update user dashboard, mark notification as confirmedbreak;
}
}
}
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:
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.tsimport * 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.
*/exportfunctionvalidatePlivoSignature(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 V2const baseString = `${requestUrl}${postParams}`;
const expectedSignature = crypto
.createHmac('sha256', authToken)
.update(baseString)
.digest('base64');
// Securely compare the signatures using timingSafeEqualreturn crypto.timingSafeEqual(
Buffer.from(signature, 'base64'), // Ensure comparison buffers use same encodingBuffer.from(expectedSignature, 'base64'),
);
} catch (error) {
console.error('Error during Plivo signature validation:', error);
returnfalse;
}
}
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
Enable Raw Body Parsing: Modify main.ts to access the raw request body.
TypeScript
// src/main.tsimport { NestFactory } from'@nestjs/core';
import { AppModule } from'./app.module';
import { ValidationPipe, RawBodyRequest } from'@nestjs/common'; // Import RawBodyRequestimport { ConfigService } from'@nestjs/config';
import { NestExpressApplication } from'@nestjs/platform-express'; // Use NestExpressApplication for rawBodyimport * as bodyParser from'body-parser'; // Import body-parserasyncfunctionbootstrap() {
// Use NestExpressApplication for Express-specific features like rawBodyconst app = awaitNestFactory.create<NestExpressApplication>(AppModule, {
// Enable raw body parsing alongside JSON parsingrawBody: 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 neededconst 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(
newValidationPipe({
whitelist: true, // Strip properties not in DTOtransform: true, // Automatically transform payloads to DTO instancesforbidNonWhitelisted: 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.tsimport {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
Logger,
RawBodyRequest, // Import RawBodyRequestInternalServerErrorException, // Import for config errors
} from'@nestjs/common';
import { ConfigService } from'@nestjs/config';
import { Request } from'express'; // Use Express Request typeimport { validatePlivoSignature } from'../../utils/plivo-validation.util';
@Injectable()
exportclassPlivoSignatureGuardimplementsCanActivate {
privatereadonly logger = newLogger(PlivoSignatureGuard.name);
constructor(privatereadonlyconfigService: ConfigService) {}
asynccanActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<RawBodyRequest<Request>>();
const signature = request.headers['x-plivo-signature-v2'] asstring;
if (!signature) {
this.logger.warn('Missing Plivo signature header (X-Plivo-Signature-V2)');
thrownewUnauthorizedException('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 issuethrownewInternalServerErrorException('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 specificsthrownewInternalServerErrorException('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'] asstring || request.protocol;
const host = request.headers['x-forwarded-host'] asstring || 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}`);
thrownewUnauthorizedException('Invalid Plivo signature');
}
this.logger.log(`Valid Plivo V2 signature verified for URL: ${fullUrl}`);
returntrue; // 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:
// In main.ts, after creating the app
app.set('trust proxy', 1); // Trust first proxy
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.
Initialize Prisma:
Bash
npx prisma init --datasource-provider sqlite
This creates prisma/schema.prisma and updates .env with DATABASE_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.
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.
Generate Messaging Module/Service/Controller:
Bash
nest g module messaging
nest g service messaging --no-spec
nest g controller messaging --no-spec
Implement Messaging Service:
TypeScript
// src/messaging/messaging.service.tsimport { Injectable, Logger, InternalServerErrorException } from'@nestjs/common';
import { ConfigService } from'@nestjs/config';
import * as plivo from'plivo'; // Use import * as plivo@Injectable()
exportclassMessagingService {
privatereadonly logger = newLogger(MessagingService.name);
privateplivoClient: plivo.Client;
privatesenderNumber: string;
privatecallbackBaseUrl: string;
constructor(privatereadonlyconfigService: 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}`);
thrownewInternalServerErrorException('Server configuration error for Plivo Messaging.');
}
this.plivoClient = new plivo.Client(authId, authToken);
this.logger.log('Plivo client initialized.');
}
asyncsendSms(to: string, text: string): Promise<{ messageUuid: string }> {
// Construct the full callback URLconst callbackUrl = `${this.callbackBaseUrl}/callbacks/delivery-status`;
this.logger.log(`Sending SMS to ${to} from ${this.senderNumber} with callback URL: ${callbackUrl}`);
try {
const response = awaitthis.plivoClient.messages.create(
this.senderNumber, // src
to, // dst
text, // text
{
url: callbackUrl, // Specify the delivery report URL heremethod: 'POST', // Ensure method matches your endpoint
},
);
this.logger.log(`SMS submitted to Plivo. API Response:`, response);
// Verify response structure and extract UUID safelyif (!response || !Array.isArray(response.messageUuid) || response.messageUuid.length === 0) {
this.logger.error('Plivo API response missing expected messageUuid array.', response);
thrownewError('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 specificallyif (error.status === 429) {
thrownewInternalServerErrorException('Plivo rate limit exceeded. Try again later.');
}
// Rethrow a more generic error or a specific application errorthrownewInternalServerErrorException(`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.
Implement Messaging Controller (for testing): Add a simple endpoint to trigger sending an SMS.
TypeScript
// src/messaging/messaging.controller.tsimport { 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 endpointclassSendSmsDto {
@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')
exportclassMessagingController {
privatereadonly logger = newLogger(MessagingController.name);
constructor(privatereadonlymessagingService: MessagingService) {}
@Post('send-test')
@HttpCode(HttpStatus.ACCEPTED) // Accepted (202) is suitable as sending is async// TODO: Add authentication guard before production use// @UseGuards(AuthGuard)asyncsendTestSms(@Body() body: SendSmsDto) {
this.logger.log(`Received request to send test SMS to ${body.to}`);
try {
const result = awaitthis.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 InternalServerErrorExceptionthis.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:
Bash
npm run start:dev
Expose your local server with ngrok:
Bash
ngrok http 3000
ngrok will display a forwarding URL like https://abc123.ngrok.io.
Update your .env file:
ini
APP_BASE_URL=https://abc123.ngrok.io
Restart your NestJS application to pick up the new URL.
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"}'
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:
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_URL with your production domain.
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")
}
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.
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:
Bash
npm install @nestjs/throttler
TypeScript
// In app.module.tsThrottlerModule.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 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:
Add authentication: Secure your /messaging/send-test endpoint 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 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.
Frequently asked questions
Implement a webhook endpoint in your Node.js application using a framework like NestJS. This endpoint receives real-time delivery updates from Plivo, allowing you to track message statuses like 'delivered', 'failed', or 'rejected'. This guide demonstrates building a robust system for managing these callbacks.
A Plivo delivery status callback (or webhook) is a notification sent by Plivo to your application when the status of an SMS message changes. This notification contains crucial information about the message delivery outcome, such as 'sent', 'delivered', 'failed', or other statuses. Callbacks enable real-time monitoring and response to delivery events.
NestJS provides a structured and efficient way to handle Plivo SMS callbacks. Its features, like validation pipes and dependency injection, simplify development and improve code maintainability. This makes building a robust and scalable callback handling system easier.
Always validate Plivo webhook signatures. This crucial security step verifies the authenticity of incoming requests, ensuring that the callback data truly originated from Plivo and protecting your application from malicious actors.
Yes, use a tool like ngrok to expose your local development server to the internet. This allows Plivo to send webhooks to your local endpoint during development and testing. Configure your Plivo application with the ngrok URL as the callback URL.
In the Plivo console, create a new application, providing a name and configuring the 'Delivery Report URL' to point to your application's callback endpoint. Ensure the method is set to 'POST'. Optionally, link a Plivo number to the application, though specifying the callback URL per message offers greater flexibility.
Data Transfer Objects (DTOs) in NestJS define the structure of incoming callback data. They enable automatic validation using decorators from 'class-validator', ensuring data integrity and type safety in your TypeScript code.
Plivo uses signature validation to ensure the security and integrity of webhook deliveries. It verifies that requests received by your application genuinely originate from Plivo, preventing unauthorized or malicious actors from sending fake callbacks.
Store Plivo credentials (Auth ID, Auth Token) as environment variables in a .env file. Use the @nestjs/config package in your NestJS application to load these variables securely, preventing them from being hardcoded in your codebase.
SQLite is suitable for development and testing. For production, consider more robust options like PostgreSQL or MySQL. Prisma, a database toolkit for Node.js, simplifies database interactions and schema management regardless of the chosen database.
NestJS, coupled with a DTO and the 'class-transformer', automatically parses incoming JSON payloads from Plivo into strongly-typed objects. This simplifies data access and ensures type safety within your application logic.
Return a 204 No Content status code. Plivo primarily needs an acknowledgment that you received the callback. A 2xx status signals successful receipt. Returning content isn't required.
Prisma simplifies database interactions in NestJS. Use it to define your data models, manage database migrations, and perform type-safe database queries, regardless of whether you use SQLite, PostgreSQL, MySQL, or other databases.
Use the client.messages.create() method of the Plivo Node.js SDK. Provide your sender number, recipient number, message text, and an object with the url property set to your callback URL and method set to 'POST'.
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:
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:
Technologies you'll use:
npm install plivo(use the actively maintained "plivo" package, not the deprecated "plivo-node").@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:
Prerequisites:
npm install -g @nestjs/clingrok(optional but recommended for local testing): Download and installngrokto expose your local development server.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:
npm ERR! peer dependencywarningsnpm install --legacy-peer-depsEACCESpermission errorsnvmto install Node.js without sudo, or prefix commands withnpxnpm install typescript@~5.3.0 --save-devProject structure:
NestJS provides a standard structure. You'll add modules for specific features like
messagingandcallbacks.2. Configure your Plivo account and application
Configure your Plivo account to send callbacks to your application.
Phone Numbers→Buy Numbersand purchase a number capable of sending SMS messages in your desired region. Note this number.Messaging→XML Applications.Add New Application.NestJS Callback App).http://example.com/callbacks/delivery-status. You'll update this later with your actual endpoint URL (likely anngrokURL during development).MethodtoPOST.Create Application. Note theApp IDgenerated (though you won't use it directly when specifying callback URLs per message).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:
3. Configure your environment
Never hardcode sensitive credentials – use environment variables.
Create
.envfile: In your project root, create a file named.env:# .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".envis listed in your.gitignorefile to prevent committing secrets.Create
.env.examplefor team documentation:# .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.// 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
POSTrequests from Plivo containing delivery status information.Generate Module, Controller, Service:
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.// 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.
// 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.
// 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:
X-Plivo-Signature-V2headerX-Plivo-Signature-V3andX-Plivo-Signature-V3-NonceheadersThis 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:
@nestjs/throttlerto limit requests:@Throttle(100, 60)(100 requests per minute)Create Signature Validation Helper: Based on Plivo's documentation, create a utility function.
// 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:
X-Forwarded-*handlingx-plivo-signature-v2vsX-Plivo-Signature-V2Enable Raw Body Parsing: Modify
main.tsto access the raw request body.// 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.
// 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:Update your NestJS app to trust proxy headers:
// In main.ts, after creating the app app.set('trust proxy', 1); // Trust first proxyApply 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:
This creates
prisma/schema.prismaand updates.envwithDATABASE_URL="file:./dev.db".Define Schema: Edit
prisma/schema.prisma.Why store
rawPayload? It's invaluable for debugging issues with callbacks or understanding unexpected data formats.Useful queries for reporting:
// 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.
This creates the SQLite database file (
prisma/dev.db) and generates the Prisma Client.Create Prisma Service: Create a reusable service for Prisma Client.
# Create a core module if you don't have one nest g module core nest g service core/prisma --no-spec// 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.// 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:
Implement Messaging Service:
// 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.
// 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:
Expose your local server with ngrok:
ngrok will display a forwarding URL like
https://abc123.ngrok.io.Update your
.envfile:APP_BASE_URL=https://abc123.ngrok.ioRestart your NestJS application to pick up the new URL.
Send a test SMS:
curl -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:
ngrok debugging tools:
http://localhost:4040to see all HTTP requests and responsesCommon ngrok issues:
brew install ngrok(macOS) or download from ngrok.com9. 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:Example production
DATABASE_URL: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:
Rate limiting: Implement rate limiting on your webhook endpoint to protect against potential abuse:
// 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:
APP_BASE_URLtohttps://your-app.herokuapp.comX-Forwarded-Prototrusttrust proxyin main.ts for load balancer supportFrequently 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 yourAPP_BASE_URLenvironment 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), andrejected(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
DeliveryStatusmodel with fields formessageUuid(unique identifier),status,errorCode, recipient, sender, timestamps, and the raw payload JSON. Use Prisma'supsertoperation 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
urlparameter inclient.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
messageUuidas a unique constraint in your database. Use Prisma'supsertoperation 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:
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_URLmatches 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:
/messaging/send-testendpoint with JWT or API key authenticationAdvanced patterns to consider:
TotalAmountfieldAs 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.