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
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.
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.
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).
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.
Project Structure (Conceptual):
NestJS promotes a modular structure. We'll organize our code logically:
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.
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/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.
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.
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
.env
# 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
.env
# 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 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 globallyenvFilePath: '.env', // Specify the env file
}),
// TypeOrmModule will be added here later// Other modules...
],
controllers: [],
providers: [],
})
exportclassAppModule {}
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.
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_postgresenvironment:POSTGRES_USER:${DATABASE_USERNAME:-postgres}# Use .env or defaultPOSTGRES_PASSWORD:${DATABASE_PASSWORD:-secret}POSTGRES_DB:${DATABASE_NAME:-sms_campaigns_db}ports:-"${DATABASE_PORT:-5432}:5432"volumes:-postgres_data:/var/lib/postgresql/datarestart:unless-stoppedvolumes:postgres_data:driver:local
Run docker-compose up -d to start the PostgreSQL container.
Define Entities:
Create entity files for Campaign, Subscriber, and MessageLog.
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, Index } from'typeorm';
import { Campaign } from'./campaign.entity';
import { Subscriber } from'./subscriber.entity';
exportenumMessageStatus {
PENDING = 'pending', // Initial state before sending attemptSUBMITTED = 'submitted', // Successfully submitted to VonageDELIVERED = 'delivered', // Confirmed deliveryEXPIRED = 'expired', // Message expired before deliveryFAILED = 'failed', // Delivery failedREJECTED = 'rejected', // Rejected by carrier or VonageUNKNOWN = 'unknown', // Status unknown or not yet received
}
@Entity('message_logs')
@Index(['vonageMessageId']) // Index for quick lookup via webhookexportclassMessageLog {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 50, nullable: true }) // Vonage Message UUIDvonageMessageId?: string;
@ManyToOne(() =>Campaign, (campaign) => campaign.messageLogs, { onDelete: 'CASCADE' })
campaign: Campaign;
@ManyToOne(() =>Subscriber, { eager: true, onDelete: 'CASCADE' }) // Eager load subscriber for easy accesssubscriber: 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 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 laterimport { 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 prodlogging: 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 ModulesCampaignsModule,
SubscribersModule,
VonageModule, // Ensure VonageModule is Global or imported where neededWebhooksModule,
HealthModule,
// RateLimiterModule // Register RateLimiterModule if using it globally
],
// ... controllers/providers if any at root level
})
exportclassAppModule {}
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.
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
})
exportclassCampaignsModule {}
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 typesimport * as fs from'fs'; // Import fs to read the private keyimport * as path from'path'; // Import path for resolving paths@Injectable()
exportclassVonageServiceimplementsOnModuleInit {
privatereadonly logger = newLogger(VonageService.name);
privatevonageClient: Vonage;
privatevonageNumber: string;
constructor(privateconfigService: 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');
thrownewError('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)) {
thrownewError(`Private key file not found at: ${resolvedPath}`);
}
const privateKey = fs.readFileSync(resolvedPath); // Read the key filethis.vonageClient = newVonage({
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
}
}
asyncsendSms(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.' };
}
constmessageRequest: MessageSendRequest = {
message_type: 'text',
channel: 'sms',
to: to, // E.164 formatfrom: this.vonageNumber, // Your Vonage numbertext: text,
};
try {
this.logger.log(`Attempting to send SMS to ${to}`);
const response = awaitthis.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 SDKthis.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 {
returnthis.vonageClient;
}
/**
* Verify Vonage Messages API webhook signature using JWT/HMAC-SHA256.
* Vonage uses JWT Bearer Authorization with HMAC-SHA256 for webhook authentication.
*
* @paramauthHeader - The Authorization header value (format: "Bearer <token>")
* @parampayload - 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
*/asyncverifyWebhookSignature(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');
returnfalse;
}
if (!authHeader || !authHeader.startsWith('Bearer ')) {
this.logger.error('Invalid Authorization header format');
returnfalse;
}
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');
returntrue;
} catch (err) {
this.logger.error(`Webhook signature verification failed: ${err.message}`);
returnfalse;
}
}
}
Code
*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.
Code
*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.
Define Data Transfer Objects (DTOs) with Validation:
Use class-validator decorators for robust input validation.
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 helmetimport { NestExpressApplication } from'@nestjs/platform-express'; // Import this for rawBody optionimport * as bodyParser from'body-parser'; // Import body-parserasyncfunctionbootstrap() {
// Specify NestExpressApplication for access to Express-specific options like bodyParserconst app = awaitNestFactory.create<NestExpressApplication>(AppModule, {
// Enable bodyParser to access raw request body for webhook verificationbodyParser: 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(newValidationPipe({
whitelist: true, // Strip properties not defined in DTOtransform: true, // Automatically transform payloads to DTO instancesforbidNonWhitelisted: 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.)
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 "), (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.
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.
Frequently asked questions
Use the Vonage Messages API integrated with a NestJS service. The Vonage Node.js SDK simplifies sending SMS messages by providing methods to manage API requests and responses. Make sure to configure the Vonage service with your API credentials from the Vonage dashboard and inject this service into your NestJS campaign service or controller where you'll handle sending logic.
The Vonage Messages API is a service that allows you to send and receive SMS messages programmatically. It's a key component for building SMS marketing campaigns, and you interact with it using the Vonage Node.js SDK within your NestJS application. It also handles message status updates and inbound messages via webhooks.
TypeORM is used for database interaction with PostgreSQL. It provides an object-relational mapper (ORM) simplifying database operations in NestJS. TypeORM defines entities (Campaign, Subscriber, MessageLog) to represent database tables, allowing interaction using JavaScript/TypeScript objects and methods.
Ngrok is useful for testing webhooks locally during development when your local server isn't publicly accessible. It creates a temporary public URL that forwards requests to your local machine. For production, you'll need a stable public URL on a server or platform-as-a-service (PaaS).
Yes, TypeORM supports various databases like MySQL, MongoDB, and more. While the guide uses PostgreSQL, you can adapt the TypeORM configuration in your AppModule to connect to your preferred database. Remember to install the appropriate database driver and adjust connection details accordingly.
Access your Vonage API Dashboard, ensure 'Messages API' is selected under Default SMS settings. Create a new Vonage Application, enabling the 'Messages' capability. Save the private key file securely and note the Application ID. Link your Vonage number to this application so incoming messages are routed to your app's webhook.
The guide recommends a modular structure with separate modules for campaigns, subscribers, Vonage API integration, webhooks, and database operations. This keeps the code organized, maintainable, and easy to test. Create modules using the NestJS CLI and organize related code within each module.
Use the @nestjs/config module to load environment variables from a .env file. Store API keys, database credentials, and other sensitive information in .env and ensure it's added to your .gitignore file. Never commit .env to version control.
You'll need Node.js, npm/yarn, NestJS CLI, Docker, Docker Compose, a Vonage API account with a Vonage phone number, and ngrok for local webhook testing.
The Vonage service provides a verifyWebhookJwt method. You should implement this method by retrieving the Vonage public key or signature secret from your config and using it to verify the webhook signature using the Vonage SDK method or appropriate JWT libraries. This ensures the webhook request is indeed from Vonage.
The guide suggests creating a subscribers module with a service and controller to manage subscriber data. You'll need to implement endpoints and methods to store and retrieve subscribers from the database using TypeORM. Ensure you collect necessary subscriber data like phone numbers.
Consider techniques such as asynchronous message sending with queues or message batching instead of the simple sequential sending in the example. Rate limiting should be carefully managed, potentially with external tools or libraries, if higher throughput is needed.
The private.key file contains your Vonage application's private key, used to authenticate with the Vonage API. It is crucial for security, and should never be committed to version control. Store it securely in your project directory and add it to your .gitignore file.
DTOs provide a structured way to define and validate data coming from external sources like API requests. By using class-validator decorators in DTOs, you can easily apply validation rules ensuring data integrity and security.
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:
This guide serves as a complete reference, enabling you to build and deploy a production-ready SMS marketing system following industry best practices.
Prerequisites for Building SMS Marketing with NestJS
Before starting this SMS marketing tutorial, ensure you have:
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.postgres:18-alpine). See PostgreSQL Documentation and Docker Hub – PostgreSQL.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.
Create a New NestJS Project: Open your terminal and run:
nest 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.
# 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.2. How to Configure Vonage API for SMS Marketing
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.
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.private.keyfile securely within your project directory (e.g., at the root). Do not commit this file to Git. Addprivate.keyto your.gitignorefile.3. How to Configure Environment Variables for Vonage SMS
Securely manage Vonage API keys and configuration settings using environment variables in your NestJS application.
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.exampleFile:
.envIntegrate
ConfigModule: Configure theConfigModulein your rootAppModuleto load these variables globally.File:
src/app.module.tsimport { 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.
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.ymlversion: '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.tsimport { 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.tsimport { 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.tsimport { 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.tsimport { 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.Import
TypeOrmModule.forFeature([...])intoCampaignsModuleandSubscribersModuleto inject repositories.File:
src/campaigns/campaigns.module.tsimport { 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.tsimport { 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.
Create Vonage Module & Service: Encapsulate Vonage SDK initialization and interaction.
File:
src/vonage/vonage.service.tsimport { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Vonage } from '@vonage/server-sdk'; import { MessageSendRequest } from '@vonage/messages'; // Import 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; } } }2. Implement Campaign Sending Logic: Add a method in
CampaignsServiceto fetch subscribers and send messages usingVonageService.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.
Define Data Transfer Objects (DTOs) with Validation: Use
class-validatordecorators for robust input validation.File:
src/subscribers/dto/create-subscriber.dto.tsimport { 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.tsimport { 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.tsimport { 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 "), (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
jsonwebtokenin 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
unsubscribedAttimestamp, (3) Send a confirmation reply: "You have been unsubscribed. Reply START to opt back in." For START messages: (1) Update status to "SUBSCRIBED," (2) ClearunsubscribedAt, (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
messageTemplatefield. 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.
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) Runngrok http 3000to create a tunnel, (3) Copy the HTTPS URL (e.g.,https://abc123.ngrok.io), (4) Update your Vonage Application webhooks: set Inbound URL tohttps://abc123.ngrok.io/webhooks/inboundand Status URL tohttps://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.