code examples

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

NestJS SMS Marketing: Build Bulk SMS Campaigns with Vonage API (2025 Guide)

Complete step-by-step tutorial: Build production-ready SMS marketing campaigns using NestJS 11, TypeORM, PostgreSQL, and Vonage Messages API. Learn bulk messaging, webhook handling, 10DLC compliance, rate limiting, and deployment best practices.

Build Production-Ready SMS Marketing Campaigns with NestJS and Vonage

Learn how to build a production-ready SMS marketing campaign application using NestJS 11, TypeScript, TypeORM, PostgreSQL, and the Vonage Messages API. This comprehensive tutorial covers everything from project setup and Vonage API integration to bulk messaging, webhook handling, 10DLC compliance, security best practices, and deployment strategies for scalable SMS campaigns.

By the end of this tutorial, you'll have a functional NestJS SMS marketing application capable of:

  • Managing marketing campaigns and subscriber lists
  • Sending bulk SMS messages via the Vonage Messages API
  • Receiving inbound SMS messages (e.g., STOP replies)
  • Tracking message delivery statuses
  • Implementing security, error handling, and logging

This guide serves as a complete reference, enabling you to build and deploy a production-ready SMS marketing system following industry best practices.

<!-- GAP: Missing estimated time to complete tutorial (Type: Substantive, Priority: Medium) --> <!-- EXPAND: Add prerequisites difficulty level indicator (Type: Enhancement, Priority: Low) -->

Prerequisites for Building SMS Marketing with NestJS

Before starting this SMS marketing tutorial, ensure you have:

  • Node.js and npm/yarn: Install Node.js v22 LTS or later. As of 2025, Node.js v22 (codename "Jod") is the current LTS version, actively supported until October 2025 (Active LTS), then Maintenance LTS until April 2027. Node.js v20 (Iron) is also supported until April 2026. Download from nodejs.org.
  • NestJS CLI: Install globally: npm install -g @nestjs/cli. The current stable version is NestJS 11 (v11.1.6 as of early 2025), which includes improved logging, cache-manager v6 support, and new features like ParseDatePipe. See NestJS Documentation.
  • Docker and Docker Compose: For running PostgreSQL locally and containerizing the application. We recommend PostgreSQL 18 (released September 2025) using the Alpine variant for smaller images (e.g., postgres:18-alpine). See PostgreSQL Documentation and Docker Hub – PostgreSQL.
  • Vonage API Account: Sign up for free at vonage.com. You'll need your Application ID and Private Key. Note: For US SMS, you must register a 10DLC Brand & Campaign to comply with carrier requirements. See Vonage 10DLC Documentation.
  • Vonage Phone Number: Purchase an SMS-capable number from your Vonage dashboard.
  • ngrok: Useful for testing webhooks locally. Sign up for a free account at ngrok.com and install it. Note: For production environments, use a stable, publicly accessible URL for your webhook endpoints. This could be your deployed application's URL on a server, PaaS, or dedicated tunneling service with a stable address. Use a staging environment that mirrors production for testing before deploying live.

1. How to Set Up Your NestJS SMS Marketing Project

Initialize your NestJS project and install the necessary dependencies for building an SMS marketing application with Vonage.

<!-- DEPTH: Section lacks explanation of package version compatibility issues (Type: Substantive, Priority: Medium) -->
  1. Create a New NestJS Project: Open your terminal and run:

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

    Choose your preferred package manager (npm or yarn).

  2. Install Dependencies: Install modules for configuration, database interaction (TypeORM with PostgreSQL), validation, the Vonage SDK, rate limiting, and health checks.

    bash
    # Core NestJS & Utility Modules
    npm install @nestjs/config @nestjs/typeorm typeorm pg class-validator class-transformer dotenv
    
    # Vonage SDK – Latest versions as of 2025
    npm install @vonage/server-sdk@^3.24.1 @vonage/messages@^1.20.3
    
    # Optional but Recommended for Production
    npm install @nestjs/terminus nestjs-rate-limiter helmet # Health checks, rate limiting, security headers
    npm install --save-dev @types/helmet # Types for helmet
    • @nestjs/config: Manages environment variables.
    • @nestjs/typeorm (v11.0.0 compatible with NestJS 11), typeorm, pg: For PostgreSQL database integration using TypeORM.
    • class-validator, class-transformer: For request data validation using DTOs.
    • dotenv: Loads environment variables from a .env file.
    • @vonage/server-sdk (v3.24.1): The official Vonage Node.js SDK. Supports Messages API, SMS API, Voice API, and more. See Vonage Node SDK Documentation.
    • @vonage/messages (v1.20.3): Standalone Messages API package supporting WhatsApp, Facebook, Viber, SMS, MMS, and RCS channels. See npm @vonage/messages.
    • @nestjs/terminus: Implements health check endpoints.
    • nestjs-rate-limiter: Adds rate limiting to protect endpoints.
    • helmet: Sets various HTTP headers for security.
<!-- GAP: Missing troubleshooting section for common dependency installation errors (Type: Substantive, Priority: High) -->
  1. Project Structure (Conceptual): NestJS promotes a modular structure. We'll organize our code logically:
    text
    vonage-sms-campaign-app/
    ├── src/
    │   ├── app.module.ts         # Root module
    │   ├── main.ts               # Application entry point
    │   ├── config/               # Configuration setup (env vars)
    │   ├── database/             # Database connection, entities, migrations
    │   ├── campaigns/            # Campaign management module (controller, service, entity)
    │   ├── subscribers/          # Subscriber management module (controller, service, entity)
    │   ├── vonage/               # Vonage integration module (service, config)
    │   ├── webhooks/             # Webhook handling module (controller, service)
    │   ├── common/               # Shared utilities, filters, interceptors
    │   └── health/               # Health check module
    ├── .env                      # Environment variables (DO NOT COMMIT SENSITIVE DATA)
    ├── .env.example              # Example environment variables
    ├── ormconfig.json            # TypeORM configuration (optional, can be in AppModule)
    ├── Dockerfile
    ├── docker-compose.yml
    └── ... (other NestJS files)

