Implementing Plivo Inbound SMS and Two-Way Messaging with NestJS
This guide provides a step-by-step walkthrough for building a production-ready NestJS application capable of receiving incoming SMS messages via Plivo webhooks and responding automatically, enabling two-way messaging. We will cover project setup, core implementation, security considerations, error handling, deployment, and testing.
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, bots, notification systems, and more.
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).
ngrok
installed 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 the Project
Let's initialize our NestJS project and install the necessary dependencies.
-
Install NestJS CLI: If you don't have it_ install it globally:
npm install -g @nestjs/cli
-
Create NestJS Project:
nest new plivo-nestjs-inbound cd plivo-nestjs-inbound
Choose 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.
npm install plivo @nestjs/config # OR yarn add plivo @nestjs/config
-
Environment Variables: Create a
.env
file in the project root for storing sensitive credentials and configuration. Never commit this file to version control – add.env
to your.gitignore
file.# .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 thesrc
if sending replies via the API instead of XML response.
-
Configure Environment Variables: Import and configure
ConfigModule
in your main application module (src/app.module.ts
) to load the.env
file.// 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.ts
to use the configured port and enable raw body parsing for signature validation.// 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.create
is 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.
nest generate module plivo-webhook nest generate controller plivo-webhook --no-spec nest generate service plivo-webhook --no-spec
This creates a
src/plivo-webhook
directory with the necessary files and updatessrc/app.module.ts
automatically (as seen in step 5).
2. Implementing Core Functionality (Webhook Handler)
Now, let's build the controller and service to handle incoming Plivo webhooks.
-
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.// 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; MessageUUID: string; // Add other fields like 'Media*' for MMS if needed } @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
PlivoSmsPayload
for type safety based on Plivo's webhook parameters. - The
handleIncomingSms
method takes the parsed payload, logs it, and uses theplivo
SDK'sResponse
class 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.// 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_url
in 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-urlencoded
from Plivo) and map it to ourPlivoSmsPayload
interface.@Req() request: RawBodyRequest<Request>
: Provides access to the full request object, including the raw body buffer needed by the signature guard.RawBodyRequest
is used for type safety when{ rawBody: true }
is enabled.@Header('Content-Type', 'application/xml')
: Sets theContent-Type
header on the response to tell Plivo we are sending back XML.@HttpCode(HttpStatus.OK)
: Ensures a200 OK
status code is sent on success. Plivo expects this.- It calls the
plivoWebhookService.handleIncomingSms
method 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
ngrok
and sending a real SMS.
4. Integrating with Plivo (Configuration)
Now, let's configure Plivo to send incoming messages to our local NestJS application using ngrok
.
-
Start Your NestJS App:
npm run start:dev # OR yarn start:dev
Ensure it's running and listening on the correct port (e.g., 3000) and that the
rawBody: true
option is enabled inmain.ts
. -
Start ngrok: Open a new terminal window and expose your local port to the internet.
ngrok http 3000 # Make sure '3000' matches the PORT in your .env file / main.ts
ngrok
will display forwarding URLs (e.g.,https://<unique-id>.ngrok-free.app
). Copy thehttps
URL. -
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
ngrok
HTTPS 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...catch
blocks. -
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 OK
with an empty<Response></Response>
is often preferred even on error to prevent Plivo from retrying the webhook, while still logging the error internally.// 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
:// src/main.ts (inside bootstrap function) import { AllExceptionsFilter } from './common/all-exceptions.filter'; // Adjust path if needed // ... app.useGlobalFilters(new AllExceptionsFilter()); // ...
- Apply Globally: Apply this filter in
-
Plivo Retries: If your webhook endpoint fails to respond with a
200 OK
within 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 OK
via 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
.env
and configure theTypeOrmModule
inapp.module.ts
. - Define Entities/Models: Create TypeORM entities or Prisma models (e.g.,
MessageLog
) to represent your database tables/collections.// 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
):// 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; // --- 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.create
insrc/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 proxy
settings). - Error Handling: Throws
ForbiddenException
on 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
ConfigService
is available (it is, viaConfigModule.forRoot({ isGlobal: true })
) and that the guard is provided in thePlivoWebhookModule
.// 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 NestJSValidationPipe
withclass-validator
DTOs or sanitization libraries (likeclass-sanitizer
or 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/throttler
to limit requests per source IP or other criteria. -
HTTPS: Always use HTTPS for your webhook endpoint in production.
ngrok
provides 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:// Inside PlivoWebhookService.handleIncomingSms 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
payload
forType=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/message
endpoint 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 Caveats
- Ngrok Session Expired: Free
ngrok
sessions 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 paidngrok
plan 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_TOKEN
in your.env
file exactly matches the one on your Plivo Console dashboard. - Ensure the URL constructed within the
PlivoSignatureGuard
matches the URL Plivo is actually calling (checkngrok
logs or server access logs). Pay attention tohttp
vshttps
. - Raw Body Issue: This is the most common cause. Confirm
{ rawBody: true }
is set inNestFactory.create
and thatrequest.rawBody
is a Buffer containing the exact bytes Plivo sent. Any intermediate parsing or modification will invalidate the signature. - Verify
X-Plivo-Signature-V3
andX-Plivo-Signature-V3-Nonce
headers are being correctly received and extracted.
- Verify
- Body Parsing Issues: NestJS with
{ rawBody: true }
typically handlesapplication/x-www-form-urlencoded
correctly, 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. STOP
Keyword 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/xml
header 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-fastify
with@fastify/aws-lambda
or@vendia/serverless-express
to 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
:// package.json ""scripts"": { // ... other scripts ""build"": ""nest build"", ""start:prod"": ""node dist/main"" }
Run
npm run build
to transpile TypeScript to JavaScript in thedist
folder. -
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):
# 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
).