Handling delivery statuses for SMS messages is crucial for applications that rely on reliable communication. Knowing whether a message was delivered, failed, or was rejected enables developers to build more robust error handling, reporting, and user feedback mechanisms. Plivo provides delivery status updates via webhooks (callbacks), sending real-time information about message events to a URL you specify.
This guide provides a complete walkthrough for building a production-ready system in Node.js using the NestJS framework to receive, validate, process, and store Plivo SMS delivery status callbacks. We will cover project setup, secure handling of callbacks, data storage, testing, and deployment considerations.
By the end of this guide, you will have a functional NestJS application capable of:
- Sending SMS messages via the Plivo API.
- Receiving delivery status callbacks from Plivo at a dedicated endpoint.
- Securely validating incoming callbacks using Plivo's signature verification.
- Parsing the callback data.
- Storing the delivery status information in a database (using Prisma and SQLite for this example).
- Logging relevant information for monitoring and debugging.
Technologies Used:
- Node.js: The JavaScript runtime environment.
- NestJS: A progressive Node.js framework for building efficient, reliable, and scalable server-side applications. Its modular architecture and built-in features (like validation pipes and configuration management) make it ideal for this task.
- Plivo Node.js SDK: Simplifies interaction with the Plivo API for sending messages and validating callbacks.
- Prisma: A modern database toolkit for Node.js and TypeScript, used here for database schema management, migrations, and type-safe database access.
- SQLite: A simple file-based database, suitable for development and demonstration purposes. (Production deployments might use PostgreSQL, MySQL, etc.)
@nestjs/config
: For managing environment variables securely.ngrok
: (For local development) A tool to expose local servers to the internet, necessary for receiving 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: Ensure you have Node.js (LTS version recommended) and npm (or yarn) installed.
- NestJS CLI: Install globally:
npm install -g @nestjs/cli
- Plivo Account: A Plivo account with Auth ID, Auth Token, and a Plivo phone number capable of sending SMS. You can sign up for a free trial.
ngrok
(optional but recommended for local testing): Download and installngrok
to expose your local development server.
1. Setting up the NestJS project
First, 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-dev
plivo
: The official Plivo Node.js SDK.@nestjs/config
: For handling environment variables.class-validator
,class-transformer
: Used by NestJS for request validation via DTOs.prisma
,@prisma/client
: The Prisma CLI and Client for database interactions.
Project Structure:
NestJS provides a standard structure. We will add modules for specific features like messaging
and callbacks
.
plivo-callbacks-app/
├── prisma/ # Prisma schema and migrations
│ └── schema.prisma
├── src/
│ ├── app.module.ts # Root module
│ ├── main.ts # Application entry point
│ ├── config/ # Configuration setup (optional, using global ConfigModule here)
│ ├── core/ # Core utilities (e.g., PrismaService)
│ ├── messaging/ # Module for sending messages
│ ├── callbacks/ # Module for handling Plivo callbacks
│ └── utils/ # Utility functions (e.g., Plivo validation)
├── .env # Environment variables (ignored by git)
├── .gitignore
├── nest-cli.json
├── package.json
├── README.md
├── tsconfig.build.json
└── tsconfig.json
2. Plivo account and application setup
Before writing code, 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. These are needed 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 down. - Create a Plivo Application:
- Go to
Messaging
->XML Applications
. - Click
Add New Application
. - Give it a name (e.g.,
NestJS Callback App
). - Crucially, find the Delivery Report URL (sometimes labeled Message URL or DLR URL). This is where Plivo will send the status updates.
- For now, you can enter a placeholder like
http://example.com/callbacks/delivery-status
. We will update this later with our actual endpoint URL (likely anngrok
URL during development).
- For now, you can enter a placeholder like
- Set the
Method
toPOST
. - Leave other fields blank or default unless you have specific needs for inbound message handling with this app.
- Click
Create Application
. Note theApp ID
generated for this application (though we won't use it directly for sending messages with callback URLs specified per message).
- Go to
- Link Number to Application (Optional but Good Practice): If you want all messages sent from a specific number to use this application's settings by default (instead of specifying the callback URL per message), go to
Phone Numbers
->Your Numbers
, click on your number, select your newly created application from theApplication Type
dropdown, and update. For this guide, we will specify the callback URL directly when sending the message. This approach is highly flexible, allowing different messages (e.g., transactional vs. marketing) sent from the same number to potentially use different callback endpoints or logic if needed.
3. Environment configuration
Never hardcode sensitive credentials. Use environment variables.
-
Create
.env
file: In the root of your project, 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"
- Replace the placeholder values with your actual Plivo credentials and number.
- Ensure
.env
is listed in your.gitignore
file to prevent committing secrets.
-
Configure NestJS
ConfigModule
: Update your rootAppModule
to 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', }), CoreModule, // Import CoreModule for PrismaService CallbacksModule, MessagingModule, ], controllers: [], providers: [], }) export class AppModule {}
ConfigModule.forRoot({ isGlobal: true })
makes theConfigService
available throughout your application via dependency injection without needing to importConfigModule
in every feature module.
4. Building the callback endpoint
This endpoint will receive the POST
requests from Plivo containing delivery status information.
-
Generate Module, Controller, Service:
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
.// 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.
-
Implement the Controller: Set up the route to listen for POST requests.
// src/callbacks/callbacks.controller.ts import { Controller, Post, Body, HttpCode, HttpStatus, Headers, // Keep for potential use or clarity Req, // Keep for potential use or clarity 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 // @Req() request: any, // Can be removed if guard handles everything needed // @Headers('X-Plivo-Signature-V3') signature: string, // Handled by guard // @Headers('X-Plivo-Signature-V3-Nonce') nonce: string, // Handled by guard ): 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 generally just needs acknowledgment (a 2xx status code) that you received the callback. Sending back content isn't necessary, and 204 No Content is semantically correct. WhyLogger
? NestJS provides a built-in logger that's easy to use and integrates well with the framework. Effective logging is essential for debugging webhook issues. -
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 } 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}`); } catch (error) { this.logger.error(`Database error processing status for ${payload.MessageUUID}: ${error.message}`, error.stack); // Decide if you need to throw to potentially trigger retries upstream // For this guide, we log and let the controller return 204 // Re-throw or handle depending on desired behavior throw error; // Re-throwing allows centralized error handling if needed, but ensures 204 isn't sent on DB error } } }
5. Security: Validating Plivo signatures
It's critical to verify that incoming webhook requests actually originate from Plivo. Plivo provides signatures for this purpose (using X-Plivo-Signature-V3
and X-Plivo-Signature-V3-Nonce
headers).
-
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 V3 Webhook Signatures. * Ref: https://www.plivo.com/docs/voice/concepts/request-authentication/#validate-requests-from-plivo * @param requestUrl The full URL Plivo sent the request to (including scheme, host, path, and query params). **Must match exactly what Plivo used.** * @param nonce The value from the 'X-Plivo-Signature-V3-Nonce' header. * @param signature The value from the 'X-Plivo-Signature-V3' 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 validatePlivoV3Signature( requestUrl: string, nonce: string, signature: string, authToken: string, postParams: string, // Raw request body as a string ): boolean { try { // Construct the base string: URL + Nonce + RawBody const baseString = `${requestUrl}${nonce}${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 V3 signature includes the full URL (scheme, host, path, query), the nonce, and the raw request body concatenated. Ensure you reconstruct this correctly. Getting the raw body requires specific configuration in NestJS.
-
Enable Raw Body Parsing: Modify
main.ts
to 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 { validatePlivoV3Signature } 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-v3'] as string; const nonce = request.headers['x-plivo-signature-v3-nonce'] as string; if (!signature || !nonce) { this.logger.warn('Missing Plivo signature headers'); throw new UnauthorizedException('Missing Plivo signature headers'); } // 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.protocol; // 'http' or 'https' const host = request.get('host'); // e.g., 'your-ngrok-id.ngrok.io' or 'yourapp.com' const originalUrl = request.originalUrl; // e.g., '/callbacks/delivery-status?query=param' const fullUrl = `${protocol}://${host}${originalUrl}`; this.logger.debug(`Validating signature for URL: ${fullUrl} with Nonce: ${nonce}`); const isValid = validatePlivoV3Signature( fullUrl, nonce, signature, authToken, request.rawBody.toString('utf-8'), // Pass raw body as string ); if (!isValid) { this.logger.warn(`Invalid Plivo signature received for URL: ${fullUrl}`); throw new UnauthorizedException('Invalid Plivo signature'); } this.logger.log(`Valid Plivo signature verified for URL: ${fullUrl}`); return true; // Allow request processing } }
-
Apply the Guard: Add
@UseGuards(PlivoSignatureGuard)
to thehandleDeliveryStatus
method inCallbacksController
, as shown earlier. Ensure theCallbacksModule
importsConfigModule
if it's not global, or providesConfigService
appropriately.
6. Creating a database schema and data layer (Prisma)
Let's store the delivery statuses using Prisma and SQLite.
-
Initialize Prisma:
npx prisma init --datasource-provider sqlite
This creates
prisma/schema.prisma
and updates.env
withDATABASE_URL=""file:./dev.db""
. -
Define Schema: Edit
prisma/schema.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. -
Run Migration: Apply the schema to your database.
npx prisma migrate dev --name init-delivery-status
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
CoreModule
exportsPrismaService
and 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
CallbacksService
shown in Step 4 already includes the injection and usage ofPrismaService
.
7. Sending a message and triggering callbacks
Now, let's implement the functionality to send an SMS and tell Plivo where to send the delivery report.
-
Generate Messaging Module/Service/Controller:
nest g module messaging nest g service messaging --no-spec nest g controller messaging --no-spec
-
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); // 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.create
tells Plivo where to send the delivery status for this specific message. This overrides any default URL set in the Plivo Application settings. -
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 } 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 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 } } }