2. How to Configure Vonage API for SMS Marketing

<!-- GAP: Missing troubleshooting steps for common configuration errors (Type: Substantive, Priority: High) --> <!-- DEPTH: Section lacks explanation of webhook format differences between SMS API and Messages API (Type: Critical, Priority: High) -->

Before writing code, configure your Vonage account and Messages API application correctly. Proper configuration is essential for SMS authentication, webhook delivery, and 10DLC compliance for US messaging.

  1. Access Vonage Dashboard: Log in to your Vonage API Dashboard.
  2. API Settings (Crucial):
    • Navigate to "API settings" in the left-hand menu.
    • Scroll down to the "SMS settings" section.
    • Under "Default SMS Setting," ensure "Messages API" is selected. This determines the webhook format and authentication method you'll use. Save changes if necessary.
  3. Create a Vonage Application:
    • Go to "Applications" → "Create a new application."
    • Give it a name (e.g., "NestJS SMS Campaigns").
    • Enable the "Messages" capability.
    • You'll need webhook URLs. For now, enter temporary placeholders like http://example.com/webhooks/inbound for the Inbound URL and http://example.com/webhooks/status for the Status URL. Update these later with your ngrok URL or public deployment URL.
    • Click "Generate public and private key." Save the private.key file securely within your project directory (e.g., at the root). Do not commit this file to Git. Add private.key to your .gitignore file.
    • Note down the Application ID displayed after creation.
  4. Link Your Vonage Number:
    • Go to "Numbers" → "Your numbers."
    • Find the SMS-capable number you purchased.
    • Click "Manage" or "Link" next to the number.
    • Select the application you just created ("NestJS SMS Campaigns") from the dropdown and save. This directs incoming messages for this number to your application's webhooks.
<!-- EXPAND: Add visual diagram showing Vonage configuration flow (Type: Enhancement, Priority: Medium) --> <!-- GAP: Missing security checklist for production deployment (Type: Critical, Priority: High) -->

3. How to Configure Environment Variables for Vonage SMS

Securely manage Vonage API keys and configuration settings using environment variables in your NestJS application.

<!-- DEPTH: Lacks guidance on managing secrets in production environments (Type: Critical, Priority: High) -->
  1. Create .env and .env.example files: In the project root, create .env (for your local values) and .env.example (as a template). Add .env to your .gitignore.

    File: .env.example

    dotenv
    # Application
    PORT=3000
    NODE_ENV=development
    
    # Vonage Configuration
    VONAGE_APPLICATION_ID=YOUR_VONAGE_APPLICATION_ID
    VONAGE_PRIVATE_KEY_PATH=./private.key # Relative path to your downloaded key
    VONAGE_NUMBER=YOUR_VONAGE_VIRTUAL_NUMBER # e.g., 14155550100
    VONAGE_SIGNATURE_SECRET=YOUR_SIGNATURE_SECRET # From Vonage Dashboard API Settings
    
    # Database (PostgreSQL)
    DATABASE_HOST=localhost
    DATABASE_PORT=5432
    DATABASE_USERNAME=your_db_user
    DATABASE_PASSWORD=your_db_password
    DATABASE_NAME=sms_campaigns_db
    
    # ngrok URL (update when running ngrok for local testing) or Public URL
    BASE_URL=http://localhost:3000

    File: .env

    dotenv
    # Fill with your actual values
    PORT=3000
    NODE_ENV=development
    
    VONAGE_APPLICATION_ID=abc123xyz…
    VONAGE_PRIVATE_KEY_PATH=./private.key
    VONAGE_NUMBER=14155550100
    VONAGE_SIGNATURE_SECRET=your_actual_secret
    
    DATABASE_HOST=localhost
    DATABASE_PORT=5432
    DATABASE_USERNAME=postgres # Default for docker-compose setup later
    DATABASE_PASSWORD=secret
    DATABASE_NAME=sms_campaigns_db
    
    BASE_URL=http://localhost:3000 # Will be replaced by ngrok URL during development or public URL in deployment
  2. Integrate ConfigModule: Configure the ConfigModule in your root AppModule to load these variables globally.

    File: src/app.module.ts

    typescript
    import { Module } from '@nestjs/common';
    import { ConfigModule } from '@nestjs/config';
    import { TypeOrmModule } from '@nestjs/typeorm'; // Import later
    // Other module imports...
    
    @Module({
      imports: [
        ConfigModule.forRoot({
          isGlobal: true, // Make ConfigService available globally
          envFilePath: '.env', // Specify the env file
        }),
        // TypeOrmModule will be added here later
        // Other modules...
      ],
      controllers: [],
      providers: [],
    })
    export class AppModule {}
<!-- GAP: Missing environment variable validation implementation (Type: Substantive, Priority: High) -->

4. Database Schema for SMS Marketing Campaigns (TypeORM & PostgreSQL)

Design a robust database schema using TypeORM to manage SMS marketing campaigns, subscribers, and message delivery tracking with PostgreSQL.

