code examples
code examples
Plivo Inbound SMS & Two-Way Messaging with NestJS: Complete Implementation Guide
Learn how to build production-ready NestJS applications with Plivo webhooks for inbound SMS. Complete tutorial covering webhook setup, two-way messaging, signature validation, and secure deployment.
Plivo Inbound SMS and Two-Way Messaging with NestJS: Complete Guide
Learn how to build a production-ready NestJS application that receives incoming SMS messages via Plivo webhooks and responds automatically with two-way messaging. This comprehensive guide covers project setup, webhook implementation, security best practices, error handling, and deployment strategies.
You'll learn how to configure Plivo to forward incoming messages to your NestJS application, process the message content, and send replies using Plivo's XML format. This forms the foundation for building interactive SMS applications, automated customer service bots, appointment reminder systems, and real-time notification platforms.
Project Overview and Goals
-
What We'll Build: A NestJS application with a dedicated webhook endpoint that listens for incoming SMS messages sent to a Plivo phone number. Upon receiving a message, the application will log the message details and automatically send a reply back to the sender.
-
Problem Solved: Enables applications to react to and engage with users via standard SMS, facilitating interactive communication without requiring users to install a separate app.
-
Technologies Used:
- NestJS: A progressive Node.js framework for building efficient, reliable, and scalable server-side applications. Its modular architecture and built-in features (like configuration management, logging, and validation pipes) make it ideal for production APIs.
- Plivo: A cloud communications platform providing SMS and Voice APIs. We'll use their SMS API and Node.js SDK.
- Node.js: The underlying JavaScript runtime environment.
- TypeScript: Superset of JavaScript used by NestJS, providing static typing.
- ngrok (for local development): A tool to expose local development servers to the public internet, necessary for Plivo webhooks to reach your machine during testing.
-
Architecture:
User's Phone <-- SMS --> Plivo Platform <-- HTTPS POST --> Your Server (via ngrok or Public IP) ^ | | | Process Request | Plivo XML Response <----------------| NestJS Application | | ------------------------- SMS Reply <------------------------- -
Prerequisites:
- Node.js (LTS version recommended) and npm/yarn installed.
- A Plivo account (Free trial available).
- An SMS-enabled Plivo phone number.
- Plivo Auth ID and Auth Token (found on your Plivo Console dashboard).
ngrokinstalled globally or available in your PATH for local testing.- Basic understanding of TypeScript, Node.js, and REST APIs.
-
Final Outcome: A functional NestJS application deployed (locally via ngrok or on a server) that receives SMS messages sent to your Plivo number and replies automatically.
1. Setting Up Your NestJS Project with Plivo
Let's initialize our NestJS project and install the necessary dependencies for Plivo webhook integration.
-
Install NestJS CLI: If you don't have it, install it globally:
bashnpm install -g @nestjs/cli -
Create NestJS Project:
bashnest new plivo-nestjs-inbound cd plivo-nestjs-inboundChoose your preferred package manager (npm or yarn) when prompted.
-
Install Dependencies: We need the Plivo Node.js SDK and NestJS config module for environment variables.
bashnpm install plivo @nestjs/config # OR yarn add plivo @nestjs/config -
Environment Variables: Create a
.envfile in the project root for storing sensitive credentials and configuration. Never commit this file to version control – add.envto your.gitignorefile.dotenv# .env PORT=3000 # Plivo Credentials - Get from https://console.plivo.com/dashboard/ PLIVO_AUTH_ID=YOUR_PLIVO_AUTH_ID PLIVO_AUTH_TOKEN=YOUR_PLIVO_AUTH_TOKEN PLIVO_PHONE_NUMBER=YOUR_PLIVO_PHONE_NUMBER # The number receiving messages (e.g., +14155551212)PORT: The port your NestJS application will listen on.PLIVO_AUTH_ID,PLIVO_AUTH_TOKEN: Your Plivo API credentials. Crucially important for validating incoming webhook requests.PLIVO_PHONE_NUMBER: Your Plivo number associated with the webhook. Useful for configuration and potentially as thesrcif sending replies via the API instead of XML response.
-
Configure Environment Variables: Import and configure
ConfigModulein your main application module (src/app.module.ts) to load the.envfile.typescript// src/app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { PlivoWebhookModule } from './plivo-webhook/plivo-webhook.module'; // We will create this next @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, // Make ConfigService available globally envFilePath: '.env', }), PlivoWebhookModule, // Import our Plivo module ], controllers: [AppController], providers: [AppService], }) export class AppModule {} -
Update Main Entry Point: Modify
src/main.tsto use the configured port and enable raw body parsing for signature validation.typescript// src/main.ts import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { ConfigService } from '@nestjs/config'; import { Logger } from '@nestjs/common'; // Import Logger async function bootstrap() { // Enable rawBody for webhook signature validation const app = await NestFactory.create(AppModule, { rawBody: true }); const configService = app.get(ConfigService); const port = configService.get<number>('PORT', 3000); // Default to 3000 if not set // Enable shutdown hooks for graceful shutdown app.enableShutdownHooks(); // NestJS with rawBody: true handles common body types like JSON and urlencoded // while preserving the raw buffer. No need for app.useBodyParser here usually. await app.listen(port); Logger.log(`Application listening on port ${port}`, 'Bootstrap'); // Use NestJS Logger } bootstrap();Note: Passing
{ rawBody: true }toNestFactory.createis essential for the Plivo signature validation guard to work correctly. -
Create Plivo Webhook Module: Generate a new module, controller, and service to handle Plivo logic.
bashnest generate module plivo-webhook nest generate controller plivo-webhook --no-spec nest generate service plivo-webhook --no-specThis creates a
src/plivo-webhookdirectory with the necessary files and updatessrc/app.module.tsautomatically (as seen in step 5).
2. Implementing the Plivo Webhook Handler
Now, let's build the controller and service to handle incoming Plivo webhooks for inbound SMS messages.
-
Plivo Webhook Service (
src/plivo-webhook/plivo-webhook.service.ts): This service will contain the logic to process the incoming message data and generate the Plivo XML response.typescript// src/plivo-webhook/plivo-webhook.service.ts import { Injectable, Logger } from '@nestjs/common'; import * as plivo from 'plivo'; export interface PlivoSmsPayload { From: string; To: string; Text: string; Type: string; // 'sms', 'mms', or 'whatsapp' MessageUUID: string; Units: number; // Number of message parts (>1 for long messages) TotalRate?: string; // Charge per message unit TotalAmount?: string; // Total message charge MessageIntent?: 'optout' | 'optin' | 'help'; // Platform-detected compliance keywords // MMS-specific fields (when Type='mms') Body?: string; // Text content for MMS MediaCount?: number; // Number of media files Media0?: string; // URL for first media file Media1?: string; // URL for second media file (if exists) // Additional Media URLs follow pattern Media2, Media3, etc. } @Injectable() export class PlivoWebhookService { private readonly logger = new Logger(PlivoWebhookService.name); handleIncomingSms(payload: PlivoSmsPayload): string { this.logger.log( `Received SMS from ${payload.From} to ${payload.To}: ""${payload.Text}"" (UUID: ${payload.MessageUUID})`, ); // Basic validation or keyword checking can go here // Example: Check if Text is 'HELP' or 'STOP' // --- Generate Plivo XML Response --- const response = new plivo.Response(); const replyText = `Thanks for your message! You said: ""${payload.Text}""`; // Parameters for the <Message> XML element const params = { src: payload.To, // The Plivo number that received the message dst: payload.From, // The user's number to reply to }; response.addMessage(replyText, params); const xmlResponse = response.toXML(); this.logger.log(`Generated XML Response: ${xmlResponse}`); return xmlResponse; } // Add methods here for sending outbound messages via API if needed later // e.g., using Plivo client: client.messages.create(...) }- We define an interface
PlivoSmsPayloadfor type safety based on Plivo's webhook parameters. - The
handleIncomingSmsmethod takes the parsed payload, logs it, and uses theplivoSDK'sResponseclass to build the XML. response.addMessage(text, params)creates the<Message>element in the XML. Note howsrc(source of reply) anddst(destination of reply) are set.- It returns the generated XML string.
- We define an interface
-
Plivo Webhook Controller (
src/plivo-webhook/plivo-webhook.controller.ts): This controller defines the HTTP endpoint that Plivo will call.typescript// src/plivo-webhook/plivo-webhook.controller.ts import { Controller, Post, Body, Header, HttpCode, HttpStatus, Logger, UseGuards, // Import UseGuards Req, // Import Req RawBodyRequest, // Import RawBodyRequest } from '@nestjs/common'; import { PlivoWebhookService, PlivoSmsPayload } from './plivo-webhook.service'; import { PlivoSignatureGuard } from './plivo-signature.guard'; // We will create this next import { Request } from 'express'; // Import Request type @Controller('plivo-webhook') // Route prefix: /plivo-webhook export class PlivoWebhookController { private readonly logger = new Logger(PlivoWebhookController.name); constructor(private readonly plivoWebhookService: PlivoWebhookService) {} @Post('message') // Full route: POST /plivo-webhook/message @UseGuards(PlivoSignatureGuard) // Apply the signature validation guard @HttpCode(HttpStatus.OK) // Plivo expects a 200 OK for successful handling @Header('Content-Type', 'application/xml') // Set the response content type handleMessageWebhook( @Body() payload: PlivoSmsPayload, // @Req() is still needed for the guard to access the request object // even if we primarily use @Body for the payload here. @Req() request: RawBodyRequest<Request>, ): string { this.logger.log('Incoming Plivo Message Webhook Request Received'); // The PlivoSignatureGuard runs first. If it passes, this handler executes. // NestJS automatically parses the urlencoded body into 'payload' // The guard uses the rawBody attached by NestFactory ({ rawBody: true }) return this.plivoWebhookService.handleIncomingSms(payload); } }@Controller('plivo-webhook'): Defines the base path for routes in this controller.@Post('message'): Defines a handler for POST requests to/plivo-webhook/message. This will be ourmessage_urlin Plivo.@UseGuards(PlivoSignatureGuard): Crucial security step. This applies a guard (created in Section 7) to verify the incoming request genuinely came from Plivo using its signature.@Body() payload: PlivoSmsPayload: Uses NestJS's built-in parsing to extract the request body (expected to beapplication/x-www-form-urlencodedfrom Plivo) and map it to ourPlivoSmsPayloadinterface.@Req() request: RawBodyRequest<Request>: Provides access to the full request object, including the raw body buffer needed by the signature guard.RawBodyRequestis used for type safety when{ rawBody: true }is enabled.@Header('Content-Type', 'application/xml'): Sets theContent-Typeheader on the response to tell Plivo we are sending back XML.@HttpCode(HttpStatus.OK): Ensures a200 OKstatus code is sent on success. Plivo expects this.- It calls the
plivoWebhookService.handleIncomingSmsmethod with the payload and returns the resulting XML string directly as the response body.
3. Building an API Layer
In this specific scenario, our "API" is the single webhook endpoint /plivo-webhook/message designed to be consumed by Plivo. Authentication and authorization are handled via Plivo's request signature validation (covered in Section 7). Request validation is implicitly handled by NestJS's body parsing and TypeScript types; more complex validation using class-validator could be added if needed (e.g., ensuring From and To are valid phone numbers).
Testing this endpoint locally requires ngrok and sending an SMS, or using curl/Postman to simulate Plivo's request. Simulating requires generating valid X-Plivo-Signature-V3 and X-Plivo-Signature-V3-Nonce headers, which is complex.
Example Simulated curl Request (Will Fail Signature Validation):
This command demonstrates the structure of Plivo's request but will be blocked by the PlivoSignatureGuard because it lacks a valid signature. Use it only for basic route testing if the guard is temporarily disabled (not recommended).
curl -X POST http://localhost:3000/plivo-webhook/message \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "From=15551234567" \
--data-urlencode "To=14155551212" \
--data-urlencode "Text=Hello from curl" \
--data-urlencode "Type=sms" \
--data-urlencode "MessageUUID=abc-123-def-456"- Important Note: Never disable the signature guard in staging or production environments. Doing so creates a severe security vulnerability, allowing anyone to send unverified requests to your webhook endpoint, potentially leading to abuse, data corruption, or unwanted costs. The best way to test the full flow locally is using
ngrokand sending a real SMS.
4. Configuring Plivo to Send Webhooks to Your Application
Now, let's configure Plivo to send incoming messages to your local NestJS application using ngrok.
-
Start Your NestJS App:
bashnpm run start:dev # OR yarn start:devEnsure it's running and listening on the correct port (e.g., 3000) and that the
rawBody: trueoption is enabled inmain.ts. -
Start ngrok: Open a new terminal window and expose your local port to the internet.
bashngrok http 3000 # Make sure '3000' matches the PORT in your .env file / main.tsngrokwill display forwarding URLs (e.g.,https://<unique-id>.ngrok-free.app). Copy thehttpsURL. -
Create/Configure Plivo Application:
- Log in to your Plivo Console.
- Navigate to Messaging -> Applications -> XML.
- Click Add New Application.
- Application Name: Give it a descriptive name (e.g.,
NestJS Inbound App). - Message URL: Paste your
ngrokHTTPS URL, appending the controller route:https://<unique-id>.ngrok-free.app/plivo-webhook/message. - Method: Select POST.
- Hangup URL / Answer URL: Leave blank (these are for voice calls).
- Click Create Application.
-
Link Plivo Number to Application:
- Navigate to Phone Numbers -> Your Numbers.
- Find the SMS-enabled number you want to use. Click on it.
- Under Application Type, select XML Application.
- From the Plivo Application dropdown, select the application you just created (
NestJS Inbound App). - Click Update Number.
Environment Variables Summary:
PORT: (e.g.,3000) - Port your NestJS app runs on locally.PLIVO_AUTH_ID: (e.g.,MANXXXXXXXXXXXXXXXXX) - Found on Plivo Console Dashboard. Used for signature validation.PLIVO_AUTH_TOKEN: (e.g.,abc...xyz) - Found on Plivo Console Dashboard. Used for signature validation.PLIVO_PHONE_NUMBER: (e.g.,+14155551212) - Your Plivo number receiving the SMS. Used potentially in logic, good to have in config.
5. Implementing Error Handling and Logging
-
Logging: We are using NestJS's built-in
Logger. It provides structured logging with timestamps and context (class names). In production, consider configuring more advanced logging (e.g., sending logs to a centralized service like Datadog or ELK stack) using custom logger implementations or libraries likewinston. -
Basic Error Handling: The
plivo.Response()generation is unlikely to throw errors. More complex logic in the service (e.g., database interactions, external API calls) should be wrapped intry...catchblocks. -
NestJS Exception Filters: For a global error handling strategy, implement NestJS Exception Filters. This allows you to catch unhandled exceptions, log them consistently, and return appropriate responses. For Plivo webhooks, returning a
200 OKwith an empty<Response></Response>is often preferred even on error to prevent Plivo from retrying the webhook, while still logging the error internally.typescript// Example: src/common/all-exceptions.filter.ts (Basic structure) import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger, } from '@nestjs/common'; import { Response, Request } from 'express'; // Import Response/Request @Catch() // Catch all exceptions if no specific type is provided export class AllExceptionsFilter implements ExceptionFilter { private readonly logger = new Logger(AllExceptionsFilter.name); catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse<Response>(); const request = ctx.getRequest<Request>(); const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; const message = exception instanceof HttpException ? exception.getResponse() : 'Internal server error'; // Log the error with context this.logger.error( `HTTP Status: ${status} Error Message: ${JSON.stringify(message)} Request: ${request.method} ${request.url}`, exception instanceof Error ? exception.stack : '', AllExceptionsFilter.name, // Context ); // For Plivo webhooks, acknowledge receipt to prevent retries, // even if an internal error occurred. Log the error for investigation. response.setHeader('Content-Type', 'application/xml'); response.status(HttpStatus.OK).send('<Response></Response>'); // Empty XML response } }-
Apply Globally: Apply this filter in
src/main.ts:typescript// src/main.ts (inside bootstrap function) import { AllExceptionsFilter } from './common/all-exceptions.filter'; // Adjust path if needed // ... app.useGlobalFilters(new AllExceptionsFilter()); // ...
-
-
Plivo Retries: If your webhook endpoint fails to respond with a
200 OKwithin Plivo's timeout period (typically 15 seconds), or returns an error status code (like 5xx), Plivo may retry the request. Returning an empty<Response></Response>with a200 OKvia the exception filter prevents unwanted retries while allowing you to log the internal failure. Check Plivo's logs (Messaging -> Logs) for webhook delivery status and failures.
6. Creating a Database Schema and Data Layer
While this core example doesn't require a database, a real-world application would likely store message history, user state, or related data.
Next Steps (Optional):
-
Choose ORM/Database: Select a database (e.g., PostgreSQL, MySQL, MongoDB) and an ORM/ODM like TypeORM or Prisma.
-
Install Dependencies: Example for TypeORM + PostgreSQL:
npm install @nestjs/typeorm typeorm pg. -
Configure Database Connection: Set up database credentials in
.envand configure theTypeOrmModuleinapp.module.ts. -
Define Entities/Models: Create TypeORM entities or Prisma models (e.g.,
MessageLog) to represent your database tables/collections.typescript// Example: src/message-log/message-log.entity.ts (TypeORM) import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm'; @Entity('message_logs') // Specify table name export class MessageLog { @PrimaryGeneratedColumn('uuid') id: string; @Index() // Index for faster lookups @Column({ name: 'message_uuid', unique: true }) // Plivo's MessageUUID messageUuid: string; @Column() direction: 'inbound' | 'outbound'; @Index() @Column({ name: 'from_number' }) fromNumber: string; @Index() @Column({ name: 'to_number' }) toNumber: string; @Column('text') text: string; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; // Add columns for status, error messages, media URLs etc. if needed } -
Create Repository/Service: Inject the TypeORM repository or Prisma client into your
PlivoWebhookService(or a dedicated logging service) to save incoming messages and potentially retrieve conversation history. -
Migrations: Use TypeORM migrations (
typeorm migration:generate,typeorm migration:run) or Prisma Migrate (prisma migrate dev,prisma migrate deploy) to manage schema changes safely.
7. Adding Security Features
-
Webhook Signature Validation (MANDATORY): This is the most critical security measure. Plivo signs its webhook requests using your Auth Token, allowing you to verify the request's authenticity and integrity.
-
Create a Guard (
src/plivo-webhook/plivo-signature.guard.ts):typescript// src/plivo-webhook/plivo-signature.guard.ts import { Injectable, CanActivate, ExecutionContext, Logger, ForbiddenException, RawBodyRequest, // Use RawBodyRequest type } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as plivo from 'plivo'; import { Observable } from 'rxjs'; import { Request } from 'express'; // Import Request type @Injectable() export class PlivoSignatureGuard implements CanActivate { private readonly logger = new Logger(PlivoSignatureGuard.name); constructor(private configService: ConfigService) {} canActivate( context: ExecutionContext, ): boolean | Promise<boolean> | Observable<boolean> { const request = context.switchToHttp().getRequest<RawBodyRequest<Request>>(); // Get typed request with rawBody // Construct the full URL Plivo used to call the webhook // Note: Trusting proxy headers might be needed if behind a reverse proxy (e.g., X-Forwarded-Proto) // Consult NestJS documentation on `set('trust proxy', true)` if needed. const protocol = request.protocol; // http or https const host = request.get('host'); // e.g., your-ngrok-id.ngrok-free.app or your domain const originalUrl = request.originalUrl; // e.g., /plivo-webhook/message const url = `${protocol}://${host}${originalUrl}`; const signature = request.headers['x-plivo-signature-v3'] as string; // Use V3 signature const nonce = request.headers['x-plivo-signature-v3-nonce'] as string; // --- IMPORTANT: SMS vs Voice Signature Versions --- // According to Plivo documentation (as of 2025): // - Voice webhooks use: X-Plivo-Signature-V3, X-Plivo-Signature-V3-Nonce // - SMS/Messaging webhooks use: X-Plivo-Signature-V2, X-Plivo-Signature-V2-Nonce // This code uses V3. For SMS-only applications, you may need to: // 1. Use 'x-plivo-signature-v2' and 'x-plivo-signature-v2-nonce' headers instead // 2. Call the appropriate SDK validation method (check Plivo Node.js SDK docs) // Verify which headers your Plivo webhooks actually send by logging request.headers // --- CRITICAL: Raw Body Requirement --- // Access the raw body buffer attached by NestJS ({ rawBody: true }) const rawBody = request.rawBody; if (!signature || !nonce) { this.logger.warn(`Missing Plivo signature headers. URL: ${url}`); throw new ForbiddenException('Missing Plivo signature headers'); } // Ensure rawBody is available (should be if NestFactory was configured correctly) if (!rawBody) { this.logger.error( 'Raw request body is not available. Ensure NestJS is configured with { rawBody: true } in NestFactory.create().', ); // Throw ForbiddenException because validation cannot proceed. throw new ForbiddenException( 'Server configuration error: Raw body unavailable for signature validation.', ); } const authToken = this.configService.get<string>('PLIVO_AUTH_TOKEN'); if (!authToken) { this.logger.error('PLIVO_AUTH_TOKEN is not configured!'); // Avoid leaking info about missing token in the response throw new ForbiddenException('Server configuration error'); } try { // Use the rawBody buffer directly for validation. const isValid = plivo.validateV3Signature(url, nonce, signature, authToken, rawBody); if (!isValid) { this.logger.warn(`Invalid Plivo signature. URL: ${url}, Nonce: ${nonce}, Sig: ${signature}`); throw new ForbiddenException('Invalid Plivo signature'); } this.logger.log(`Plivo signature validated successfully for URL: ${url}`); return true; // Allow request to proceed } catch (error) { this.logger.error('Error validating Plivo signature:', error); throw new ForbiddenException('Signature validation failed'); } } }- Critical Note on Raw Body: This guard relies entirely on
request.rawBody. Ensure you have configured NestJS to provide it by passing{ rawBody: true }toNestFactory.createinsrc/main.ts. Without the exact raw body bytes, signature validation will fail. - URL Construction: The guard constructs the full URL (
protocol://host/originalUrl) that Plivo used to sign the request. Ensure this matches exactly, especially if running behind a reverse proxy (considertrust proxysettings). - Error Handling: Throws
ForbiddenExceptionon missing headers, missing token, missing raw body, or invalid signature, preventing unauthorized access.
- Critical Note on Raw Body: This guard relies entirely on
-
Apply the Guard: We already applied it in the controller using
@UseGuards(PlivoSignatureGuard). -
Import/Provide: Ensure
ConfigServiceis available (it is, viaConfigModule.forRoot({ isGlobal: true })) and that the guard is provided in thePlivoWebhookModule.typescript// src/plivo-webhook/plivo-webhook.module.ts import { Module } from '@nestjs/common'; import { PlivoWebhookController } from './plivo-webhook.controller'; import { PlivoWebhookService } from './plivo-webhook.service'; import { PlivoSignatureGuard } from './plivo-signature.guard'; // Import the guard @Module({ // imports: [ConfigModule], // Not needed if ConfigModule is global controllers: [PlivoWebhookController], providers: [ PlivoWebhookService, PlivoSignatureGuard, // Provide the guard so NestJS can inject ConfigService ], }) export class PlivoWebhookModule {}
-
-
Input Validation/Sanitization: While signature validation confirms the source, validate the content (
payload.Text,payload.From, etc.) if you use it for logic beyond simple replies. Use NestJSValidationPipewithclass-validatorDTOs or sanitization libraries (likeclass-sanitizeror manual checks) to prevent unexpected behavior or injection if storing/processing data. -
Rate Limiting: Protect your endpoint from potential (though less likely if signature validation is robust) denial-of-service or accidental loops. Use
@nestjs/throttlerto limit requests per source IP or other criteria. -
HTTPS: Always use HTTPS for your webhook endpoint in production.
ngrokprovides this for local testing. Ensure your production deployment server is configured with TLS/SSL certificates.
8. Handling Special Cases
-
Compliance Keywords (STOP/HELP): US/Canada regulations often require handling
STOP(opt-out) andHELP(provide info) keywords. Plivo has features to manage opt-outs automatically at the platform level (check your account settings and Messaging Services features). If handling manually in your application:typescript// Inside PlivoWebhookService.handleIncomingSms // --- Modern Approach: Use Plivo's MessageIntent field (recommended) --- // Plivo automatically detects compliance keywords and sets MessageIntent field if (payload.MessageIntent === 'optout') { this.logger.log(`Received opt-out via MessageIntent from ${payload.From}.`); const response = new plivo.Response(); // Return empty response if Plivo handles confirmation automatically return response.toXML(); } if (payload.MessageIntent === 'help') { this.logger.log(`Received help request via MessageIntent from ${payload.From}.`); const response = new plivo.Response(); const helpText = "Help: Reply STOP to unsubscribe. Msg&Data rates may apply. More info: your-support-url.com"; response.addMessage(helpText, { src: payload.To, dst: payload.From }); return response.toXML(); } // --- Alternative: Manual keyword parsing (if MessageIntent not available) --- const textUpper = payload.Text.trim().toUpperCase(); const response = new plivo.Response(); const params = { // Define params for potential replies src: payload.To, dst: payload.From, }; if (textUpper === 'STOP' || textUpper === 'UNSUBSCRIBE') { // Log opt-out, potentially update user record in DB to block future messages this.logger.log(`Received STOP keyword from ${payload.From}. Processing opt-out.`); // Respond with confirmation (optional, Plivo might handle this via Compliance settings) // const stopResponseText = "You have been unsubscribed. No more messages will be sent."; // response.addMessage(stopResponseText, params); // Return empty response if Plivo handles confirmation, otherwise add message above. return response.toXML(); } if (textUpper === 'HELP' || textUpper === 'INFO') { // Respond with help information this.logger.log(`Received HELP keyword from ${payload.From}. Sending info.`); const helpText = "Help: Reply STOP to unsubscribe. Msg&Data rates may apply. More info: your-support-url.com"; response.addMessage(helpText, params); return response.toXML(); } // --- If not STOP/HELP, proceed with normal message handling --- const replyText = `Thanks for your message! You said: ""${payload.Text}""`; response.addMessage(replyText, params); const xmlResponse = response.toXML(); this.logger.log(`Generated XML Response: ${xmlResponse}`); return xmlResponse; -
MMS (Multimedia Messaging): If your Plivo number is MMS-enabled and you expect media:
- Check the
payloadforType=mms. - Look for
Media_Count(number of media files) andMedia_URL0,Media_URL1, etc. - Download media from the URLs if needed. Note that Plivo media URLs are temporary and may require authentication.
- Check the
-
Long Messages (Concatenation): Plivo automatically handles concatenation for inbound messages exceeding single SMS limits. If your reply generated via XML might exceed character limits (160 GSM-7 chars / 70 UCS-2 chars), Plivo automatically splits it into multiple segments when sending. Be mindful of billing implications (each segment is billed as one SMS).
-
Encoding: Plivo typically uses GSM-7 encoding for standard characters and UCS-2 (Unicode) if special characters are present. Ensure your service handles text appropriately (Node.js/TypeScript generally handle Unicode well via UTF-8 internally).
9. Implementing Performance Optimizations
For a simple webhook like this, performance is rarely an issue initially. However, if handleIncomingSms involves slow operations (complex database queries, external API calls):
- Asynchronous Processing: Acknowledge the Plivo webhook immediately with a
200 OK(e.g., returnresponse.toXML()with no<Message>element) and then process the message asynchronously.- Use a message queue system (like Redis with BullMQ, RabbitMQ, Kafka, or Google Pub/Sub / AWS SQS).
- The webhook handler (
PlivoWebhookController) simply validates the request (signature guard) and adds a job to the queue containing thePlivoSmsPayload. - A separate NestJS worker process (or microservice) listens to the queue, picks up jobs, and performs the slow logic (DB writes, external API calls, sending replies).
- Important: If processing is async, any reply cannot be sent via the initial XML response. You must use the Plivo Send Message API (e.g.,
plivo.Client().messages.create(...)) in your asynchronous worker process to send the reply as a separate outbound message.
- Caching: If fetching common data frequently within the handler (e.g., user settings, templates), use caching (NestJS
CacheModule, Redis, Memcached) to speed up lookups and reduce database load.
10. Adding Monitoring, Observability, and Analytics
- Health Checks: Implement a health check endpoint (e.g.,
/health) using@nestjs/terminus. This endpoint should verify critical dependencies (database connection, Plivo API reachability if sending outbound messages). Monitor this endpoint externally (e.g., using UptimeRobot, Pingdom, or your cloud provider's monitoring). - Performance Metrics (APM): Integrate with Application Performance Monitoring (APM) tools (Datadog, New Relic, Dynatrace, OpenTelemetry). These tools automatically instrument your NestJS application to monitor request latency, throughput, error rates, and resource usage (CPU, memory). Track performance of the
/plivo-webhook/messageendpoint specifically. - Error Tracking: Use dedicated error tracking services like Sentry or Bugsnag. Integrate them via NestJS loggers or exception filters to capture, aggregate, alert on, and debug application errors in real-time.
- Plivo Logs: Regularly check the Plivo Console (Messaging -> Logs) for detailed information on message delivery status (inbound and outbound), webhook delivery attempts, failures, and error codes from Plivo's perspective. This is crucial for debugging delivery issues.
- Custom Metrics/Analytics: Log key business events (e.g.,
inbound_message_received,reply_sent,stop_keyword_received,help_keyword_received,processing_error) to your logging system or a dedicated analytics platform. Build dashboards (Grafana, Kibana, Datadog Dashboards) to visualize SMS activity, error trends, and user engagement.
11. Troubleshooting and Common Issues
- Ngrok Session Expired: Free
ngroksessions expire after a few hours. You'll need to restartngrok(which gives a new URL) and update the Plivo Application's Message URL in the Plivo Console. Consider a paidngrokplan or deploying to a stable environment for persistent URLs. - Firewall Issues: If deploying to your own server, ensure your firewall allows incoming HTTPS traffic on the configured port (e.g., 3000 or 443 if behind a proxy) from Plivo's IP ranges. While signature validation is the primary security, network connectivity is still required. Plivo publishes its IP ranges.
- Incorrect Plivo Config: Double-check:
- The Message URL in the Plivo Application settings is exactly correct (HTTPS, full path:
https://.../plivo-webhook/message). - The Method is set to
POST. - The correct Plivo Number is linked to the correct Plivo Application.
- The Message URL in the Plivo Application settings is exactly correct (HTTPS, full path:
- Signature Validation Failures:
- Verify
PLIVO_AUTH_TOKENin your.envfile exactly matches the one on your Plivo Console dashboard. - Ensure the URL constructed within the
PlivoSignatureGuardmatches the URL Plivo is actually calling (checkngroklogs or server access logs). Pay attention tohttpvshttps. - Raw Body Issue: This is the most common cause. Confirm
{ rawBody: true }is set inNestFactory.createand thatrequest.rawBodyis a Buffer containing the exact bytes Plivo sent. Any intermediate parsing or modification will invalidate the signature. - Verify
X-Plivo-Signature-V3andX-Plivo-Signature-V3-Nonceheaders are being correctly received and extracted.
- Verify
- Body Parsing Issues: NestJS with
{ rawBody: true }typically handlesapplication/x-www-form-urlencodedcorrectly, parsing it into@Body()while preservingrawBody. If Plivo were configured to send JSON, you'd need NestJS's JSON body parser, but signature validation still needs the raw JSON buffer. STOPKeyword Blocking: If a user textsSTOP, Plivo's platform-level compliance features may automatically block your Plivo number from sending further messages to that user's number. This is expected behavior for compliance. Your application might still receive the STOP message via webhook depending on settings.- Trial Account Limitations: Plivo trial accounts have restrictions:
- Typically can only send messages to phone numbers verified in the Plivo Console (Sandbox Numbers).
- Receiving messages from any number usually works, but check trial limitations.
- May have rate limits or volume caps.
- XML Errors: If your generated XML in the response is invalid (e.g., malformed tags, incorrect structure), Plivo will fail to process the reply instructions. Check your NestJS logs for the exact XML generated by
response.toXML()and validate it using an XML validator if needed. Ensure theContent-Type: application/xmlheader is correctly set on the response.
12. Deployment and CI/CD
-
Choose Deployment Target:
- PaaS (Platform as a Service): Heroku, Render, Google App Engine, AWS Elastic Beanstalk. Often provide simpler deployment workflows for Node.js applications. Ensure the platform supports persistent WebSocket connections if needed for other features, and check how environment variables are managed.
- Containers: Dockerize your NestJS application. Deploy the container image to orchestrators like Kubernetes (EKS, GKE, AKS), AWS ECS, Google Cloud Run, or managed container platforms. This offers more control and portability.
- Serverless Functions: AWS Lambda, Google Cloud Functions, Azure Functions. Requires using an adapter like
@nestjs/platform-fastifywith@fastify/aws-lambdaor@vendia/serverless-expressto run NestJS in a serverless environment. Can be cost-effective but has cold start implications and different architectural considerations. - Virtual Machines (IaaS): AWS EC2, Google Compute Engine, Azure VMs. Provides maximum control but requires managing the OS, Node.js runtime, security patches, and potentially a reverse proxy (like Nginx or Caddy) yourself.
-
Build for Production: Add a build script to your
package.json:json// package.json "scripts": { // ... other scripts "build": "nest build", "start:prod": "node dist/main" }Run
npm run buildto transpile TypeScript to JavaScript in thedistfolder. -
Environment Variables: Securely configure environment variables (
PORT,PLIVO_AUTH_ID,PLIVO_AUTH_TOKEN,PLIVO_PHONE_NUMBER, database credentials, etc.) in your chosen deployment environment. Do not commit sensitive keys to your repository. Use the platform's secrets management (e.g., Heroku Config Vars, AWS Secrets Manager, GCP Secret Manager). -
Dockerfile (Example):
dockerfile# Stage 1: Build the application FROM node:18-alpine AS builder WORKDIR /usr/src/app COPY package*.json ./ RUN npm install --only=production --ignore-scripts --prefer-offline COPY . . RUN npm run build # Stage 2: Create the final production image FROM node:18-alpine WORKDIR /usr/src/app # Copy only necessary files from builder stage COPY /usr/src/app/node_modules ./node_modules COPY /usr/src/app/dist ./dist # Copy package.json for runtime info if needed, but not for installing deps COPY package.json . # Expose the port the app runs on # Use an ENV var for flexibility, matching your .env or deployment config ARG PORT=3000 ENV PORT=${PORT} EXPOSE ${PORT} # Command to run the application CMD ["node", "dist/main"] -
CI/CD Pipeline: Set up a Continuous Integration/Continuous Deployment pipeline (GitHub Actions, GitLab CI, Jenkins, CircleCI, AWS CodePipeline, Google Cloud Build):
- Trigger: On push/merge to main branch.
- CI Steps: Install dependencies, lint, run tests (unit, integration, e2e).
- Build Step: Build the production application (
npm run build) or build the Docker image. - CD Steps: Push Docker image to a registry (Docker Hub, ECR, GCR), deploy the new version to your chosen platform (e.g., update Heroku app, deploy to Kubernetes, update Lambda function).
-
Update Plivo Message URL: Once deployed to a stable public URL (not
ngrok), update the Message URL in your Plivo Application settings in the Plivo Console to point to your production endpoint (e.g.,https://your-app-domain.com/plivo-webhook/message).
Related Resources
Frequently Asked Questions
how to receive sms messages in nestjs
Receive SMS messages in NestJS by setting up a webhook endpoint using Plivo. Configure Plivo to forward incoming messages to this endpoint, which will then process them using the Plivo Node.js SDK. This enables two-way messaging and allows your application to react to incoming SMS.
what is plivo used for in nestjs
Plivo is a cloud communications platform that provides SMS and Voice APIs, used in this NestJS application to handle inbound and outbound SMS messages. The Plivo Node.js SDK helps to build the XML responses required by Plivo and also to send outgoing messages if needed.
why use nestjs for sms applications
NestJS provides a structured, scalable, and efficient framework for building server-side applications, making it well-suited for production SMS APIs. Features like dependency injection, modularity, and TypeScript support enhance code maintainability and reliability.
when should I use ngrok with plivo
ngrok is essential for local development with Plivo webhooks. Since Plivo needs a publicly accessible URL, ngrok creates a tunnel to expose your local server, allowing Plivo to reach your application during testing before deploying to a live server.
how to set up plivo webhook in nestjs
Create a dedicated controller with a POST route (e.g., '/plivo-webhook/message') in your NestJS application. Configure this URL as the 'Message URL' in your Plivo application settings. This endpoint will then receive incoming SMS messages from Plivo.
how to validate plivo signature in nestjs
Use the PlivoSignatureGuard with the '@UseGuards' decorator on your webhook route. This guard utilizes the 'plivo.validateV3Signature' function and the 'PLIVO_AUTH_TOKEN' to verify that requests genuinely originate from Plivo and haven't been tampered with.
what is plivo message uuid
The Plivo MessageUUID is a unique identifier assigned to each incoming message by Plivo. Use this UUID for logging, tracking, and potentially storing messages in a database to avoid duplicates and maintain message history.
how to handle stop keyword in plivo sms
Handle 'STOP' keywords by checking the incoming message text. Implement logic to unsubscribe the user, potentially updating your database. The Plivo platform may also offer automatic opt-out management via Compliance settings.
how to send sms replies with plivo xml
Use the 'plivo.Response' object to construct XML containing '<Message>' elements. Set 'src' to your Plivo number and 'dst' to the sender's number. Return this XML from your webhook handler; Plivo will process it and send the reply.
how to configure environment variables for plivo
Create a '.env' file in your project root to store 'PLIVO_AUTH_ID', 'PLIVO_AUTH_TOKEN', and 'PLIVO_PHONE_NUMBER'. Import and configure the '@nestjs/config' module in your app.module.ts to load these variables securely. Never commit the '.env' file.
what is the purpose of raw body in nestjs plivo integration
The 'rawBody' option in NestFactory.create is crucial for Plivo signature validation. It ensures the guard receives the exact, unmodified request body bytes needed by 'plivo.validateV3Signature', which is essential for security.
how to handle mms messages with plivo webhook
For MMS messages, look for 'Type=mms' in the webhook payload. Access media via the provided 'Media_URL' parameters. Note that these URLs are temporary and may require authentication for download.
how to test plivo webhook locally
Use ngrok to expose your local development server. Configure your Plivo application's Message URL to point to the ngrok HTTPS URL plus your webhook route. Send a real SMS to your Plivo number to trigger the webhook.
why does plivo signature validation fail
Plivo signature validation can fail due to several reasons, including an incorrect 'PLIVO_AUTH_TOKEN', a mismatch between the constructed URL and the actual URL called by Plivo, especially when using a proxy, or most commonly issues with the raw body not containing the original request bytes.