code examples
code examples
NestJS Two-Way SMS Integration with Vonage: Complete Webhook Tutorial
Build production-ready two-way SMS messaging with NestJS and Vonage Messages API. Step-by-step guide covering outbound SMS, inbound webhooks, delivery receipts, error handling, and database integration with TypeScript.
Build Two-Way SMS Messaging with NestJS and Vonage Messages API: Complete Integration Guide
Build a production-ready two-way SMS messaging system using NestJS, Node.js, and the Vonage Messages API. This comprehensive tutorial covers project setup, sending outbound SMS messages, and receiving inbound messages via webhooks – from initial configuration through deployment and monitoring.
Create applications that interact with users via SMS for OTP authentication, order notifications, customer support, or conversational experiences. NestJS provides structured, scalable, and maintainable backend architecture with TypeScript. Vonage delivers enterprise-grade communication infrastructure for reliable SMS delivery and reception.
Technology Stack:
- Node.js: JavaScript runtime environment
- NestJS: Progressive Node.js framework for building efficient, reliable, and scalable server-side applications
- Vonage Messages API: Send and receive SMS programmatically
@vonage/server-sdk: Official Vonage Node.js SDK@nestjs/config: Manage environment variablesngrok: Expose local development server for webhook testing- (Optional) Prisma: Database interactions for message logging
- (Optional) Docker: Application containerization
System Architecture:
+-----------------+ +---------------------+ +-----------------+ +-----------------+
| User's Phone | <--->| Vonage Platform | <--->| Your NestJS App| ---> | (Optional) DB |
| (Sends/Receives)| | (SMS Gateway, API) | | (API, Webhooks) | | (Message Logs) |
+-----------------+ +---------------------+ +-----------------+ +-----------------+
| ^ | ^
| (Outbound SMS) | | (Inbound SMS Webhook) |
+------------------------+ +------------------------+Prerequisites:
- Node.js version 20.x or later (required for NestJS as of 2024) and npm/yarn installed. NestJS requires Node.js >=20 for current versions. (Download Node.js)
- Vonage API account (Sign up here)
- Vonage Application ID and Private Key file (generated via Vonage Dashboard)
- Vonage virtual phone number capable of sending/receiving SMS, linked to your application
ngrokinstalled and authenticated (Download here)- Basic understanding of TypeScript and NestJS concepts
- (Optional) Docker installed if you plan to containerize
Final Outcome:
By the end of this guide, you will have a NestJS application capable of:
- Sending SMS messages via a simple API endpoint
- Receiving inbound SMS messages via a webhook endpoint
- Securely managing Vonage credentials
- Basic logging and error handling for SMS operations
- (Optional) Storing message history in a database
- Ready for deployment with considerations for security and testing
How to Set Up Your NestJS Project for SMS Integration
Start by creating a new NestJS project and setting up the basic structure and dependencies.
1. Install NestJS CLI (if you haven't already):
npm install -g @nestjs/cli2. Create a new NestJS project:
nest new vonage-sms-app
cd vonage-sms-appChoose your preferred package manager (npm or yarn) when prompted.
3. Install necessary dependencies:
Install the Vonage SDK and NestJS config module:
# Using npm
npm install @vonage/server-sdk @nestjs/config class-validator class-transformer
# Using yarn
yarn add @vonage/server-sdk @nestjs/config class-validator class-transformer@vonage/server-sdk: Official SDK for interacting with Vonage APIs@nestjs/config: Handles environment variables gracefullyclass-validator&class-transformer: Validate incoming request data (webhook payloads, API requests)
Note: This guide uses Vonage Node.js SDK v3.x (current version 3.25.1 as of September 2024). The SDK uses a modular package structure with separate packages for different API functionalities. If migrating from SDK v2.x, be aware of significant architectural changes including Promise-based interactions and updated authentication methods. For migration details, consult the Vonage SDK v2 to v3 migration guide. The Messages API is in General Availability status.
4. Configure environment variables:
Manage sensitive credentials like API keys using a .env file for local development.
-
Create a
.envfile in the project root:bashtouch .env -
Add the following variables to your
.envfile. Obtain these values from your Vonage Dashboard (Applications → Your Application):dotenv# .env # Vonage Credentials VONAGE_APPLICATION_ID=YOUR_VONAGE_APPLICATION_ID VONAGE_PRIVATE_KEY_PATH=./private.key # Path relative to project root VONAGE_NUMBER=YOUR_VONAGE_VIRTUAL_NUMBER # The number sending/receiving SMS # Application Port PORT=3000VONAGE_APPLICATION_ID: Found on your Vonage Application pageVONAGE_PRIVATE_KEY_PATH: The path to theprivate.keyfile you downloaded when creating the Vonage Application. Copy theprivate.keyfile into your project's root directory. Ensure this path is correct. Never commit your private key to version control.VONAGE_NUMBER: The Vonage virtual number linked to your application. Use E.164 format (e.g.,14155550100)PORT: The port your NestJS application will listen on
-
Important security note: Add
.envandprivate.keyto your.gitignorefile immediately to prevent accidentally committing secrets:text# .gitignore # dependencies /node_modules /dist # env .env private.key # Add this line! # logs npm-debug.log* yarn-debug.log* yarn-error.log*
5. Integrate ConfigModule:
Load environment variables into your NestJS application using ConfigModule.
-
Modify
src/app.module.ts:typescript// src/app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; // Import ConfigModule import { AppController } from './app.controller'; import { AppService } from './app.service'; import { VonageModule } from './vonage/vonage.module'; // We will create this next // Import PrismaModule if using Prisma and it's set to Global // import { PrismaModule } from './prisma/prisma.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, // Make ConfigModule globally available envFilePath: '.env', // Specify the env file path }), VonageModule, // Import our upcoming Vonage module // PrismaModule, // Include if using Prisma ], controllers: [AppController], providers: [AppService], }) export class AppModule {}ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env' }): Initializes the configuration module, makes it available application-wide, and loads variables from the.envfile
Project Structure Rationale:
NestJS CLI provides a standard, modular structure (src, test, configuration files). Create dedicated modules (VonageModule) for specific functionalities (like interacting with Vonage) to keep the codebase organized and maintainable, following NestJS best practices. Environment variables are managed centrally via @nestjs/config for security and flexibility across different environments (development, staging, production).
How to Implement Vonage Service for Sending SMS in NestJS
Create a dedicated module and service to encapsulate all interactions with the Vonage SDK.
1. Generate the Vonage module and service:
Use the NestJS CLI to generate the necessary files:
nest generate module vonage
nest generate service vonage --no-spec # We'll add tests laterThis creates a src/vonage directory with vonage.module.ts and vonage.service.ts.
2. Implement the VonageService:
This service will initialize the Vonage SDK and provide methods for sending SMS. Note: If using Prisma (Section 6), ensure PrismaService is injected here.
-
Edit
src/vonage/vonage.service.ts:typescript// src/vonage/vonage.service.ts import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Vonage } from '@vonage/server-sdk'; import { MessageSendRequest } from '@vonage/messages'; import * as fs from 'fs'; // Import Node.js fs module import { PrismaService } from '../prisma/prisma.service'; // Import if using Prisma @Injectable() export class VonageService implements OnModuleInit { private readonly logger = new Logger(VonageService.name); private vonageClient: Vonage; private vonageNumber: string; constructor( private configService: ConfigService, // Inject PrismaService if you are using the optional database logging (Section 6) // Make sure PrismaModule is imported in AppModule and PrismaService is exported/global private readonly prismaService?: PrismaService, // Make optional if Prisma is optional ) {} onModuleInit() { const applicationId = this.configService.get<string>('VONAGE_APPLICATION_ID'); const privateKeyPath = this.configService.get<string>('VONAGE_PRIVATE_KEY_PATH'); this.vonageNumber = this.configService.get<string>('VONAGE_NUMBER'); if (!applicationId || !privateKeyPath || !this.vonageNumber) { this.logger.error('Vonage credentials missing in environment variables.'); throw new Error('Vonage credentials missing.'); } try { // Read the private key file content const privateKey = fs.readFileSync(privateKeyPath); this.vonageClient = new Vonage({ applicationId: applicationId, privateKey: privateKey, // Pass the key content, not the path }); this.logger.log('Vonage Client Initialized Successfully.'); } catch (error) { this.logger.error(`Failed to initialize Vonage Client: ${error.message}`, error.stack); throw error; // Re-throw to prevent application startup if Vonage fails } } async sendSms(to: string, text: string): Promise<string | null> { const messageRequest: MessageSendRequest = { message_type: 'text', to: to, from: this.vonageNumber, channel: 'sms', text: text, }; let messageUuid: string | null = null; try { const response = await this.vonageClient.messages.send(messageRequest); messageUuid = response.message_uuid; this.logger.log(`SMS sent successfully to ${to}, message_uuid: ${messageUuid}`); // Optional: Log to database (See Section 6) // Ensure PrismaService is injected and available before using this.prismaService if (messageUuid && this.prismaService) { this.prismaService.smsMessage.create({ data: { messageUuid: messageUuid, direction: 'OUTBOUND', fromNumber: this.vonageNumber, toNumber: to, text: text, timestamp: new Date(), // Time sent initiated status: 'submitted', // Initial status }, }).catch(err => this.logger.error('Failed to save outbound message to DB', err?.stack || err)); } // End Optional DB Log return messageUuid; } catch (error) { this.logger.error(`Failed to send SMS to ${to}: ${error?.message || error}`, error?.response?.data || error?.stack); // Depending on requirements, you might want to throw the error // or handle it gracefully (e.g., return null, queue for retry). // For this example, we log and return null. return null; } } // We will add methods for handling inbound messages later if needed, // but the primary handling will be in a controller. }OnModuleInit: Ensures the Vonage client is initialized when the module loadsConfigService: Injected to retrieve environment variables securelyPrismaService: Injected (conditionally, if using DB logging from Section 6). Made optional in constructor- Error Handling: Checks for missing credentials and catches errors during client initialization and message sending
fs.readFileSync: Reads the content of the private key file specified byVONAGE_PRIVATE_KEY_PATH. The SDK expects the key content, not the file path directlysendSmsMethod:- Constructs the
MessageSendRequestobject required by the SDK - Uses
this.vonageClient.messages.send()to dispatch the SMS - Logs success or failure, returning the
message_uuidon success ornullon failure - Includes optional Prisma logging logic (checks if
this.prismaServiceexists before using)
- Constructs the
3. Update VonageModule:
Make the VonageService available for injection elsewhere in the application.
-
Edit
src/vonage/vonage.module.ts:typescript// src/vonage/vonage.module.ts import { Module } from '@nestjs/common'; import { VonageService } from './vonage.service'; import { ConfigModule } from '@nestjs/config'; // Import ConfigModule // No need to import PrismaModule here if it's marked as @Global in AppModule // and PrismaService is exported from PrismaModule. @Module({ imports: [ConfigModule], // Import ConfigModule here as VonageService depends on it providers: [VonageService], exports: [VonageService], // Export VonageService so other modules can use it }) export class VonageModule {}
Now, any other module that imports VonageModule can inject VonageService.
Building API Endpoints: How to Send and Receive SMS Messages
Create endpoints to trigger sending SMS and to receive inbound SMS webhooks from Vonage.
1. Generate a controller:
Create a controller to handle SMS-related HTTP requests:
nest generate controller sms --no-specThis creates src/sms/sms.controller.ts.
2. Create Data Transfer Objects (DTOs):
DTOs define the expected shape of request bodies and enable validation using class-validator.
-
Create
src/sms/dtodirectory if it doesn't exist -
Create
src/sms/dto/send-sms.dto.ts:typescript// src/sms/dto/send-sms.dto.ts import { IsNotEmpty, IsString, IsPhoneNumber } from 'class-validator'; export class SendSmsDto { @IsNotEmpty() @IsPhoneNumber(null) // Use null for generic phone number validation (adjust region if needed) @IsString() to: string; // E.164 format recommended (e.g., +14155550100) @IsNotEmpty() @IsString() text: string; } -
Create
src/sms/dto/inbound-sms.dto.ts:typescript// src/sms/dto/inbound-sms.dto.ts import { IsString, IsNotEmpty, IsOptional, IsEnum, ValidateNested, IsDateString } from 'class-validator'; import { Type } from 'class-transformer'; // Based on Vonage Messages API webhook format for inbound SMS // Ref: https://developer.vonage.com/en/messages/concepts/inbound-sms#webhook-format enum MessageType { TEXT = 'text', IMAGE = 'image', AUDIO = 'audio', VIDEO = 'video', FILE = 'file', VCARD = 'vcard', LOCATION = 'location', TEMPLATE = 'template', CUSTOM = 'custom', UNSUPPORTED = 'unsupported', // Added for robustness } class UsageDto { @IsOptional() @IsString() currency?: string; @IsOptional() @IsString() // Price might be a string representation price?: string; } class SmsInfoDto { @IsOptional() @IsString() num_messages?: string; // Often comes as a string } export class InboundSmsDto { @IsNotEmpty() @IsString() message_uuid: string; @IsNotEmpty() @IsString() to: string; // Your Vonage number @IsNotEmpty() @IsString() from: string; // Sender's number @IsNotEmpty() @IsDateString() timestamp: string; @IsNotEmpty() @IsEnum(MessageType) message_type: MessageType; @IsOptional() @IsString() text?: string; // Only present for message_type 'text' @IsOptional() @IsString() keyword?: string; // If applicable @IsNotEmpty() @IsString() channel: 'sms'; // Hardcoded for SMS focus // Optional fields based on Vonage documentation @IsOptional() @ValidateNested() @Type(() => UsageDto) usage?: UsageDto; @IsOptional() @ValidateNested() @Type(() => SmsInfoDto) sms?: SmsInfoDto; // Add other fields if needed (e.g., for MMS: image, audio, video URLs) } -
Create
src/sms/dto/sms-status.dto.ts:typescript// src/sms/dto/sms-status.dto.ts import { IsString, IsNotEmpty, IsOptional, IsEnum, IsDateString, IsUUID } from 'class-validator'; // Based on common Vonage Delivery Receipt (DLR) format via Status Webhook // Ref: https://developer.vonage.com/en/messages/concepts/delivery-receipts#dlr-format export enum MessageStatus { SUBMITTED = 'submitted', DELIVERED = 'delivered', EXPIRED = 'expired', FAILED = 'failed', REJECTED = 'rejected', ACCEPTED = 'accepted', // intermediate state BUFFERED = 'buffered', // intermediate state UNKNOWN = 'unknown', // default/fallback READ = 'read', // For channels supporting read receipts UNDELIVERABLE = 'undeliverable', // Common failure reason } export class SmsStatusDto { @IsNotEmpty() @IsUUID() message_uuid: string; @IsNotEmpty() @IsString() to: string; // Recipient number @IsNotEmpty() @IsString() from: string; // Your Vonage number @IsNotEmpty() @IsDateString() timestamp: string; // Timestamp of the status update @IsNotEmpty() @IsEnum(MessageStatus) status: MessageStatus; @IsOptional() @IsString() client_ref?: string; // If you included one in the outbound request @IsOptional() @IsString() // Often a numeric string error_code?: string; @IsOptional() @IsString() error_code_label?: string; // Human-readable error label // Add other potentially useful fields if needed // e.g., network_code, message_price, currency }
3. Implement the SMS controller:
-
Edit
src/sms/sms.controller.ts:typescript// src/sms/sms.controller.ts import { Controller, Post, Body, HttpCode, HttpStatus, Logger, ValidationPipe, UsePipes } from '@nestjs/common'; import { VonageService } from '../vonage/vonage.service'; import { SendSmsDto } from './dto/send-sms.dto'; import { InboundSmsDto } from './dto/inbound-sms.dto'; import { SmsStatusDto } from './dto/sms-status.dto'; // Import the new DTO import { PrismaService } from '../prisma/prisma.service'; // Import if using Prisma @Controller('sms') export class SmsController { private readonly logger = new Logger(SmsController.name); constructor( private readonly vonageService: VonageService, // Inject PrismaService if needed for direct DB access in controller // Make sure PrismaModule is imported in AppModule and PrismaService is exported/global private readonly prismaService?: PrismaService, // Make optional if Prisma is optional ) {} /** * Endpoint to trigger sending an SMS. * POST /sms/send */ @Post('send') @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) @HttpCode(HttpStatus.OK) // Return 200 OK on success async sendSms(@Body() sendSmsDto: SendSmsDto): Promise<{ message: string; messageId?: string }> { this.logger.log(`Received request to send SMS to: ${sendSmsDto.to}`); // VonageService now handles DB logging internally if enabled and available const messageId = await this.vonageService.sendSms(sendSmsDto.to, sendSmsDto.text); if (messageId) { return { message: 'SMS sent successfully requested.', messageId: messageId }; } else { // Consider returning a more specific error status, e.g., HttpStatus.INTERNAL_SERVER_ERROR // For simplicity here, we stick to a basic response. // The VonageService already logged the detailed error. return { message: 'Failed to send SMS.' }; } } /** * Endpoint for receiving inbound SMS messages from Vonage. * POST /sms/inbound */ @Post('inbound') @HttpCode(HttpStatus.OK) // Vonage expects a 200 OK to confirm receipt @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) // Validate incoming payload handleInboundSms(@Body() inboundSmsDto: InboundSmsDto): void { // Important: Respond immediately with 200 OK before processing. // Vonage has a 15-second response timeout after connection establishment (3-second connection timeout). If no 200 OK is received within 15 seconds, Vonage will retry with exponential backoff: initially every 5 seconds, backing off to 1 minute, 5 minutes, and 15 minutes for up to 24 hours. Source: [Vonage Webhook Timeout Specifications](https://api.support.vonage.com/hc/en-us/articles/226572227). this.logger.log(`Received inbound SMS from ${inboundSmsDto.from} with message ID ${inboundSmsDto.message_uuid}`); this.logger.debug('Inbound SMS Payload:', JSON.stringify(inboundSmsDto, null, 2)); // --- Process the inbound message asynchronously --- // Avoid blocking the response to Vonage. // Example: Save the message to a database (using PrismaService) if (this.prismaService) { this.prismaService.smsMessage.create({ data: { messageUuid: inboundSmsDto.message_uuid, direction: 'INBOUND', fromNumber: inboundSmsDto.from, toNumber: inboundSmsDto.to, text: inboundSmsDto.text, // Handle cases where text might be missing timestamp: new Date(inboundSmsDto.timestamp), status: 'received', // Initial status for inbound vonageStatus: 'received', // Can align vonageStatus too }, }).catch(err => this.logger.error('Failed to save inbound message to DB', err?.stack || err)); } // Add other async processing: trigger workflows, queue jobs, etc. // No explicit return needed as NestJS handles the 200 OK due to @HttpCode } /** * Webhook endpoint for receiving SMS status updates (DLRs) from Vonage. * POST /sms/status */ @Post('status') @HttpCode(HttpStatus.OK) // Vonage expects 200 OK @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) // Validate DLR payload handleSmsStatus(@Body() statusPayload: SmsStatusDto): void { // Respond quickly this.logger.log(`Received DLR status '${statusPayload.status}' for message ID ${statusPayload.message_uuid}`); this.logger.debug('DLR Payload:', JSON.stringify(statusPayload, null, 2)); // Process the status update asynchronously // Example: Update message status in the database (using PrismaService) if (this.prismaService) { this.prismaService.smsMessage.update({ where: { messageUuid: statusPayload.message_uuid }, data: { status: statusPayload.status, // Use validated status enum value vonageStatus: statusPayload.status, // Store raw status too errorCode: statusPayload.error_code, // If present // You might want to parse statusPayload.timestamp here too updatedAt: new Date(), }, }).catch(err => { // Log error, maybe check if it's a non-critical error like 'message not found' this.logger.error(`Failed to update DLR status for ${statusPayload.message_uuid}: ${err?.message}`, err?.stack); }); } // Add other async processing based on status (e.g., trigger alert on 'failed') // No explicit return needed } }SendSmsDto,InboundSmsDto,SmsStatusDto: Imported and used for request body validation and type safetyValidationPipe: Applied to all endpoints receiving data (send,inbound,status) to enforce DTO ruleshandleInboundSms&handleSmsStatus: Respond immediately with200 OK. Database operations (if using Prisma) are performed asynchronously (.catch()handles errors without blocking the response). Checks ifthis.prismaServiceexistsPrismaService: Injected into the controller (conditionally, made optional) if needed for direct database access in webhook handlers
4. Register the controller:
Add the SmsController to a module. We can add it to the main AppModule or create a dedicated SmsModule. Let's add it to AppModule for simplicity.
-
Modify
src/app.module.ts: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 { VonageModule } from './vonage/vonage.module'; import { SmsController } from './sms/sms.controller'; // Import SmsController // Import PrismaModule if using Prisma and it's set to Global // import { PrismaModule } from './prisma/prisma.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env', }), VonageModule, // PrismaModule, // Include if using Prisma (ensure it's Global or imported here) ], controllers: [ AppController, SmsController, // Add SmsController here ], providers: [AppService], // PrismaService is available globally if PrismaModule is Global and exports it }) export class AppModule {}
Example curl commands:
Send SMS:
curl -X POST http://localhost:3000/sms/send \
-H "Content-Type: application/json" \
-d '{"to": "+14155550100", "text": "Hello from NestJS!"}'Test inbound webhook (simulating Vonage):
curl -X POST http://localhost:3000/sms/inbound \
-H "Content-Type: application/json" \
-d '{"message_uuid": "abc123", "to": "14155550100", "from": "14155550200", "timestamp": "2025-01-15T10:00:00Z", "message_type": "text", "text": "Hello!", "channel": "sms"}'Configuring Vonage Dashboard Webhooks for Inbound SMS
Configure Vonage to send webhooks to your application.
1. Start your local application:
npm run start:devYour NestJS app should be running, typically on http://localhost:3000 (or the PORT specified in .env).
2. Expose your localhost using ngrok:
Vonage needs a publicly accessible URL to send webhooks. Note: ngrok, especially the free tier with changing URLs, is primarily intended for development and testing. For production deployments, you need a stable, public URL provided by your hosting platform or a static IP address.
ngrok http 3000 # Use the same port your NestJS app is running onngrok will provide a Forwarding URL (e.g., https://abcdef123456.ngrok.io). Copy the https version. This URL forwards public internet traffic to your local application.
3. Configure Vonage Application webhooks:
- Go to your Vonage API Dashboard
- Find the Application you created (or create a new one)
- Click "Edit" next to your application
- Capabilities: Ensure "Messages" is toggled ON
- Webhooks:
- Inbound URL: Enter your ngrok
httpsURL followed by the path to your inbound webhook endpoint:YOUR_NGROK_HTTPS_URL/sms/inbound(e.g.,https://abcdef123456.ngrok.io/sms/inbound) - Status URL: Enter your ngrok
httpsURL followed by the path to your status webhook endpoint:YOUR_NGROK_HTTPS_URL/sms/status(e.g.,https://abcdef123456.ngrok.io/sms/status)
- Inbound URL: Enter your ngrok
- Link Virtual Number: Ensure your Vonage virtual number (
VONAGE_NUMBER) is linked to this application at the bottom of the page. If not, link it - Save Changes
4. Set default SMS API (crucial):
Vonage has two SMS APIs. For the @vonage/server-sdk's messages.send and the webhook format we're using, you must set the Messages API as the default for your account.
- Go to your Vonage API Dashboard Settings
- Under "API settings", find the "Default SMS Setting"
- Select "Use the Messages API"
- Click "Save changes"
Environment Variables Recap:
VONAGE_APPLICATION_ID: (String) Your application's unique ID from the Vonage dashboard. Used by the SDK to identify your appVONAGE_PRIVATE_KEY_PATH: (String) The relative path from your project root to theprivate.keyfile. Used by the SDK for JWT authentication when sending messages via the Messages API. Obtain by downloading the key when creating/editing the Vonage applicationVONAGE_NUMBER: (String) The E.164 formatted Vonage virtual number linked to your application. Used as thefromnumber when sending SMS and is the number users will text to. Purchase/manage in the Numbers section of the dashboardPORT: (Number) The local port your NestJS application listens on. Must match the port used in thengrokcommand
Implementing Error Handling, Logging, and Retry Logic for SMS
Robustness comes from anticipating failures.
Error Handling:
- VonageService: The
sendSmsmethod includes atry...catchblock. It logs detailed errors usingthis.logger.error, including the error message and potentially the response data from Vonage (error?.response?.data). Note: The exact structure of theerror.response.dataobject can vary depending on the specific Vonage API error, so inspect it during testing to understand its format for different failure scenarios. Currently, it returnsnullon failure. For critical messages, consider implementing retries or queuing (see below) - SmsController:
- Uses
ValidationPipeto automatically reject requests with invalid data (e.g., missingtonumber, invalid format), returning a400 Bad Request - The
handleInboundSmsandhandleSmsStatusendpoints must respond with200 OKquickly. Any errors during the asynchronous processing of the message/status (like DB writes) should be logged and handled without causing the endpoint to return an error (e.g., 500). Otherwise, Vonage will retry the webhook unnecessarily
- Uses
- Global Exception Filter (Optional): For centralized error handling, implement a NestJS Exception Filter to catch unhandled exceptions, log them, and return standardized error responses
Logging:
- NestJS's built-in
Loggeris used. Logs provide context, timestamps, and levels - Consider redacting sensitive data (like full message text) in production logs or using appropriate log levels (
debugvs.log) - For advanced logging (structured JSON, better performance), consider
Pinovianestjs-pino
Retry Mechanisms (Vonage Webhooks):
- Vonage automatically retries webhooks with specific timeout and backoff behavior. Connection timeout: 3 seconds to establish HTTP connection. Response timeout: 15 seconds after connection is established. If no 200 OK is received within 15 seconds, Vonage retries with exponential backoff: initially every 5 seconds, then backing off to 1 minute, 5 minutes, and 15 minutes intervals for up to 24 hours. Source: Vonage Webhook Timeout & Retry Specifications
- Our immediate
200 OKresponse in handlers prevents unnecessary retries - Idempotency requirement: Ensure your webhook processing logic is idempotent (can handle the same webhook multiple times without side effects). Check if
message_uuidalready exists in the database before creating a new record to prevent duplicate entries from retried webhooks
Retry Mechanisms (Sending SMS):
- The current
sendSmsdoesn't retry. For higher reliability:- Simple Retry: Implement a loop with delays within
sendSms - Exponential Backoff: Use increasing delays between retries (libraries like
async-retrycan help) - Job Queues (Recommended): Use BullMQ, RabbitMQ, etc. On failure in
sendSms, add a job to a queue. A separate worker process handles sending, retries, and dead-lettering
- Simple Retry: Implement a loop with delays within
How to Store SMS Message History with Prisma (Optional)
Store message history using Prisma.
1. Install Prisma:
npm install prisma --save-dev
npm install @prisma/client2. Initialize Prisma:
npx prisma init --datasource-provider postgresql # Or your preferred DBUpdate DATABASE_URL in .env.
3. Define schema:
-
Edit
prisma/schema.prisma:prisma// prisma/schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" // Or your chosen provider url = env("DATABASE_URL") } model SmsMessage { id String @id @default(uuid()) messageUuid String @unique // Vonage message_uuid direction Direction // INBOUND or OUTBOUND fromNumber String toNumber String text String? // Message content (nullable if not always text) status String? // Our internal status (e.g., submitted, received, delivered, failed) vonageStatus String? // Raw status from Vonage DLR (e.g., delivered, expired) errorCode String? // Error code from Vonage DLR if failed timestamp DateTime // Time received by Vonage (inbound/status) or sent initiated (outbound) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } enum Direction { INBOUND OUTBOUND }
Frequently Asked Questions
How do I send SMS messages from a NestJS application?
Send SMS from NestJS by creating a service that uses the Vonage Node.js SDK. Initialize the Vonage client with your Application ID and Private Key, then call vonageClient.messages.send() with the recipient number, your Vonage number, and message text. The SDK returns a message_uuid for tracking.
What Node.js version does NestJS require for SMS integration?
NestJS requires Node.js version 20.x or later as of 2024. Ensure you install Node.js >=20 before beginning your SMS integration project with NestJS and Vonage.
How do Vonage webhooks work for inbound SMS?
Vonage sends HTTP POST requests to your configured webhook URL when inbound SMS messages arrive. Your NestJS endpoint must respond with 200 OK within 15 seconds (after a 3-second connection timeout). Vonage retries failed webhooks with exponential backoff for up to 24 hours.
What is the timeout for Vonage webhook responses?
Vonage allows 3 seconds to establish an HTTP connection and 15 seconds for your application to respond with 200 OK. If no response is received within 15 seconds, Vonage retries with exponential backoff: initially every 5 seconds, then 1 minute, 5 minutes, and 15 minutes intervals for up to 24 hours.
How do I test Vonage webhooks locally with ngrok?
Run ngrok http 3000 (matching your NestJS port) to expose your localhost publicly. Copy the https forwarding URL ngrok provides and configure it as your Inbound URL and Status URL in the Vonage Dashboard (e.g., https://abcdef123456.ngrok.io/sms/inbound).
What SDK version should I use for Vonage Messages API?
Use Vonage Node.js SDK v3.x (current version 3.25.1 as of September 2024). The SDK features a modular package structure and Promise-based interactions. If migrating from SDK v2.x, review the migration guide for significant architectural changes.
How do I handle SMS delivery receipts (DLRs) in NestJS?
Create a status webhook endpoint that accepts POST requests from Vonage. The endpoint receives delivery receipt payloads with status updates (delivered, failed, expired). Respond immediately with 200 OK and process the status update asynchronously to update your database or trigger workflows.
Why must I respond quickly to Vonage webhooks?
Vonage requires fast 200 OK responses (within 15 seconds) to confirm webhook receipt. Slow responses trigger unnecessary retries. Process webhook data asynchronously after responding to prevent timeout issues.
How do I make my webhook handler idempotent?
Check if the message_uuid already exists in your database before creating a new record. Since Vonage retries webhooks on timeout, idempotent handling prevents duplicate database entries from the same webhook being processed multiple times.
What environment variables does NestJS need for Vonage?
Configure four environment variables: VONAGE_APPLICATION_ID (your app's unique ID), VONAGE_PRIVATE_KEY_PATH (path to your private.key file), VONAGE_NUMBER (your E.164 formatted virtual number), and PORT (your NestJS application port, typically 3000).
How do I deploy my NestJS SMS application to production?
Replace ngrok with a stable public URL from your hosting platform (Heroku, AWS, DigitalOcean, etc.). Update Vonage webhook URLs to use your production domain. Store environment variables securely using your platform's secrets management. Enable HTTPS/TLS for webhook endpoints. Implement authentication for your send endpoint to prevent unauthorized access.
What scaling considerations exist for high-volume SMS applications?
Implement rate limiting to respect Vonage API limits. Use job queues (BullMQ, RabbitMQ) to handle send failures and retries. Consider horizontal scaling with load balancers. Monitor webhook processing time to stay within 15-second timeout. Use database connection pooling for Prisma. Implement caching for frequently accessed data.
(Note: The original text ended here. Further steps would involve creating a PrismaService, generating the client, running migrations, and integrating the service as shown optionally in previous code snippets.)