<!-- EXPAND: Add database indexing strategy explanation (Type: Enhancement, Priority: Medium) --> <!-- DEPTH: Lacks migration strategy guidance for production (Type: Critical, Priority: High) -->
  1. Docker Compose for PostgreSQL: Create a docker-compose.yml file in the project root for easy local database setup. Use PostgreSQL 18 Alpine for a smaller image footprint.

    File: docker-compose.yml

    yaml
    version: '3.8'
    services:
      postgres:
        image: postgres:18-alpine # Updated to PostgreSQL 18 (latest stable as of Sept 2025)
        container_name: vonage_sms_postgres
        environment:
          POSTGRES_USER: ${DATABASE_USERNAME:-postgres} # Use .env or default
          POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-secret}
          POSTGRES_DB: ${DATABASE_NAME:-sms_campaigns_db}
        ports:
          - "${DATABASE_PORT:-5432}:5432"
        volumes:
          - postgres_data:/var/lib/postgresql/data
        restart: unless-stopped
    
    volumes:
      postgres_data:
        driver: local

    Run docker-compose up -d to start the PostgreSQL container.

<!-- GAP: Missing database backup and recovery strategy (Type: Critical, Priority: Medium) -->
  1. Define Entities: Create entity files for Campaign, Subscriber, and MessageLog.

    File: src/database/entities/subscriber.entity.ts

    typescript
    import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm';
    
    export enum SubscriptionStatus {
      SUBSCRIBED = 'subscribed',
      UNSUBSCRIBED = 'unsubscribed',
    }
    
    @Entity('subscribers')
    export class Subscriber {
      @PrimaryGeneratedColumn('uuid')
      id: string;
    
      @Index({ unique: true })
      @Column({ type: 'varchar', length: 20 })
      phoneNumber: string; // E.164 format recommended
    
      @Column({ type: 'varchar', length: 100, nullable: true })
      firstName?: string;
    
      @Column({ type: 'varchar', length: 100, nullable: true })
      lastName?: string;
    
      @Column({
        type: 'enum',
        enum: SubscriptionStatus,
        default: SubscriptionStatus.SUBSCRIBED,
      })
      status: SubscriptionStatus;
    
      @CreateDateColumn()
      subscribedAt: Date;
    
      @UpdateDateColumn()
      updatedAt: Date;
    
      @Column({ type: 'timestamp', nullable: true })
      unsubscribedAt?: Date;
    }

    File: src/database/entities/campaign.entity.ts

    typescript
    import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, OneToMany } from 'typeorm';
    import { MessageLog } from './message-log.entity';
    
    export enum CampaignStatus {
      DRAFT = 'draft',
      SCHEDULED = 'scheduled',
      SENDING = 'sending',
      SENT = 'sent',
      FAILED = 'failed',
    }
    
    @Entity('campaigns')
    export class Campaign {
      @PrimaryGeneratedColumn('uuid')
      id: string;
    
      @Column({ type: 'varchar', length: 255 })
      name: string;
    
      @Column({ type: 'text' })
      messageTemplate: string;
    
      @Column({
        type: 'enum',
        enum: CampaignStatus,
        default: CampaignStatus.DRAFT,
      })
      status: CampaignStatus;
    
      @CreateDateColumn()
      createdAt: Date;
    
      @Column({ type: 'timestamp', nullable: true })
      scheduledAt?: Date;
    
      @Column({ type: 'timestamp', nullable: true })
      sentAt?: Date;
    
      // Relation: A campaign can have many message logs
      @OneToMany(() => MessageLog, (log) => log.campaign)
      messageLogs: MessageLog[];
    }

    File: src/database/entities/message-log.entity.ts

    typescript
    import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, Index } from 'typeorm';
    import { Campaign } from './campaign.entity';
    import { Subscriber } from './subscriber.entity';
    
    export enum MessageStatus {
      PENDING = 'pending', // Initial state before sending attempt
      SUBMITTED = 'submitted', // Successfully submitted to Vonage
      DELIVERED = 'delivered', // Confirmed delivery
      EXPIRED = 'expired', // Message expired before delivery
      FAILED = 'failed', // Delivery failed
      REJECTED = 'rejected', // Rejected by carrier or Vonage
      UNKNOWN = 'unknown', // Status unknown or not yet received
    }
    
    @Entity('message_logs')
    @Index(['vonageMessageId']) // Index for quick lookup via webhook
    export class MessageLog {
      @PrimaryGeneratedColumn('uuid')
      id: string;
    
      @Column({ type: 'varchar', length: 50, nullable: true }) // Vonage Message UUID
      vonageMessageId?: string;
    
      @ManyToOne(() => Campaign, (campaign) => campaign.messageLogs, { onDelete: 'CASCADE' })
      campaign: Campaign;
    
      @ManyToOne(() => Subscriber, { eager: true, onDelete: 'CASCADE' }) // Eager load subscriber for easy access
      subscriber: Subscriber;
    
      @Column({
        type: 'enum',
        enum: MessageStatus,
        default: MessageStatus.PENDING,
      })
      status: MessageStatus;
    
      @Column({ type: 'text', nullable: true })
      errorMessage?: string; // Store error details from Vonage
    
      @CreateDateColumn()
      createdAt: Date; // When we created the log/attempted send
    
      @UpdateDateColumn()
      updatedAt: Date; // When the status was last updated (e.g., via webhook)
    
      @Column({ type: 'timestamp', nullable: true })
      submittedAt?: Date; // Timestamp from Vonage status webhook
    
      @Column({ type: 'timestamp', nullable: true })
      deliveredAt?: Date; // Timestamp from Vonage status webhook
    }
