code examples

Sent logo
Sent TeamMar 8, 2026 / code examples / NestJS

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 variables
  • ngrok: Expose local development server for webhook testing
  • (Optional) Prisma: Database interactions for message logging
  • (Optional) Docker: Application containerization

System Architecture:

text
+-----------------+      +---------------------+      +-----------------+      +-----------------+
|  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
  • ngrok installed 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:

  1. Sending SMS messages via a simple API endpoint
  2. Receiving inbound SMS messages via a webhook endpoint
  3. Securely managing Vonage credentials
  4. Basic logging and error handling for SMS operations
  5. (Optional) Storing message history in a database
  6. 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):

bash
npm install -g @nestjs/cli

2. Create a new NestJS project:

bash
nest new vonage-sms-app
cd vonage-sms-app

Choose your preferred package manager (npm or yarn) when prompted.

3. Install necessary dependencies:

Install the Vonage SDK and NestJS config module:

bash
# 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 gracefully
  • 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):

    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=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:

    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 .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.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 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.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:

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.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 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.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:

bash
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):

bash
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:

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.

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 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.

  • 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 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)

Store message history using Prisma.

1. Install Prisma:

bash
npm install prisma --save-dev
npm install @prisma/client

2. Initialize Prisma:

bash
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.)