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 variables
ngrok: Expose local development server for webhook testing
(Optional) Prisma: Database interactions for message logging
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)
class-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 .env file in the project root:
Bash
touch .env
Add the following variables to your .env file. Obtain these values from your Vonage Dashboard (Applications → Your Application):
.env
# .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=3000
VONAGE_APPLICATION_ID: Found on your Vonage Application page
VONAGE_PRIVATE_KEY_PATH: The path to the private.key file you downloaded when creating the Vonage Application. Copy the private.key file 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 .env and private.key to your .gitignore file immediately to prevent accidentally committing secrets:
Load environment variables into your NestJS application using ConfigModule.
Modify src/app.module.ts:
TypeScript
// src/app.module.tsimport { Module } from'@nestjs/common';
import { ConfigModule } from'@nestjs/config'; // Import ConfigModuleimport { 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 availableenvFilePath: '.env', // Specify the env file path
}),
VonageModule, // Import our upcoming Vonage module// PrismaModule, // Include if using Prisma
],
controllers: [AppController],
providers: [AppService],
})
exportclassAppModule {}
ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env' }): Initializes the configuration module, makes it available application-wide, and loads variables from the .env file
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:
Bash
nest generate module vonage
nest generate service vonage --no-spec # We'll add tests later
This 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.tsimport { 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 moduleimport { PrismaService } from'../prisma/prisma.service'; // Import if using Prisma@Injectable()
exportclassVonageServiceimplementsOnModuleInit {
privatereadonly logger = newLogger(VonageService.name);
privatevonageClient: Vonage;
privatevonageNumber: string;
constructor(privateconfigService: ConfigService,
// Inject PrismaService if you are using the optional database logging (Section 6)// Make sure PrismaModule is imported in AppModule and PrismaService is exported/globalprivatereadonlyprismaService?: 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.');
thrownewError('Vonage credentials missing.');
}
try {
// Read the private key file contentconst privateKey = fs.readFileSync(privateKeyPath);
this.vonageClient = newVonage({
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
}
}
asyncsendSms(to: string, text: string): Promise<string | null> {
constmessageRequest: MessageSendRequest = {
message_type: 'text',
to: to,
from: this.vonageNumber,
channel: 'sms',
text: text,
};
letmessageUuid: string | null = null;
try {
const response = awaitthis.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.prismaServiceif (messageUuid && this.prismaService) {
this.prismaService.smsMessage.create({
data: {
messageUuid: messageUuid,
direction: 'OUTBOUND',
fromNumber: this.vonageNumber,
toNumber: to,
text: text,
timestamp: newDate(), // Time sent initiatedstatus: 'submitted', // Initial status
},
}).catch(err =>this.logger.error('Failed to save outbound message to DB', err?.stack || err));
}
// End Optional DB Logreturn 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.returnnull;
}
}
// 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 loads
ConfigService: Injected to retrieve environment variables securely
PrismaService: 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 by VONAGE_PRIVATE_KEY_PATH. The SDK expects the key content, not the file path directly
sendSms Method:
Constructs the MessageSendRequest object required by the SDK
Uses this.vonageClient.messages.send() to dispatch the SMS
Logs success or failure, returning the message_uuid on success or null on failure
Includes optional Prisma logging logic (checks if this.prismaService exists before using)
3. Update VonageModule:
Make the VonageService available for injection elsewhere in the application.
Edit src/vonage/vonage.module.ts:
TypeScript
// src/vonage/vonage.module.tsimport { 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 itproviders: [VonageService],
exports: [VonageService], // Export VonageService so other modules can use it
})
exportclassVonageModule {}
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:
Bash
nest generate controller sms --no-spec
This 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/dto directory if it doesn't exist
Create src/sms/dto/send-sms.dto.ts:
TypeScript
// src/sms/dto/send-sms.dto.tsimport { IsNotEmpty, IsString, IsPhoneNumber } from'class-validator';
exportclassSendSmsDto {
@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.tsimport { 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-formatenumMessageType {
TEXT = 'text',
IMAGE = 'image',
AUDIO = 'audio',
VIDEO = 'video',
FILE = 'file',
VCARD = 'vcard',
LOCATION = 'location',
TEMPLATE = 'template',
CUSTOM = 'custom',
UNSUPPORTED = 'unsupported', // Added for robustness
}
classUsageDto {
@IsOptional()
@IsString()
currency?: string;
@IsOptional()
@IsString() // Price might be a string representationprice?: string;
}
classSmsInfoDto {
@IsOptional()
@IsString()
num_messages?: string; // Often comes as a string
}
exportclassInboundSmsDto {
@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.tsimport { 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-formatexportenumMessageStatus {
SUBMITTED = 'submitted',
DELIVERED = 'delivered',
EXPIRED = 'expired',
FAILED = 'failed',
REJECTED = 'rejected',
ACCEPTED = 'accepted', // intermediate stateBUFFERED = 'buffered', // intermediate stateUNKNOWN = 'unknown', // default/fallbackREAD = 'read', // For channels supporting read receiptsUNDELIVERABLE = 'undeliverable', // Common failure reason
}
exportclassSmsStatusDto {
@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 stringerror_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.tsimport { 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 DTOimport { PrismaService } from'../prisma/prisma.service'; // Import if using Prisma@Controller('sms')
exportclassSmsController {
privatereadonly logger = newLogger(SmsController.name);
constructor(privatereadonlyvonageService: VonageService,
// Inject PrismaService if needed for direct DB access in controller// Make sure PrismaModule is imported in AppModule and PrismaService is exported/globalprivatereadonlyprismaService?: PrismaService, // Make optional if Prisma is optional) {}
/**
* Endpoint to trigger sending an SMS.
* POST /sms/send
*/@Post('send')
@UsePipes(newValidationPipe({ transform: true, whitelist: true }))
@HttpCode(HttpStatus.OK) // Return 200 OK on successasyncsendSms(@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 availableconst messageId = awaitthis.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(newValidationPipe({ transform: true, whitelist: true })) // Validate incoming payloadhandleInboundSms(@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 missingtimestamp: newDate(inboundSmsDto.timestamp),
status: 'received', // Initial status for inboundvonageStatus: '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(newValidationPipe({ transform: true, whitelist: true })) // Validate DLR payloadhandleSmsStatus(@Body() statusPayload: SmsStatusDto): void {
// Respond quicklythis.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 valuevonageStatus: statusPayload.status, // Store raw status tooerrorCode: statusPayload.error_code, // If present// You might want to parse statusPayload.timestamp here tooupdatedAt: newDate(),
},
}).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 safety
ValidationPipe: Applied to all endpoints receiving data (send, inbound, status) to enforce DTO rules
handleInboundSms & handleSmsStatus: Respond immediately with 200 OK. Database operations (if using Prisma) are performed asynchronously (.catch() handles errors without blocking the response). Checks if this.prismaService exists
PrismaService: 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.tsimport { 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
})
exportclassAppModule {}
Example curl commands:
Send SMS:
Bash
curl -X POST http://localhost:3000/sms/send \
-H "Content-Type: application/json" \
-d '{"to": "+14155550100", "text": "Hello from NestJS!"}'
Configuring Vonage Dashboard Webhooks for Inbound SMS
Configure Vonage to send webhooks to your application.
1. Start your local application:
Bash
npm run start:dev
Your 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.
Bash
ngrok http 3000 # Use the same port your NestJS app is running on
ngrok 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.
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 https URL 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 https URL followed by the path to your status webhook endpoint:
YOUR_NGROK_HTTPS_URL/sms/status
(e.g., https://abcdef123456.ngrok.io/sms/status)
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.
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 app
VONAGE_PRIVATE_KEY_PATH: (String) The relative path from your project root to the private.key file. Used by the SDK for JWT authentication when sending messages via the Messages API. Obtain by downloading the key when creating/editing the Vonage application
VONAGE_NUMBER: (String) The E.164 formatted Vonage virtual number linked to your application. Used as the from number when sending SMS and is the number users will text to. Purchase/manage in the Numbers section of the dashboard
PORT: (Number) The local port your NestJS application listens on. Must match the port used in the ngrok command
Implementing Error Handling, Logging, and Retry Logic for SMS
Robustness comes from anticipating failures.
Error Handling:
VonageService: The sendSms method includes a try...catch block. It logs detailed errors using this.logger.error, including the error message and potentially the response data from Vonage (error?.response?.data). Note: The exact structure of the error.response.data object can vary depending on the specific Vonage API error, so inspect it during testing to understand its format for different failure scenarios. Currently, it returns null on failure. For critical messages, consider implementing retries or queuing (see below)
SmsController:
Uses ValidationPipe to automatically reject requests with invalid data (e.g., missing to number, invalid format), returning a 400 Bad Request
The handleInboundSms and handleSmsStatus endpoints must respond with 200 OK quickly. 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
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 Logger is used. Logs provide context, timestamps, and levels
Consider redacting sensitive data (like full message text) in production logs or using appropriate log levels (debug vs. log)
For advanced logging (structured JSON, better performance), consider Pino via nestjs-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 OK response 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_uuid already exists in the database before creating a new record to prevent duplicate entries from retried webhooks
Retry Mechanisms (Sending SMS):
The current sendSms doesn't retry. For higher reliability:
Simple Retry: Implement a loop with delays within sendSms
Exponential Backoff: Use increasing delays between retries (libraries like async-retry can 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
How to Store SMS Message History with Prisma (Optional)
npx prisma init --datasource-provider postgresql # Or your preferred DB
Update 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.)
Frequently asked questions
Use the VonageService.sendSms method, providing the recipient's number and message text. This method interacts with the Vonage Messages API using the @vonage/server-sdk to dispatch SMS messages reliably. You'll need to configure your Vonage application and set up necessary credentials within your project's .env file first, as described in the article's setup section.
The Vonage Messages API is a programmatic interface for sending and receiving SMS messages, as well as other communication channels. It provides tools to manage message delivery, receive inbound messages via webhooks, and track message status. This article uses the Messages API for building a robust two-way SMS system.
NestJS provides a structured, scalable, and maintainable architecture for server-side applications using Node.js. Its modularity and dependency injection features simplify development and testing. The article leverages these features for efficient organization and management of the SMS functionality.
ngrok is useful during local development to create a temporary, public URL that forwards requests to your localhost. This enables Vonage to send webhooks to your local development server for testing. However, for production, a stable public URL from your hosting provider is required.
Yes, the article provides an optional section using Prisma, an ORM, for database integration. It defines a database schema for storing message details, like sender, recipient, timestamp, message content, and status. The VonageService and SmsController include snippets to log outbound, inbound, and status updates to the database if Prisma is enabled and configured correctly.
Set up a webhook endpoint (e.g., /sms/inbound) in your NestJS application using SmsController. Then, configure your Vonage application to send inbound SMS webhooks to this URL via the Vonage Dashboard. Your endpoint should parse the incoming webhook data using an InboundSmsDto and process it asynchronously, responding immediately with 200 OK to acknowledge receipt.
The private.key file contains your Vonage application's private key, crucial for authenticating with the Vonage Messages API. Keep this file secure and never commit it to version control. Store the path to the private.key in an environment variable (VONAGE_PRIVATE_KEY_PATH) and ensure you load its contents during client initialization in the Vonage service.
Create a dedicated webhook endpoint (e.g., /sms/status) and configure your Vonage application to forward delivery receipts there. Use a DTO like SmsStatusDto to validate the incoming DLR payload. The handler should respond with a 200 OK promptly, then process the status asynchronously (e.g., update message status in a database).
You'll need Node.js, npm/yarn, a Vonage API account (with an application and a linked virtual number), ngrok for local testing, and a basic understanding of NestJS and TypeScript. Optionally, install Prisma and Docker for database and containerization.
Create a .env file in your project root and add your VONAGE_APPLICATION_ID, VONAGE_PRIVATE_KEY_PATH, and VONAGE_NUMBER. Use NestJS's ConfigModule to load these environment variables securely into your application. Never commit the .env file or your private.key to version control.
Two-way SMS refers to the ability of the application to both send outbound SMS messages to users and receive inbound SMS messages from users. This allows for interactive communication, such as notifications, alerts, verifications, and conversational experiences.
DTOs (Data Transfer Objects) define the structure of incoming request data. Using class-validator with DTOs enforces data validation rules, improving security and preventing errors caused by invalid or malicious input.
Use ngrok to create a public tunnel to your locally running NestJS server. Run ngrok http <your_port>, where <your_port> is the port your server is on. Use the HTTPS URL provided by ngrok as your webhook URL in the Vonage Dashboard. Remember, ngrok is for development/testing; use a proper public URL in production.
For more robust SMS sending, consider adding retry mechanisms such as a simple retry loop, exponential backoff, or using a dedicated message queue (e.g., BullMQ, RabbitMQ) to handle retries and error management asynchronously.
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:
@vonage/server-sdk: Official Vonage Node.js SDK@nestjs/config: Manage environment variablesngrok: Expose local development server for webhook testingSystem Architecture:
Prerequisites:
ngrokinstalled and authenticated (Download here)Final Outcome:
By the end of this guide, you will have a NestJS application capable of:
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):
2. 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
.envfile for local development.Create a
.envfile in the project root:touch .envAdd the following variables to your
.envfile. Obtain these values from your Vonage Dashboard (Applications → Your Application):VONAGE_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 onImportant security note: Add
.envandprivate.keyto your.gitignorefile immediately to prevent accidentally committing secrets:5. Integrate ConfigModule:
Load environment variables into your NestJS application using
ConfigModule.Modify
src/app.module.ts:// 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.envfileProject 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/configfor 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/vonagedirectory withvonage.module.tsandvonage.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
PrismaServiceis injected here.Edit
src/vonage/vonage.service.ts:// 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 constructorfs.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:MessageSendRequestobject required by the SDKthis.vonageClient.messages.send()to dispatch the SMSmessage_uuidon success ornullon failurethis.prismaServiceexists before using)3. Update VonageModule:
Make the
VonageServiceavailable for injection elsewhere in the application.Edit
src/vonage/vonage.module.ts:// 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
VonageModulecan injectVonageService.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:
This 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 existCreate
src/sms/dto/send-sms.dto.ts:// 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:// 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:// 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:// 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 handlers4. Register the controller:
Add the
SmsControllerto a module. We can add it to the mainAppModuleor create a dedicatedSmsModule. Let's add it toAppModulefor simplicity.Modify
src/app.module.ts:// 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:
Your NestJS app should be running, typically on
http://localhost:3000(or thePORTspecified 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
ForwardingURL (e.g.,https://abcdef123456.ngrok.io). Copy thehttpsversion. This URL forwards public internet traffic to your local application.3. Configure Vonage Application webhooks:
httpsURL followed by the path to your inbound webhook endpoint:YOUR_NGROK_HTTPS_URL/sms/inbound(e.g.,https://abcdef123456.ngrok.io/sms/inbound)httpsURL followed by the path to your status webhook endpoint:YOUR_NGROK_HTTPS_URL/sms/status(e.g.,https://abcdef123456.ngrok.io/sms/status)VONAGE_NUMBER) is linked to this application at the bottom of the page. If not, link it4. Set default SMS API (crucial):
Vonage has two SMS APIs. For the
@vonage/server-sdk'smessages.sendand the webhook format we're using, you must set the Messages API as the default for your account.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 thengrokcommandImplementing Error Handling, Logging, and Retry Logic for SMS
Robustness comes from anticipating failures.
Error Handling:
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)ValidationPipeto automatically reject requests with invalid data (e.g., missingtonumber, invalid format), returning a400 Bad RequesthandleInboundSmsandhandleSmsStatusendpoints 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 unnecessarilyLogging:
Loggeris used. Logs provide context, timestamps, and levelsdebugvs.log)Pinovianestjs-pinoRetry Mechanisms (Vonage Webhooks):
200 OKresponse in handlers prevents unnecessary retriesmessage_uuidalready exists in the database before creating a new record to prevent duplicate entries from retried webhooksRetry Mechanisms (Sending SMS):
sendSmsdoesn't retry. For higher reliability:sendSmsasync-retrycan help)sendSms, add a job to a queue. A separate worker process handles sending, retries, and dead-letteringHow to Store SMS Message History with Prisma (Optional)
Store message history using Prisma.
1. Install Prisma:
2. Initialize Prisma:
npx prisma init --datasource-provider postgresql # Or your preferred DBUpdate
DATABASE_URLin.env.3. Define schema:
Edit
prisma/schema.prisma: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 amessage_uuidfor 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 thehttpsforwarding 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_uuidalready 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), andPORT(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.)