<!-- EXPAND: Add entity relationship diagram (Type: Enhancement, Priority: High) -->
  1. Configure TypeORM Connection: Update AppModule to configure the database connection using ConfigService.

    File: src/app.module.ts

    typescript
    import { Module } from '@nestjs/common';
    import { ConfigModule, ConfigService } from '@nestjs/config';
    import { TypeOrmModule } from '@nestjs/typeorm';
    import { Subscriber } from './database/entities/subscriber.entity';
    import { Campaign } from './database/entities/campaign.entity';
    import { MessageLog } from './database/entities/message-log.entity';
    // Other imports...
    // Import your feature modules here later
    import { CampaignsModule } from './campaigns/campaigns.module';
    import { SubscribersModule } from './subscribers/subscribers.module';
    import { VonageModule } from './vonage/vonage.module';
    import { WebhooksModule } from './webhooks/webhooks.module';
    import { HealthModule } from './health/health.module';
    import { RateLimiterModule } from 'nestjs-rate-limiter'; // Import RateLimiterModule if used globally
    
    @Module({
      imports: [
        ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env' }),
        TypeOrmModule.forRootAsync({
          imports: [ConfigModule],
          useFactory: (configService: ConfigService) => ({
            type: 'postgres',
            host: configService.get<string>('DATABASE_HOST'),
            port: configService.get<number>('DATABASE_PORT'),
            username: configService.get<string>('DATABASE_USERNAME'),
            password: configService.get<string>('DATABASE_PASSWORD'),
            database: configService.get<string>('DATABASE_NAME'),
            entities: [Subscriber, Campaign, MessageLog],
            synchronize: configService.get<string>('NODE_ENV') !== 'production', // Auto-create schema in dev, use migrations in prod
            logging: configService.get<string>('NODE_ENV') !== 'production',
            // migrations: [__dirname + '/database/migrations/*{.ts,.js}'], // Configure for production
            // cli: { migrationsDir: 'src/database/migrations' }, // Configure for production
          }),
          inject: [ConfigService],
        }),
        // Feature Modules
        CampaignsModule,
        SubscribersModule,
        VonageModule, // Ensure VonageModule is Global or imported where needed
        WebhooksModule,
        HealthModule,
        // RateLimiterModule // Register RateLimiterModule if using it globally
      ],
      // ... controllers/providers if any at root level
    })
    export class AppModule {}

    Note: For production, set synchronize: false and use TypeORM migrations. Refer to the NestJS TypeORM documentation for migration setup. For this guide, synchronize: true simplifies local development.

  2. Create Modules for Entities: Generate modules and services for Campaigns and Subscribers.

    bash
    nest g module campaigns
    nest g service campaigns
    nest g controller campaigns
    
    nest g module subscribers
    nest g service subscribers
    nest g controller subscribers

    Import TypeOrmModule.forFeature([...]) into CampaignsModule and SubscribersModule to inject repositories.

    File: src/campaigns/campaigns.module.ts

    typescript
    import { Module } from '@nestjs/common';
    import { TypeOrmModule } from '@nestjs/typeorm';
    import { CampaignsService } from './campaigns.service';
    import { CampaignsController } from './campaigns.controller';
    import { Campaign } from '../database/entities/campaign.entity';
    import { MessageLog } from '../database/entities/message-log.entity';
    import { Subscriber } from '../database/entities/subscriber.entity'; // Import Subscriber if needed by service
    // VonageModule is Global, so no need to import here unless it wasn't global
    
    @Module({
      imports: [
        TypeOrmModule.forFeature([Campaign, MessageLog, Subscriber]), // Add Subscriber repo if needed
      ],
      controllers: [CampaignsController],
      providers: [CampaignsService],
      exports: [CampaignsService], // Export if needed by other modules
    })
    export class CampaignsModule {}

    File: src/subscribers/subscribers.module.ts

    typescript
    import { Module } from '@nestjs/common';
    import { TypeOrmModule } from '@nestjs/typeorm';
    import { SubscribersService } from './subscribers.service';
    import { SubscribersController } from './subscribers.controller';
    import { Subscriber } from '../database/entities/subscriber.entity';
    
    @Module({
      imports: [TypeOrmModule.forFeature([Subscriber])],
      controllers: [SubscribersController],
      providers: [SubscribersService],
      exports: [SubscribersService], // Export if needed by other modules
    })
    export class SubscribersModule {}

5. How to Integrate Vonage Messages API with NestJS

Integrate the Vonage Server SDK and implement bulk SMS messaging logic for marketing campaigns using the Messages API.

