code examples
code examples
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) -->-
Create a New NestJS Project: Open your terminal and run:
bashnest new vonage-sms-campaign-app cd vonage-sms-campaign-appChoose your preferred package manager (npm or yarn).
-
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.envfile.@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.
- 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.
- Access Vonage Dashboard: Log in to your Vonage API Dashboard.
- 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.
- 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/inboundfor the Inbound URL andhttp://example.com/webhooks/statusfor the Status URL. Update these later with your ngrok URL or public deployment URL. - Click "Generate public and private key." Save the
private.keyfile securely within your project directory (e.g., at the root). Do not commit this file to Git. Addprivate.keyto your.gitignorefile. - Note down the Application ID displayed after creation.
- 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.
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) -->-
Create
.envand.env.examplefiles: In the project root, create.env(for your local values) and.env.example(as a template). Add.envto your.gitignore.File:
.env.exampledotenv# 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:3000File:
.envdotenv# 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 -
Integrate
ConfigModule: Configure theConfigModulein your rootAppModuleto load these variables globally.File:
src/app.module.tstypescriptimport { 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 {}
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) -->-
Docker Compose for PostgreSQL: Create a
docker-compose.ymlfile in the project root for easy local database setup. Use PostgreSQL 18 Alpine for a smaller image footprint.File:
docker-compose.ymlyamlversion: '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: localRun
docker-compose up -dto start the PostgreSQL container.
-
Define Entities: Create entity files for
Campaign,Subscriber, andMessageLog.File:
src/database/entities/subscriber.entity.tstypescriptimport { 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.tstypescriptimport { 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.tstypescriptimport { 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 }
-
Configure TypeORM Connection: Update
AppModuleto configure the database connection usingConfigService.File:
src/app.module.tstypescriptimport { 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: falseand use TypeORM migrations. Refer to the NestJS TypeORM documentation for migration setup. For this guide,synchronize: truesimplifies local development. -
Create Modules for Entities: Generate modules and services for
CampaignsandSubscribers.bashnest g module campaigns nest g service campaigns nest g controller campaigns nest g module subscribers nest g service subscribers nest g controller subscribersImport
TypeOrmModule.forFeature([...])intoCampaignsModuleandSubscribersModuleto inject repositories.File:
src/campaigns/campaigns.module.tstypescriptimport { 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.tstypescriptimport { 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) -->-
Create Vonage Module & Service: Encapsulate Vonage SDK initialization and interaction.
bashnest g module vonage nest g service vonageFile:
src/vonage/vonage.service.tstypescriptimport { 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; } } }
*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.
*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) -->-
Define Data Transfer Objects (DTOs) with Validation: Use
class-validatordecorators for robust input validation.File:
src/subscribers/dto/create-subscriber.dto.tstypescriptimport { 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.tstypescriptimport { 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 }
-
Implement Controllers: Create endpoints using the services and DTOs. Ensure
ValidationPipeis enabled globally.File:
src/main.tstypescriptimport { 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.tsexample above shows how to handlerawBodyfor webhooks. Ensure your final implementation correctly provides the raw body to your webhook verification logic, potentially usingNestFactory.create(AppModule, { rawBody: true })and accessingreq.rawBodyin the controller, or using middleware as shown.)(Implement
SubscribersControllerandCampaignsControllerwith appropriate methods using@Get,@Post,@Param,@Body, etc., calling the respective service methods and using the DTOs.)
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.
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.
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.