<!-- GAP: Missing error handling patterns and retry logic (Type: Critical, Priority: High) --> <!-- DEPTH: Lacks explanation of webhook payload structure (Type: Substantive, Priority: High) -->
  1. Create Vonage Module & Service: Encapsulate Vonage SDK initialization and interaction.

    bash
    nest g module vonage
    nest g service vonage

    File: src/vonage/vonage.service.ts

    typescript
    import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
    import { ConfigService } from '@nestjs/config';
    import { Vonage } from '@vonage/server-sdk';
    import { MessageSendRequest } from '@vonage/messages'; // Import specific types
    import * as fs from 'fs'; // Import fs to read the private key
    import * as path from 'path'; // Import path for resolving paths
    
    @Injectable()
    export class VonageService implements OnModuleInit {
      private readonly logger = new Logger(VonageService.name);
      private vonageClient: Vonage;
      private vonageNumber: string;
    
      constructor(private configService: ConfigService) {}
    
      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 not configured correctly in .env');
          throw new Error('Vonage credentials missing or invalid.');
        }
    
        try {
          // Resolve path relative to the project root (process.cwd())
          const resolvedPath = path.resolve(process.cwd(), privateKeyPath);
          if (!fs.existsSync(resolvedPath)) {
            throw new Error(`Private key file not found at: ${resolvedPath}`);
          }
          const privateKey = fs.readFileSync(resolvedPath); // Read the key file
    
          this.vonageClient = new Vonage({
            applicationId: applicationId,
            privateKey: privateKey, // Pass the key content
          });
          this.logger.log('Vonage client initialized successfully.');
        } catch (error) {
          this.logger.error(`Failed to initialize Vonage client: ${error.message}`, error.stack);
          throw error; // Prevent application startup if Vonage SDK fails
        }
      }
    
      async sendSms(to: string, text: string): Promise<{ success: boolean; messageId?: string; error?: any }> {
        if (!this.vonageClient) {
            this.logger.error('Vonage client not initialized.');
            return { success: false, error: 'Vonage client not initialized.' };
        }
    
        const messageRequest: MessageSendRequest = {
          message_type: 'text',
          channel: 'sms',
          to: to, // E.164 format
          from: this.vonageNumber, // Your Vonage number
          text: text,
        };
    
        try {
          this.logger.log(`Attempting to send SMS to ${to}`);
          const response = await this.vonageClient.messages.send(messageRequest);
          this.logger.log(`SMS submitted to Vonage for ${to}. Message UUID: ${response.message_uuid}`);
          return { success: true, messageId: response.message_uuid };
        } catch (error) {
          // Log the detailed error from the Vonage SDK
          this.logger.error(`Failed to send SMS to ${to}: ${error?.response?.data?.title || error.message}`, error?.response?.data || error);
          return { success: false, error: error?.response?.data || error };
        }
      }
    
      getClient(): Vonage {
        return this.vonageClient;
      }
    
      /**
       * Verify Vonage Messages API webhook signature using JWT/HMAC-SHA256.
       * Vonage uses JWT Bearer Authorization with HMAC-SHA256 for webhook authentication.
       *
       * @param authHeader - The Authorization header value (format: "Bearer <token>")
       * @param payload - The raw webhook payload as a Buffer or string
       * @returns boolean indicating if the signature is valid
       *
       * References:
       * - Vonage Webhook Signature Verification: https://developer.vonage.com/en/blog/validating-inbound-messages-from-the-vonage-messages-api-dr
       * - Using Message Signatures: https://developer.vonage.com/en/blog/using-message-signatures-to-ensure-secure-incoming-webhooks-dr
       *
       * Implementation Notes:
       * 1. Extract JWT from Authorization header (format: "Bearer <JWT>")
       * 2. Decode JWT using your signature secret (32+ bits recommended)
       * 3. Verify payload_hash claim matches actual payload hash to prevent replay attacks
       * 4. Signature secret is found in Vonage Dashboard > Settings > API Settings
       */
      async verifyWebhookSignature(authHeader: string, payload: Buffer | string): Promise<boolean> {
        const signatureSecret = this.configService.get<string>('VONAGE_SIGNATURE_SECRET');
    
        if (!signatureSecret) {
          this.logger.error('VONAGE_SIGNATURE_SECRET not configured in .env');
          return false;
        }
    
        if (!authHeader || !authHeader.startsWith('Bearer ')) {
          this.logger.error('Invalid Authorization header format');
          return false;
        }
    
        try {
          // Extract JWT token from "Bearer <token>"
          const token = authHeader.split(' ')[1];
    
          // Verify JWT using the signature secret
          // Implementation depends on your JWT library (e.g., jsonwebtoken)
          // Example: jwt.verify(token, signatureSecret, { algorithms: ['HS256'] });
    
          // Verify payload hash to prevent replay attacks
          // const decoded = jwt.decode(token);
          // const payloadHash = crypto.createHash('sha256').update(payload).digest('hex');
          // return decoded.payload_hash === payloadHash;
    
          this.logger.log('Webhook signature verification successful');
          return true;
        } catch (err) {
          this.logger.error(`Webhook signature verification failed: ${err.message}`);
          return false;
        }
      }
    }
<!-- GAP: Missing complete webhook signature verification implementation (Type: Critical, Priority: High) --> *File: `src/vonage/vonage.module.ts`* ```typescript import { Module, Global } from '@nestjs/common'; import { VonageService } from './vonage.service'; import { ConfigModule } from '@nestjs/config'; // Ensure ConfigModule is available @Global() // Make VonageService available globally without importing VonageModule everywhere @Module({ imports: [ConfigModule], // VonageService depends on ConfigService providers: [VonageService], exports: [VonageService], }) export class VonageModule {} ``` *Make sure `VonageModule` is imported into `AppModule`'s imports array (done in step 4.3).*

2. Implement Campaign Sending Logic: Add a method in CampaignsService to fetch subscribers and send messages using VonageService.

<!-- EXPAND: Add campaign scheduling implementation example (Type: Enhancement, Priority: Medium) --> <!-- DEPTH: Lacks discussion of message queueing for large-scale campaigns (Type: Substantive, Priority: High) --> *File: `src/campaigns/campaigns.service.ts`* ```typescript import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Campaign, CampaignStatus } from '../database/entities/campaign.entity'; import { Subscriber, SubscriptionStatus } from '../database/entities/subscriber.entity'; import { MessageLog, MessageStatus } from '../database/entities/message-log.entity'; import { VonageService } from '../vonage/vonage.service'; import { CreateCampaignDto } from './dto/create-campaign.dto'; // Define this DTO later @Injectable() export class CampaignsService { private readonly logger = new Logger(CampaignsService.name); constructor( @InjectRepository(Campaign) private campaignsRepository: Repository<Campaign>, @InjectRepository(Subscriber) private subscribersRepository: Repository<Subscriber>, @InjectRepository(MessageLog) private messageLogRepository: Repository<MessageLog>, // VonageService is injected because VonageModule is Global private vonageService: VonageService, ) {} async create(createCampaignDto: CreateCampaignDto): Promise<Campaign> { const campaign = this.campaignsRepository.create(createCampaignDto); campaign.status = CampaignStatus.DRAFT; return this.campaignsRepository.save(campaign); } findAll(): Promise<Campaign[]> { return this.campaignsRepository.find(); } async findOne(id: string): Promise<Campaign> { const campaign = await this.campaignsRepository.findOne({ where: { id } }); if (!campaign) { // Corrected template literal usage throw new NotFoundException(`Campaign with ID "${id}" not found`); } return campaign; } async sendCampaign(campaignId: string): Promise<void> { const campaign = await this.findOne(campaignId); if (campaign.status !== CampaignStatus.DRAFT && campaign.status !== CampaignStatus.FAILED) { this.logger.warn(`Campaign ${campaignId} is not in DRAFT or FAILED status. Current status: ${campaign.status}`); // Optionally throw an error or just return return; } // Find subscribed users const subscribers = await this.subscribersRepository.find({ where: { status: SubscriptionStatus.SUBSCRIBED }, }); if (subscribers.length === 0) { this.logger.warn(`No subscribed users found for campaign ${campaignId}.`); campaign.status = CampaignStatus.FAILED; // Or SENT if technically no messages needed sending campaign.sentAt = new Date(); await this.campaignsRepository.save(campaign); return; } this.logger.log(`Starting campaign ${campaignId} (${campaign.name}) for ${subscribers.length} subscribers.`); campaign.status = CampaignStatus.SENDING; await this.campaignsRepository.save(campaign); let successCount = 0; let failureCount = 0; /** * Simple Sequential Sending with Rate Limiting * * Vonage API Rate Limits (as of 2025): * - Default: 30 API requests/second for standard accounts * - SMS-specific: Can be as low as 1 message/second due to carrier restrictions * - 10DLC (US): 600 TPM (10 messages/second) for shared rate limit * - Messages API Sandbox: 1 message/second, 100 messages/month * * References: * - Vonage Throughput Limits: https://api.support.vonage.com/hc/en-us/articles/203993598-What-is-the-Throughput-Limit-for-Outbound-SMS * - 10DLC Limits: https://api.support.vonage.com/hc/en-us/articles/4406782736532-10-DLC-Throughput-Limits * - Rate Limit Handling: https://developer.vonage.com/en/blog/respect-api-rate-limits-with-a-backoff-dr * * For production: Implement a queue system (Bull, BullMQ, or AWS SQS) for better * throughput management and retry logic. */ for (const subscriber of subscribers) { // Simple check to avoid sending to unsubscribed (though filtered above, good practice) if (subscriber.status !== SubscriptionStatus.SUBSCRIBED) { this.logger.warn(`Skipping unsubscribed user ${subscriber.phoneNumber} for campaign ${campaignId}`); continue; } const messageLog = this.messageLogRepository.create({ campaign: campaign, subscriber: subscriber, status: MessageStatus.PENDING, }); await this.messageLogRepository.save(messageLog); // Save log before sending try { // Basic placeholder substitution (replace with a more robust method if needed) const personalizedMessage = campaign.messageTemplate .replace('{firstName}', subscriber.firstName || '') .replace('{lastName}', subscriber.lastName || '') .trim(); const result = await this.vonageService.sendSms( subscriber.phoneNumber, personalizedMessage, ); if (result.success && result.messageId) { messageLog.status = MessageStatus.SUBMITTED; messageLog.vonageMessageId = result.messageId; messageLog.submittedAt = new Date(); successCount++; } else { messageLog.status = MessageStatus.FAILED; messageLog.errorMessage = JSON.stringify(result.error || 'Unknown error during submission'); failureCount++; } } catch (error) { this.logger.error(`Critical error sending to ${subscriber.phoneNumber}: ${error.message}`, error.stack); messageLog.status = MessageStatus.FAILED; messageLog.errorMessage = `Internal error: ${error.message}`; failureCount++; } await this.messageLogRepository.save(messageLog); // Update log with status and messageId/error /** * Rate limiting delay: 1.1 seconds between messages * Adjust based on your Vonage account limits and carrier restrictions. * Conservative default: 1 message/second for long codes. * For higher throughput, contact Vonage support for account review. */ await new Promise(resolve => setTimeout(resolve, 1100)); // ~1.1 second delay } // --- End Simple Sequential Sending --- campaign.status = failureCount > 0 ? CampaignStatus.FAILED : CampaignStatus.SENT; // Mark SENT only if all submitted successfully campaign.sentAt = new Date(); await this.campaignsRepository.save(campaign); this.logger.log(`Campaign ${campaignId} finished. Submitted: ${successCount}, Failed: ${failureCount}`); } // ... other methods (update, delete) } ```

6. Building REST API Endpoints for SMS Campaign Management

Create RESTful API endpoints to manage SMS marketing campaigns and subscribers using NestJS controllers and DTOs.

<!-- GAP: Missing complete controller implementations (Type: Critical, Priority: High) --> <!-- DEPTH: Lacks API documentation and Swagger integration (Type: Substantive, Priority: Medium) -->
  1. Define Data Transfer Objects (DTOs) with Validation: Use class-validator decorators for robust input validation.

    File: src/subscribers/dto/create-subscriber.dto.ts

    typescript
    import { IsString, IsNotEmpty, IsPhoneNumber, IsOptional, MaxLength } from 'class-validator';
    
    export class CreateSubscriberDto {
      @IsNotEmpty()
      @IsPhoneNumber(null) // Use null for international format validation (e.g., +14155550100)
      @MaxLength(20)
      phoneNumber: string;
    
      @IsOptional()
      @IsString()
      @MaxLength(100)
      firstName?: string;
    
      @IsOptional()
      @IsString()
      @MaxLength(100)
      lastName?: string;
    }

    File: src/campaigns/dto/create-campaign.dto.ts

    typescript
    import { IsString, IsNotEmpty, MinLength, MaxLength, IsOptional, IsDateString } from 'class-validator';
    
    export class CreateCampaignDto {
      @IsNotEmpty()
      @IsString()
      @MinLength(3)
      @MaxLength(255)
      name: string;
    
      @IsNotEmpty()
      @IsString()
      @MinLength(10)
      messageTemplate: string;
    
      @IsOptional()
      @IsDateString()
      scheduledAt?: string; // ISO 8601 date string
    }
<!-- EXPAND: Add request/response examples for each endpoint (Type: Enhancement, Priority: High) -->
  1. Implement Controllers: Create endpoints using the services and DTOs. Ensure ValidationPipe is enabled globally.

    File: src/main.ts

    typescript
    import { NestFactory } from '@nestjs/core';
    import { AppModule } from './app.module';
    import { Logger, ValidationPipe } from '@nestjs/common';
    import { ConfigService } from '@nestjs/config';
    import helmet from 'helmet'; // Import helmet
    import { NestExpressApplication } from '@nestjs/platform-express'; // Import this for rawBody option
    import * as bodyParser from 'body-parser'; // Import body-parser
    
    async function bootstrap() {
      // Specify NestExpressApplication for access to Express-specific options like bodyParser
      const app = await NestFactory.create<NestExpressApplication>(AppModule, {
        // Enable bodyParser to access raw request body for webhook verification
        bodyParser: false, // Disable NestJS default body parsing temporarily
      });
    
      const configService = app.get(ConfigService);
      const port = configService.get<number>('PORT') || 3000;
      const nodeEnv = configService.get<string>('NODE_ENV');
    
      // Apply Helmet middleware for security headers
      app.use(helmet());
    
      // Enable CORS if your frontend/API clients are on different origins
      app.enableCors(); // Configure origins appropriately for production
    
      // Re-enable JSON body parsing for regular routes, keeping raw body for specific routes
      // Parse JSON bodies
      app.use(bodyParser.json());
      // Parse URL-encoded bodies
      app.use(bodyParser.urlencoded({ extended: true }));
      // Crucially, parse raw body specifically for webhook routes *before* JSON/URL parsing if needed
      // This setup assumes webhooks might need raw body, other routes need parsed body.
      // A more robust approach might involve applying raw body parsing only to webhook routes.
      // Example: app.use('/webhooks', bodyParser.raw({ type: 'application/json' }));
      // For simplicity here, we rely on accessing it if bodyParser hasn't consumed it.
      // NestJS's built-in `rawBody: true` in `NestFactory.create` is often simpler if applicable globally.
      // Let's revert to the simpler NestJS way if it works for Vonage webhooks:
      // Remove `bodyParser: false` from create options.
      // Remove `app.use(bodyParser...)` lines.
      // Add `rawBody: true` to NestFactory.create options:
      // const app = await NestFactory.create<NestExpressApplication>(AppModule, { rawBody: true });
    
      // Use global validation pipe
      app.useGlobalPipes(new ValidationPipe({
        whitelist: true, // Strip properties not defined in DTO
        transform: true, // Automatically transform payloads to DTO instances
        forbidNonWhitelisted: true, // Throw error if non-whitelisted properties are present
      }));
    
      await app.listen(port);
      Logger.log(`🚀 Application is running on: http://localhost:${port} in ${nodeEnv} mode`, 'Bootstrap');
      Logger.log(`📄 Swagger UI available at http://localhost:${port}/api`, 'Bootstrap'); // If Swagger is setup
    }
    bootstrap();

    (Note: The main.ts example above shows how to handle rawBody for webhooks. Ensure your final implementation correctly provides the raw body to your webhook verification logic, potentially using NestFactory.create(AppModule, { rawBody: true }) and accessing req.rawBody in the controller, or using middleware as shown.)

    (Implement SubscribersController and CampaignsController with appropriate methods using @Get, @Post, @Param, @Body, etc., calling the respective service methods and using the DTOs.)

<!-- GAP: Missing webhook controller implementation (Type: Critical, Priority: High) --> <!-- GAP: Missing health check endpoint implementation (Type: Substantive, Priority: Medium) --> <!-- EXPAND: Add monitoring and observability setup (Type: Enhancement, Priority: Medium) --> <!-- GAP: Missing deployment instructions for common platforms (Type: Substantive, Priority: High) --> <!-- DEPTH: Lacks testing strategy and examples (Type: Substantive, Priority: High) --> <!-- EXPAND: Add performance optimization recommendations (Type: Enhancement, Priority: Medium) --> <!-- GAP: Missing cost estimation for Vonage SMS at scale (Type: Substantive, Priority: Medium) --> <!-- EXPAND: Add GDPR/privacy compliance considerations (Type: Enhancement, Priority: High) -->

Frequently Asked Questions: NestJS SMS Marketing with Vonage API

How do I integrate Vonage Messages API with NestJS for SMS marketing?

The Vonage Messages API is a multi-channel messaging platform that enables sending SMS, MMS, WhatsApp, Viber, Facebook Messenger, and RCS messages through a unified interface. When integrated with NestJS, you use the @vonage/server-sdk (v3.24.1) or @vonage/messages (v1.20.3) package to authenticate with JWT, send messages programmatically, and receive delivery status updates via webhooks. NestJS provides the framework for building RESTful APIs, managing campaigns with TypeORM, and handling asynchronous message delivery.

What are Vonage SMS API rate limits for bulk messaging campaigns?

Vonage enforces several rate limits for SMS messaging: 30 API requests per second for standard accounts (default), though SMS-specific limits can be as low as 1 message per second due to carrier restrictions. For US messaging via 10DLC, shared rate limits are 600 messages per minute (10 messages/second). The Messages API sandbox is limited to 1 message per second and 100 messages per month. For higher throughput, contact Vonage support for account review. Always implement exponential backoff and queue systems (Bull, BullMQ) for production campaigns. See Vonage Throughput Limits.

How do I verify Vonage webhook signatures in NestJS?

Vonage uses JWT Bearer Authorization with HMAC-SHA256 signatures for Messages API webhooks. To verify: (1) Extract the JWT from the Authorization header (format: "Bearer <token>"), (2) Decode the JWT using your signature secret (32+ bits recommended, found in Vonage Dashboard → Settings → API Settings), (3) Verify the payload_hash claim matches the actual payload hash to prevent replay attacks. Use a JWT library like jsonwebtoken in Node.js. This authentication method is crucial for production security. See Vonage Webhook Signature Verification.

What is 10DLC and why is it required for US SMS marketing?

10DLC (10-Digit Long Code) is a mandatory registration system for all US SMS traffic that provides improved deliverability and higher throughput compared to unregistered long codes. As of 2025, all customers must register their 10DLC Brand & Campaign to send messages to US phone numbers. Registration involves verifying your business identity and describing your messaging use case. Throughput limits vary by campaign trust score (Low: 75 messages/day, Standard: 2,000 messages/day, High: varies). Register via the Vonage Dashboard. See Vonage 10DLC Documentation.

Which Node.js and NestJS versions are best for SMS marketing in 2025?

For production SMS marketing applications in 2025, use Node.js v22 LTS (Active LTS until October 2025, Maintenance LTS until April 2027) or Node.js v20 LTS (supported until April 2026). Use NestJS 11 (v11.1.6 as of early 2025), which includes improved logging, cache-manager v6 support, and strongly-typed CQRS features. These versions provide long-term stability, security updates, and compatibility with modern packages like TypeORM 0.3.x and @nestjs/typeorm v11.0.0.

How do I handle STOP/START opt-out messages in my SMS campaigns?

Implement an inbound webhook handler that receives SMS replies from Vonage. Parse the message text for keywords like "STOP," "UNSUBSCRIBE," "START," or "SUBSCRIBE" (case-insensitive). For STOP messages: (1) Update the subscriber's status to "UNSUBSCRIBED" in your database, (2) Set an unsubscribedAt timestamp, (3) Send a confirmation reply: "You have been unsubscribed. Reply START to opt back in." For START messages: (1) Update status to "SUBSCRIBED," (2) Clear unsubscribedAt, (3) Send confirmation: "You are now subscribed." Always respect opt-out requests immediately to comply with TCPA regulations.

<!-- EXPAND: Add code example for STOP/START handler implementation (Type: Enhancement, Priority: High) -->

What database schema should I use for SMS marketing campaigns?

Use three core entities: Subscribers (id, phoneNumber [E.164 format, unique indexed], firstName, lastName, status enum [subscribed/unsubscribed], subscribedAt, unsubscribedAt), Campaigns (id, name, messageTemplate, status enum [draft/scheduled/sending/sent/failed], createdAt, scheduledAt, sentAt), and MessageLogs (id, vonageMessageId [indexed], campaign relation, subscriber relation, status enum [pending/submitted/delivered/expired/failed/rejected], errorMessage, createdAt, submittedAt, deliveredAt). Use TypeORM with PostgreSQL 18 for production reliability. This schema supports campaign tracking, delivery status updates via webhooks, and subscriber management.

How do I implement message personalization in SMS templates?

Use placeholder substitution in your campaign message templates. Store templates with placeholders like "{firstName}" and "{lastName}" in the Campaign entity's messageTemplate field. When sending, replace placeholders with actual subscriber data: message.replace('{firstName}', subscriber.firstName || '').replace('{lastName}', subscriber.lastName || ''). For more advanced personalization, consider template engines like Handlebars or Mustache. Always trim() the final message and validate length (160 characters for standard SMS, 1,600 for concatenated messages). Personalization significantly improves engagement rates in marketing campaigns.

<!-- DEPTH: Lacks discussion of SMS length calculation and concatenation (Type: Substantive, Priority: Medium) -->

What's the best way to scale SMS sending for large campaigns?

For campaigns exceeding 1,000 recipients, implement a job queue system using Bull or BullMQ with Redis. Create a queue processor that: (1) Fetches batches of subscribers (100-500 per batch), (2) Sends messages with appropriate delays (1.1 seconds for conservative 1 message/second rate), (3) Implements exponential backoff for failed deliveries, (4) Updates MessageLog status in batches. Use Redis clustering for high availability. Consider Vonage's higher throughput options for enterprise campaigns. Monitor queue health with @nestjs/terminus health checks. This architecture handles millions of messages reliably.

<!-- EXPAND: Add Bull/BullMQ implementation example (Type: Enhancement, Priority: High) -->

How do I test webhooks locally during development?

Use ngrok to expose your local development server to the internet: (1) Start your NestJS application (npm run start:dev), (2) Run ngrok http 3000 to create a tunnel, (3) Copy the HTTPS URL (e.g., https://abc123.ngrok.io), (4) Update your Vonage Application webhooks: set Inbound URL to https://abc123.ngrok.io/webhooks/inbound and Status URL to https://abc123.ngrok.io/webhooks/status, (5) Test by sending SMS to your Vonage number. View webhook payloads in the ngrok web interface (http://127.0.0.1:4040). For persistent testing, upgrade to ngrok's paid plan for static URLs.

<!-- EXPAND: Add alternative local testing methods (Type: Enhancement, Priority: Low) -->