NestJS SMS Marketing with Plivo: Production-Ready Campaign Tutorial
Learn how to build scalable SMS marketing campaigns with NestJS and Plivo. Complete tutorial covering TypeORM, PostgreSQL, bulk messaging, rate limiting, error handling, and production deployment.
Build a production-ready SMS marketing campaign application using NestJS and Plivo. This comprehensive guide walks you through creating a scalable backend system that manages subscriber lists, sends bulk SMS messages with proper rate limiting, and handles failures gracefully.
By the end, you'll have a fully functional NestJS application integrated with the Plivo SMS API that stores campaign data in PostgreSQL, implements queue-based message processing, and follows industry best practices for security and observability. This tutorial solves the common challenge of building reliable, high-volume SMS marketing systems with modern Node.js frameworks.
Prerequisites:
Node.js v20 or higher – NestJS 11 requires Node.js v20+ (official NestJS migration guide). Node.js v18 reached end-of-life on April 30, 2025. Use the latest Node.js LTS version (v22 as of 2025) for optimal performance and security.
common/ # Common decorators, filters, guards, etc.
filters/
http-exception.filter.ts
guards/
api-key.guard.ts # Optional: If using API Key auth (Section 7)
health/ # Optional: Health check module (Section 10)
health.controller.ts
health.module.ts
Create modules progressively: start with database and Plivo modules (foundational), then campaign module (business logic), followed by optional modules for security, health checks, and queuing as needed.
Environment Configuration
Create a .env file in the project root for environment variables. Never commit this file to version control.
Sign up and get credentials: Log in to your Plivo console (https://console.plivo.com/). Your AUTH ID and AUTH TOKEN display on the main dashboard overview page.
Purchase a phone number: Navigate to Messaging → Phone Numbers → Your Numbers to find or purchase a Plivo number to use as the PLIVO_SOURCE_NUMBER.
Local/Mobile Numbers: $0.50/month – SMS and voice enabled, suitable for person-to-person communication
Toll-Free Numbers: $1.00/month – SMS and voice enabled, better for marketing campaigns
Short Codes: $500-$1,000/month + $1,500 one-time setup fee – High throughput for bulk campaigns (billed quarterly)
Verification requirements: Most number types require no upfront verification, but toll-free numbers in the US require verification for high-volume messaging. For 10DLC (long code) campaigns in the US, complete brand and campaign registration (10DLC compliance).
This completes the foundation with configuration management.
Integrating the Plivo SMS API with NestJS
Encapsulate Plivo interactions within a dedicated service.
Create Plivo Module and Service
TypeScript
// src/plivo/plivo.module.tsimport { Module } from'@nestjs/common';
import { ConfigModule } from'@nestjs/config';
import { PlivoService } from'./plivo.service';
@Module({
imports: [ConfigModule], // Import ConfigModule if needed directly, or rely on globalproviders: [PlivoService],
exports: [PlivoService], // Export the service for other modules to use
})
exportclassPlivoModule {}
TypeScript
// src/plivo/plivo.service.tsimport { Injectable, InternalServerErrorException, Logger } from'@nestjs/common';
import { ConfigService } from'@nestjs/config';
import * as plivo from'plivo';
@Injectable()
exportclassPlivoService {
privatereadonly logger = newLogger(PlivoService.name);
privateclient: plivo.Client;
constructor(privateconfigService: ConfigService) {
const authId = this.configService.get<string>('config.plivo.authId');
const authToken = this.configService.get<string>('config.plivo.authToken');
if (!authId || !authToken) {
this.logger.error('Plivo Auth ID or Auth Token missing in configuration.');
thrownewInternalServerErrorException('Plivo credentials are not configured.');
}
// The plivo-node library might internally handle basic retries,// but we initialize the client here.this.client = new plivo.Client(authId, authToken);
this.logger.log('Plivo Client Initialized');
}
/**
* Sends an SMS message using Plivo.
* @param to The destination phone number (E.164 format recommended).
* @param text The message content.
* @returns The message UUID from Plivo upon successful queuing.
* @throws InternalServerErrorException if sending fails.
*/asyncsendSms(to: string, text: string): Promise<string> {
const sourceNumber = this.configService.get<string>('config.plivo.sourceNumber');
if (!sourceNumber) {
this.logger.error('Plivo Source Number missing in configuration.');
thrownewInternalServerErrorException('Plivo source number is not configured.');
}
this.logger.debug(`Attempting to send SMS to ${to}`);
try {
const response = awaitthis.client.messages.create({
src: sourceNumber,
dst: to,
text: text,
// You can add a URL for delivery reports if needed (See Section 8 & 11):// url: 'https://yourapp.com/plivo/delivery-report',// method: 'POST'
});
// NOTE: Verify the structure of the response object with your SDK version.// Plivo API returns messageUuid as an array. Accessing [0] assumes success and a non-empty array.// Error handling might need to inspect the response structure more carefully.if (response.messageUuid && Array.isArray(response.messageUuid) && response.messageUuid.length > 0) {
this.logger.log(`SMS queued successfully to ${to}. Message UUID: ${response.messageUuid[0]}`);
return response.messageUuid[0];
} else {
// Log unexpected response structurethis.logger.error(`Unexpected response structure from Plivo for ${to}: ${JSON.stringify(response)}`);
thrownewInternalServerErrorException('Unexpected response from Plivo after sending SMS.');
}
} catch (error) {
this.logger.error(`Failed to send SMS to ${to}: ${error.message}`, error.stack);
// Consider more specific error handling based on Plivo error codes/types if availablethrownewInternalServerErrorException(`Failed to send SMS via Plivo: ${error.message}`);
}
}
// Potential future methods:// async getMessageStatus(messageUuid: string) { ... }// async handleIncomingSmsWebhook(payload: any) { ... } // For handling incoming messages/opt-outs// async handleDeliveryReportWebhook(payload: any) { ... } // For handling DLRs
}
Understanding Plivo Message Status and Error Codes:
Use TypeORM's forRootAsync to inject ConfigService and retrieve the database URL.
TypeScript
// src/database/database.module.tsimport { Module } from'@nestjs/common';
import { TypeOrmModule } from'@nestjs/typeorm';
import { ConfigModule, ConfigService } from'@nestjs/config';
import { Campaign } from'./entities/campaign.entity';
import { Subscriber } from'./entities/subscriber.entity';
@Module({
imports: [
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => {
const isProduction = configService.get<string>('config.nodeEnv') === 'production';
return {
type: 'postgres',
url: configService.get<string>('config.database.url'),
entities: [Campaign, Subscriber],
synchronize: !isProduction, // Auto-create schema in dev, use migrations in prodlogging: !isProduction ? 'all' : ['error'], // Log SQL in devmigrations: [__dirname + '/migrations/*{.ts,.js}'],
migrationsTableName: 'migrations_history', // Optional: custom migrations table namessl: isProduction
? {
/**
* WARNING: rejectUnauthorized: false is insecure for production environments
* as it disables SSL certificate validation.
* This should only be used if your database provider requires it AND
* you understand the risks (e.g., man-in-the-middle attacks).
* Prefer using CA certificates or platform-specific secure connection methods.
* Example for Heroku/AWS RDS might need this, but investigate secure options first.
*/rejectUnauthorized: false, // <--- SECURITY WARNING// Example using CA cert:// ca: configService.get<string>('DATABASE_CA_CERT'),
}
: false,
}
},
}),
// Optionally, re-export TypeOrmModule.forFeature here if needed by services in this module
],
// No providers or exports needed unless you have database-specific services
})
exportclassDatabaseModule {}
Why use synchronize: false in production?
synchronize: true automatically updates your schema based on entities. This is convenient for development but risky in production:
Accidental data loss: Removing a column from an entity immediately drops it from the database with all data
Schema conflicts: Multiple instances can attempt conflicting schema changes simultaneously
No rollback: Changes are applied immediately without ability to revert
Production downtime: Schema changes can lock tables during peak traffic
Example data loss scenario: Temporarily commenting out a column in your entity and deploying with synchronize: true will drop that column and all its data permanently.
Use migrations for controlled schema updates in production environments.
Setup TypeORM CLI and Migrations
Add TypeORM CLI commands to your package.json.
JSON
// package.json (add to "scripts"){"scripts":{// ... other scripts"build":"nest build","start":"node dist/main","start:dev":"nest start --watch","start:debug":"nest start --debug --watch","start:prod":"node dist/main","lint":"eslint \"{src,apps,libs,test}/**/*.ts\" --fix","test":"jest","test:watch":"jest --watch","test:cov":"jest --coverage","test:debug":"node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand","test:e2e":"jest --config ./test/jest-e2e.json","typeorm":"ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js --dataSource src/database/data-source.ts","migration:generate":"npm run typeorm -- migration:generate src/database/migrations/%npm_config_name%","migration:run":"npm run typeorm -- migration:run","migration:revert":"npm run typeorm -- migration:revert"}}
(Note: Ensure ts-node and potentially tsconfig-paths are installed as dev dependencies: npm install --save-dev ts-node tsconfig-paths or yarn add --dev ts-node tsconfig-paths)
Create a data source file required by the TypeORM CLI.
TypeScript
// src/database/data-source.tsimport { DataSource, DataSourceOptions } from'typeorm';
import * as dotenv from'dotenv';
import * as path from'path';
// Load environment variables from .env file relative to project root
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
exportconstdataSourceOptions: DataSourceOptions = {
type: 'postgres',
url: process.env.DATABASE_URL,
entities: [path.join(__dirname, '/entities/*.entity{.ts,.js}')], // Use path.join for robustnessmigrations: [path.join(__dirname, '/migrations/*{.ts,.js}')],
logging: process.env.NODE_ENV !== 'production' ? ['query', 'error'] : ['error'],
synchronize: false, // Never use synchronize true for migrations generation/runningmigrationsTableName: 'migrations_history',
ssl: process.env.NODE_ENV === 'production'
? {
/**
* WARNING: rejectUnauthorized: false is insecure for production environments.
* See warning in database.module.ts. Match the configuration there.
*/rejectUnauthorized: false, // <--- SECURITY WARNING (Match app module config)// ca: process.env.DATABASE_CA_CERT, // Example
}
: false,
};
const dataSource = newDataSource(dataSourceOptions);
exportdefault dataSource;
Create Database (Docker Example):
Create a docker-compose.yml in your project root for local development:
# Ensure your .env file has the correct DATABASE_URL# The --name argument (-n) specifies the migration file name
npm run migration:generate --name=InitialSchema
# or# yarn migration:generate -n InitialSchema
Common migration generation failures:
No changes detected: Ensure entities are properly imported in data-source.ts
Connection refused: Verify database is running and DATABASE_URL is correct
TypeScript errors: Run npm run build first to compile entities
This creates a migration file in src/database/migrations/. Review the generated SQL before running.
Run Migration:
Bash
npm run migration:run
# or# yarn migration:run
Verify migration success:
Connect to your database and verify tables were created:
Bash
# Using Docker:
docker exec -it plivo-campaign-db psql -U user -d campaign_db -c "\dt"# Should show: campaigns, subscribers, migrations_history tables
This applies the migration, creating the campaigns and subscribers tables.
Building RESTful API endpoints for campaign management
Create the CampaignModule to handle API requests for managing campaigns and triggering sends.
Define DTOs (Data Transfer Objects)
Define DTOs with class-validator decorators for request validation.
The 1,600 character limit is calculated based on SMS encoding standards:
Encoding
Single Segment
Concatenated Segment
Max Segments
Total Chars
GSM-7
160 chars
153 chars
10
1,600
UCS-2 (Unicode)
70 chars
67 chars
11
737
GSM-7 supports: Standard Latin letters (A-Z, a-z), digits (0-9), basic punctuation, and limited special characters.
UCS-2 required for: Emojis (😊, 🎉), non-Latin scripts (中文, العربية, हिन्दी), and special symbols (€, ™).
Even a single emoji or non-GSM character forces the entire message into UCS-2 encoding, reducing your character limit from 1,600 to 737. Plivo automatically handles concatenation and encoding detection (source: Plivo error code 110).
TypeScript
// src/campaign/dto/add-subscriber.dto.tsimport { IsString, IsNotEmpty, IsPhoneNumber, IsOptional, MaxLength } from'class-validator';
exportclassAddSubscriberDto {
@IsPhoneNumber(null) // Use null for region-agnostic phone number validation (basic format check)@IsNotEmpty()
phoneNumber: string; // Should be E.164 format@IsString()
@IsOptional()
@MaxLength(100)
firstName?: string;
@IsString()
@IsOptional()
@MaxLength(100)
lastName?: string;
}
E.164 Phone Number Format:
E.164 is the international telephone numbering standard that ensures each device has a globally unique number (source: E.164 standard).
Format structure: + (plus sign) + country code (1-3 digits) + subscriber number (up to 12 digits)
Maximum total length: 15 digits (excluding the + symbol)
Examples:
US: +14155552671 (country code: 1)
UK: +442071838750 (country code: 44)
Brazil: +551155256325 (country code: 55)
India: +919876543210 (country code: 91)
Phone number normalization helper:
Add this utility function to normalize phone numbers to E.164:
This service handles campaign and subscriber business logic.
TypeScript
// src/campaign/campaign.service.tsimport { Injectable, Logger, NotFoundException, BadRequestException, InternalServerErrorException } from'@nestjs/common';
import { InjectRepository } from'@nestjs/typeorm';
import { Repository } from'typeorm';
import { Campaign } from'../database/entities/campaign.entity';
import { Subscriber, SubscriberStatus } from'../database/entities/subscriber.entity';
import { PlivoService } from'../plivo/plivo.service';
import { CreateCampaignDto } from'./dto/create-campaign.dto';
import { AddSubscriberDto } from'./dto/add-subscriber.dto';
// Import Queue if using advanced queuing (See Section 9)// import { Queue } from 'bull';// import { InjectQueue } from '@nestjs/bull';@Injectable()
exportclassCampaignService {
privatereadonly logger = newLogger(CampaignService.name);
constructor(@InjectRepository(Campaign)
privatecampaignRepository: Repository<Campaign>,
@InjectRepository(Subscriber)
privatesubscriberRepository: Repository<Subscriber>,
privateplivoService: PlivoService,
// Inject Queue if using BullMQ (See Section 9)// @InjectQueue('sms-queue') private smsQueue: Queue,) {}
asynccreateCampaign(createCampaignDto: CreateCampaignDto): Promise<Campaign> {
const campaign = this.campaignRepository.create(createCampaignDto);
const savedCampaign = awaitthis.campaignRepository.save(campaign);
this.logger.log(`Campaign created with ID: ${savedCampaign.id}`);
return savedCampaign;
}
asyncfindAllCampaigns(page: number = 1, limit: number = 20): Promise<{ campaigns: Campaign[]; total: number; page: number; totalPages: number }> {
const [campaigns, total] = awaitthis.campaignRepository.findAndCount({
order: { createdAt: 'DESC' },
skip: (page - 1) * limit,
take: limit,
});
return {
campaigns,
total,
page,
totalPages: Math.ceil(total / limit),
};
}
asyncfindCampaignById(id: string): Promise<Campaign> {
const campaign = awaitthis.campaignRepository.findOneBy({ id });
if (!campaign) {
thrownewNotFoundException(`Campaign with ID ${id} not found`);
}
return campaign;
}
asyncaddSubscriber(addSubscriberDto: AddSubscriberDto): Promise<Subscriber> {
// Normalize phone number to E.164 format// In production, use normalizeToE164() helper hereconst subscriber = this.subscriberRepository.create({
...addSubscriberDto,
status: SubscriberStatus.ACTIVE,
});
try {
const savedSubscriber = awaitthis.subscriberRepository.save(subscriber);
this.logger.log(`Subscriber added: ${savedSubscriber.phoneNumber}`);
return savedSubscriber;
} catch (error) {
if (error.code === '23505') { // PostgreSQL unique violationthrownewBadRequestException(`Phone number ${addSubscriberDto.phoneNumber} already exists`);
}
throw error;
}
}
asyncfindAllSubscribers(page: number = 1, limit: number = 50): Promise<{ subscribers: Subscriber[]; total: number; page: number; totalPages: number }> {
const [subscribers, total] = awaitthis.subscriberRepository.findAndCount({
order: { createdAt: 'DESC' },
skip: (page - 1) * limit,
take: limit,
});
return {
subscribers,
total,
page,
totalPages: Math.ceil(total / limit),
};
}
asyncupdateSubscriberStatus(phoneNumber: string, status: SubscriberStatus): Promise<Subscriber> {
const subscriber = awaitthis.subscriberRepository.findOne({ where: { phoneNumber } });
if (!subscriber) {
thrownewNotFoundException(`Subscriber ${phoneNumber} not found`);
}
subscriber.status = status;
returnthis.subscriberRepository.save(subscriber);
}
asynchandleOptOut(phoneNumber: string): Promise<void> {
this.logger.log(`Processing opt-out for ${phoneNumber}`);
awaitthis.updateSubscriberStatus(phoneNumber, SubscriberStatus.INACTIVE);
}
asyncsendCampaign(campaignId: string): Promise<{ message: string; totalSent: number; failedNumbers: string[] }> {
this.logger.log(`Initiating send for campaign ID: ${campaignId}`);
const campaign = awaitthis.findCampaignById(campaignId);
if (campaign.isSent) {
thrownewBadRequestException(`Campaign ${campaignId} has already been sent.`);
}
// Fetch active subscribers with pagination for large listsconst subscribers = awaitthis.subscriberRepository.find({
where: { status: SubscriberStatus.ACTIVE },
select: ['phoneNumber'],
});
if (subscribers.length === 0) {
this.logger.warn(`No active subscribers found for campaign ${campaignId}.`);
return { message: 'No active subscribers to send to.', totalSent: 0, failedNumbers: [] };
}
this.logger.log(`Found ${subscribers.length} active subscribers for campaign ${campaignId}.`);
let sentCount = 0;
constfailedNumbers: string[] = [];
// --- Approach 1: Parallel Sending with Promise.all ---// WARNING: This approach sends all messages concurrently. It WILL likely hit Plivo's rate limits// for any reasonably sized list. It can overwhelm the Plivo API and lead to failures.// USE WITH EXTREME CAUTION and only for very small lists or low rate limits.// The QUEUE approach below is STRONGLY RECOMMENDED for production.this.logger.warn(`Using Promise.all for sending – This is NOT recommended for production due to rate limits!`);
const sendPromises = subscribers.map(async (subscriber) => {
try {
awaitthis.plivoService.sendSms(subscriber.phoneNumber, campaign.message);
return { success: true, number: subscriber.phoneNumber };
} catch (error) {
this.logger.error(`Failed sending to ${subscriber.phoneNumber}: ${error.message}`);
return { success: false, number: subscriber.phoneNumber };
}
});
const results = awaitPromise.all(sendPromises);
results.forEach(result => {
if (result.success) {
sentCount++;
} else {
failedNumbers.push(result.number);
}
});
// --- End Approach 1 ---// --- Approach 2: Basic Sequential Sending (Better than Promise.all for rate limits, but SLOW) ---/*
this.logger.log(`Using sequential sending loop.`);
for (const subscriber of subscribers) {
try {
await this.plivoService.sendSms(subscriber.phoneNumber, campaign.message);
sentCount++;
// Basic rate limiting delay – adjust based on your Plivo MPS limit
// Example: If limit is 1 MPS, delay by 1000 ms. If 10 MPS, delay by 100 ms.
await new Promise(resolve => setTimeout(resolve, 100)); // e.g., 100 ms delay for ~10 MPS
} catch (error) {
this.logger.error(`Failed sending to ${subscriber.phoneNumber}: ${error.message}`);
failedNumbers.push(subscriber.phoneNumber);
}
}
*/// --- End Approach 2 ---// --- Approach 3: Recommended Production Approach – Using a Queue (See Section 9) ---/*
this.logger.log(`Adding ${subscribers.length} SMS jobs to the queue.`);
for (const subscriber of subscribers) {
try {
await this.smsQueue.add('send-sms-job', {
to: subscriber.phoneNumber,
text: campaign.message,
campaignId: campaignId,
}, {
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: true,
removeOnFail: 5000,
});
} catch (error) {
this.logger.error(`Failed to add job for ${subscriber.phoneNumber} to queue: ${error.message}`);
failedNumbers.push(subscriber.phoneNumber);
}
}
sentCount = subscribers.length - failedNumbers.length;
this.logger.log(`Added ${sentCount} SMS jobs to the queue for campaign ${campaignId}. Failures adding: ${failedNumbers.length}`);
*/// --- End Approach 3 ---// Mark campaign as sent (use transaction in production)
campaign.isSent = true;
awaitthis.campaignRepository.save(campaign);
return { message: 'Campaign send initiated.', totalSent: sentCount, failedNumbers };
}
}
Transaction Management for Campaign Sending:
For production, wrap the campaign send operation in a database transaction to ensure data consistency:
TypeScript
// Example using TypeORM transactionsasyncsendCampaignWithTransaction(campaignId: string): Promise<any> {
returnawaitthis.campaignRepository.manager.transaction(async (transactionalEntityManager) => {
const campaign = await transactionalEntityManager.findOne(Campaign, { where: { id: campaignId } });
if (!campaign || campaign.isSent) {
thrownewBadRequestException('Campaign already sent or not found');
}
// Send messages...
campaign.isSent = true;
await transactionalEntityManager.save(campaign);
return { message: 'Campaign sent successfully' };
});
}
Frequently Asked Questions (FAQ)
How do I send bulk SMS messages using Plivo and NestJS?
Integrate the Plivo Node.js SDK into your NestJS application by creating a dedicated Plivo service that wraps the SDK functionality. For bulk sending, implement a queue-based approach using @nestjs/bull with Redis to handle rate limiting and retries. Queue each SMS job individually with configurable retry attempts and exponential backoff. This prevents overwhelming the Plivo API and ensures reliable message delivery at scale.
What's the best way to handle Plivo rate limits in NestJS?
Use a message queue system like BullMQ with Redis to control sending rate. Configure your queue processor to respect Plivo's messages per second (MPS) limit by processing jobs with appropriate delays. Avoid using Promise.all() for bulk sending as it sends all messages concurrently and will quickly hit rate limits. Sequential sending with delays works but is slow – queues provide the optimal balance of speed, reliability, and rate limit compliance.
How do I store SMS campaign data in NestJS with TypeORM?
Define TypeORM entities for campaigns and subscribers with proper relationships and indexes. Use @PrimaryGeneratedColumn('uuid') for unique identifiers, @Column decorators for fields, and @CreateDateColumn/@UpdateDateColumn for timestamps. Configure TypeORM asynchronously using TypeOrmModule.forRootAsync() to inject ConfigService for environment-based configuration. Use migrations (npm run migration:generate) for schema changes in production rather than synchronize: true.
What environment variables do I need for Plivo integration in NestJS?
You need three essential Plivo credentials: PLIVO_AUTH_ID, PLIVO_AUTH_TOKEN, and PLIVO_SOURCE_NUMBER. Find your Auth ID and Auth Token on the Plivo console dashboard. Your source number must be an SMS-enabled Plivo phone number purchased through the console. Local numbers cost $0.50/month, toll-free numbers cost $1.00/month. Store these in a .env file (never commit to version control) and load them using @nestjs/config with the ConfigModule.forRoot() method.
How do I handle SMS delivery failures in a Plivo NestJS application?
Implement comprehensive error handling in your Plivo service using try-catch blocks. Log failures with contextual information (phone number, error message, campaign ID). When using queues, configure automatic retries with exponential backoff (e.g., 3 attempts with increasing delays). Track failed phone numbers in your response object and store delivery status in your database. Consider implementing webhook endpoints to receive Plivo delivery reports for real-time status updates. Common Plivo error codes include: 30 (spam detected), 40 (invalid source), 50 (invalid destination), 200 (opt-out), and 420 (message expired).
Should I use TypeORM synchronize in production for my SMS campaign app?
Never use synchronize: true in production. This setting automatically modifies your database schema based on entity changes, which can cause data loss during unintended schema updates. Instead, use TypeORM migrations for controlled schema changes. Generate migrations with npm run migration:generate, review the SQL, and apply them with npm run migration:run. This provides version control for your database schema and prevents accidental data loss.
How do I validate phone numbers in NestJS DTOs for SMS campaigns?
Use the class-validator package with the @IsPhoneNumber() decorator in your DTOs. Import validators: @IsPhoneNumber(null) for region-agnostic validation, @IsNotEmpty() to require the field, and @IsString() for type checking. Store phone numbers in E.164 format (e.g., +14155551234) in your database using a @Column({ length: 20 }) with a unique index. E.164 format consists of a plus sign, country code (1-3 digits), and subscriber number, with a maximum total length of 15 digits. Use the libphonenumber-js library to normalize user input to E.164 format before storing.
What's the difference between development and production database configuration in NestJS?
In development, use synchronize: true for automatic schema updates and logging: 'all' to see SQL queries. In production, set synchronize: false, use migrations for schema changes, and limit logging to errors (logging: ['error']). Configure SSL for production database connections, though be cautious with rejectUnauthorized: false as it disables certificate validation. Use environment variables to toggle these settings based on NODE_ENV.
You now have a solid foundation for building production-ready SMS marketing campaigns with NestJS and Plivo. Here's what to implement next:
Step
Task
Estimated Time
Dependencies
1
Add Queue Processing
4-6 hours
Redis installed
2
Configure Webhooks
2-3 hours
Public URL/ngrok
3
Implement Security
3-4 hours
Step 1 complete
4
Add Monitoring
2-3 hours
-
5
Create Campaign Scheduler
2-3 hours
Step 1 complete
6
Build Admin Dashboard
8-12 hours
All backend complete
7
Add Segmentation
4-6 hours
Step 2 complete
8
Set Up Testing
6-8 hours
-
9
Deploy to Production
4-6 hours
Docker configured
10
Monitor Performance
Ongoing
Steps 3,4 complete
Implementation order rationale:
Queue processing (Step 1) is foundational for reliable bulk sending
Webhooks (Step 2) enable delivery tracking and opt-out handling
Security (Step 3) should be added before any public deployment
Testing (Step 8) can be done in parallel with feature development
Deployment (Step 9) requires queue processing and security to be complete
This guide provides the core infrastructure – extend it based on your specific business requirements and scale considerations.
Frequently asked questions
Integrate the Plivo Node.js SDK into your NestJS application. Create a Plivo service to encapsulate the sending logic, and use a queue like BullMQ to manage sending to large lists of subscribers, ensuring compliance with Plivo's rate limits and reliable delivery. Consider strategies for handling failed sends and implementing retries based on Plivo's error responses.
Plivo is a communications platform API that enables sending SMS messages. The Plivo Node.js SDK is integrated into a NestJS application to handle the actual sending of messages to subscribers as part of an SMS marketing campaign application.
NestJS provides a robust, modular, and scalable architecture well-suited for complex backend applications like SMS campaign management. Its TypeScript support and dependency injection features promote code maintainability and testability.
Use a message queue (like BullMQ) for sending bulk SMS, especially when dealing with many subscribers or if rate limiting is a concern. Queues provide reliable delivery and graceful handling of failures, while sequential or parallel sending can lead to rate limit issues or application crashes. This allows for robust campaign send logic, tracking, and retry mechanisms.
While technically possible with Promise.all, sending SMS in parallel with Plivo is not recommended for production due to rate limiting. Sending too many concurrent requests can overwhelm the Plivo API and cause failures. Favor a queue-based approach (e.g. using bull or bullmq) with controlled concurrency for reliability and optimal throughput.
You can install the Plivo Node.js SDK in your NestJS project using npm or yarn with the command npm install plivo-node or yarn add plivo-node. This package provides the necessary functions to interact with the Plivo API for sending SMS messages and other communication tasks.
TypeORM is an Object-Relational Mapper (ORM) that simplifies database interactions in the NestJS application. It is used to define entities (like Campaign and Subscriber), manage database connections, create migrations for schema updates, and execute queries to store and retrieve campaign and subscriber data from a PostgreSQL database.
You'll need Node.js (LTS recommended), npm or yarn, a Plivo account, access to a PostgreSQL database, and a basic understanding of TypeScript, NestJS core concepts, and REST APIs. Docker is optional but recommended for containerization and local database setup.
Subscribers are managed within the NestJS application through API endpoints that interact with a database. The application stores subscriber phone numbers (ideally in E.164 format), along with optional information like first and last names, and maintains status information such as active, inactive, or pending. Key functionality includes adding, updating, and querying subscribers, and associating them with specific campaigns.
The guide recommends PostgreSQL, a powerful and open-source object-relational database system, to store campaign and subscriber data. It integrates seamlessly with TypeORM, the ORM used in the project, allowing for efficient and type-safe database operations. TypeORM is configured to handle various database-specific properties and connection parameters.
Create a .env file in the root of your NestJS project. Store your Plivo AUTH_ID, AUTH_TOKEN, and the PLIVO_SOURCE_NUMBER (your Plivo phone number) as environment variables in this file. Never commit the .env file to version control, as it contains sensitive information. Use the @nestjs/config package to load and access these environment variables securely within your application.
Using migrations for database schema updates in a production environment ensures controlled and reversible changes to your database structure. Unlike synchronize: true, which directly modifies the schema based on entities, migrations provide a history of changes, allowing you to safely apply or revert updates without risking data loss or inconsistencies.
Follow a modular approach by creating separate modules for core features like Plivo integration (PlivoModule), campaign management (CampaignModule), database interaction (DatabaseModule), and shared utilities. This improves code organization, maintainability, and allows for independent testing of different parts of the application.
DTOs (Data Transfer Objects) are used for request validation and data transformation. They define the structure of data expected by API endpoints, along with validation rules using decorators from class-validator. DTOs improve code clarity, maintainability, and help prevent common security vulnerabilities like injection attacks by sanitizing input data.
Build a production-ready SMS marketing campaign application using NestJS and Plivo. This comprehensive guide walks you through creating a scalable backend system that manages subscriber lists, sends bulk SMS messages with proper rate limiting, and handles failures gracefully.
By the end, you'll have a fully functional NestJS application integrated with the Plivo SMS API that stores campaign data in PostgreSQL, implements queue-based message processing, and follows industry best practices for security and observability. This tutorial solves the common challenge of building reliable, high-volume SMS marketing systems with modern Node.js frameworks.
Prerequisites:
Project Overview and Goals
Build a NestJS application that serves as the backend for managing and executing SMS marketing campaigns powered by Plivo.
Key Goals:
Technologies Used:
System Architecture:
(This is a simplified text-based representation)
Final Outcome:
A deployable NestJS application with API endpoints to manage campaigns and trigger bulk SMS sends via Plivo.
Setting up the project
Create a new NestJS project and install the necessary dependencies.
Install NestJS CLI
Install the NestJS CLI if you don't have it:
npm install -g @nestjs/cli # or yarn global add @nestjs/cliCreate New Project
nest new plivo-sms-campaign-app cd plivo-sms-campaign-appInstall Dependencies
Install several packages for configuration, database interaction, Plivo integration, validation, logging, and potentially queuing/scheduling.
# Using npm npm install @nestjs/config @nestjs/typeorm typeorm pg plivo class-validator class-transformer nestjs-pino pino-http @nestjs/schedule npm install --save-dev @types/cron # Potentially needed for TypeORM CLI depending on setup: npm install --save-dev tsconfig-paths # For Queuing (Optional but Recommended – See Section 9): # npm install @nestjs/bull bull ioredis # For Security (Optional – See Section 7): # npm install helmet nestjs-rate-limiter rate-limiter-flexible @nestjs/jwt @nestjs/passport passport passport-jwt # npm install --save-dev @types/passport-jwt # For Monitoring (Optional – See Section 10): # npm install @nestjs/terminus @nestjs/axios prom-client @willsoto/nestjs-prometheus # npm install @sentry/node @sentry/tracing # Using yarn yarn add @nestjs/config @nestjs/typeorm typeorm pg plivo class-validator class-transformer nestjs-pino pino-http @nestjs/schedule @types/cron yarn add --dev tsconfig-paths # yarn add @nestjs/bull bull ioredis # yarn add helmet nestjs-rate-limiter rate-limiter-flexible @nestjs/jwt @nestjs/passport passport passport-jwt # yarn add --dev @types/passport-jwt # yarn add @nestjs/terminus @nestjs/axios prom-client @willsoto/nestjs-prometheus # yarn add @sentry/node @sentry/tracing@nestjs/config: For handling environment variables.@nestjs/typeorm,typeorm,pg: For database interaction with PostgreSQL using TypeORM.plivo: The official Plivo SDK for Node.js.class-validator,class-transformer: For request payload validation using DTOs.nestjs-pino,pino-http: For efficient, structured logging.@nestjs/schedule,@types/cron: For potentially scheduling tasks or implementing simple retry mechanisms (we'll touch on advanced queuing later).tsconfig-paths: Potentially required by TypeORM CLI scripts if using path aliases.Project Structure
NestJS promotes a modular structure. Create modules for core features:
src/app.controller.tsapp.module.tsapp.service.tsmain.tsconfig/# Configuration setupconfiguration.tsdatabase/# Database module and entitiesdatabase.module.tsentities/campaign.entity.tssubscriber.entity.tsmigrations/# TypeORM migrations (generated later)data-source.ts# Data source for CLIplivo/# Plivo integration moduleplivo.module.tsplivo.service.tscampaign/# Campaign management modulecampaign.module.tscampaign.controller.tscampaign.service.tsdto/create-campaign.dto.tsadd-subscriber.dto.ts# Added for subscriber creationsms-queue.processor.ts# Optional: If using BullMQ (Section 9)shared/# Shared utilities/modules (e.g., logging)logger/logger.module.tscommon/# Common decorators, filters, guards, etc.filters/http-exception.filter.tsguards/api-key.guard.ts# Optional: If using API Key auth (Section 7)health/# Optional: Health check module (Section 10)health.controller.tshealth.module.tsCreate modules progressively: start with database and Plivo modules (foundational), then campaign module (business logic), followed by optional modules for security, health checks, and queuing as needed.
Environment Configuration
Create a
.envfile in the project root for environment variables. Never commit this file to version control.# .env # Plivo Credentials PLIVO_AUTH_ID=YOUR_PLIVO_AUTH_ID PLIVO_AUTH_TOKEN=YOUR_PLIVO_AUTH_TOKEN PLIVO_SOURCE_NUMBER=+14155551234 # Your Plivo phone number # Database DATABASE_URL=postgresql://user:password@localhost:5432/campaign_db # Application NODE_ENV=development PORT=3000 LOG_LEVEL=info # Pino log level (trace, debug, info, warn, error, fatal) # Security (Optional – See Section 7) # API_KEY=YOUR_SECRET_API_KEY # Redis (Optional – For Queues – See Section 9) # REDIS_HOST=localhost # REDIS_PORT=6379How to get Plivo Credentials and Phone Numbers:
Sign up and get credentials: Log in to your Plivo console (https://console.plivo.com/). Your
AUTH IDandAUTH TOKENdisplay on the main dashboard overview page.Purchase a phone number: Navigate to
Messaging→Phone Numbers→Your Numbersto find or purchase a Plivo number to use as thePLIVO_SOURCE_NUMBER.Number types and pricing (source: Plivo US pricing):
Verification requirements: Most number types require no upfront verification, but toll-free numbers in the US require verification for high-volume messaging. For 10DLC (long code) campaigns in the US, complete brand and campaign registration (10DLC compliance).
Configure NestJS to load these variables.
// src/config/configuration.ts import { registerAs } from '@nestjs/config'; export default registerAs('config', () => ({ // Use registerAs for better structure if needed elsewhere port: parseInt(process.env.PORT, 10) || 3000, nodeEnv: process.env.NODE_ENV || 'development', logLevel: process.env.LOG_LEVEL || 'info', apiKey: process.env.API_KEY, // Add API_KEY here plivo: { authId: process.env.PLIVO_AUTH_ID, authToken: process.env.PLIVO_AUTH_TOKEN, sourceNumber: process.env.PLIVO_SOURCE_NUMBER, }, database: { url: process.env.DATABASE_URL, // Add SSL config if needed directly from env vars // ssl: process.env.DATABASE_SSL === 'true', // rejectUnauthorized: process.env.DATABASE_REJECT_UNAUTHORIZED !== 'false', // Example }, redis: { // Add Redis config if using BullMQ host: process.env.REDIS_HOST || 'localhost', port: parseInt(process.env.REDIS_PORT, 10) || 6379, }, }));Verify configuration is loaded:
Add a simple log statement in
main.tsafter creating the app to verify environment variables are loaded:// src/main.ts (add after app creation) const configService = app.get(ConfigService); console.log('Config loaded:', { port: configService.get('config.port'), nodeEnv: configService.get('config.nodeEnv'), plivoConfigured: !!configService.get('config.plivo.authId'), });Update
AppModuleto useConfigModule.// src/app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; // Import ConfigService if needed globally import { ScheduleModule } from '@nestjs/schedule'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import configuration from './config/configuration'; import { DatabaseModule } from './database/database.module'; import { PlivoModule } from './plivo/plivo.module'; import { CampaignModule } from './campaign/campaign.module'; import { LoggerModule } from './shared/logger/logger.module'; // We'll create this next // Optional Modules: // import { HealthModule } from './health/health.module'; // import { RateLimiterModule, RateLimiterGuard } from 'nestjs-rate-limiter'; // import { APP_GUARD } from '@nestjs/core'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, // Make config available globally load: [configuration], }), ScheduleModule.forRoot(), // For scheduled tasks / simple retries LoggerModule, // Add LoggerModule DatabaseModule, PlivoModule, CampaignModule, // HealthModule, // Optional: Uncomment if using Health Checks (Section 10) // Optional: Rate Limiting (Section 7) /* RateLimiterModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], useFactory: (configService: ConfigService) => ({ // Configure based on configService if needed points: 100, // Example: 100 requests duration: 60, // Example: per 60 seconds }), }), */ ], controllers: [AppController], providers: [ AppService, // Optional: Apply Rate Limiter Globally (Section 7) /* { provide: APP_GUARD, useClass: RateLimiterGuard, }, */ ], }) export class AppModule {}This completes the foundation with configuration management.
Integrating the Plivo SMS API with NestJS
Encapsulate Plivo interactions within a dedicated service.
Create Plivo Module and Service
// src/plivo/plivo.module.ts import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { PlivoService } from './plivo.service'; @Module({ imports: [ConfigModule], // Import ConfigModule if needed directly, or rely on global providers: [PlivoService], exports: [PlivoService], // Export the service for other modules to use }) export class PlivoModule {}// src/plivo/plivo.service.ts import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as plivo from 'plivo'; @Injectable() export class PlivoService { private readonly logger = new Logger(PlivoService.name); private client: plivo.Client; constructor(private configService: ConfigService) { const authId = this.configService.get<string>('config.plivo.authId'); const authToken = this.configService.get<string>('config.plivo.authToken'); if (!authId || !authToken) { this.logger.error('Plivo Auth ID or Auth Token missing in configuration.'); throw new InternalServerErrorException('Plivo credentials are not configured.'); } // The plivo-node library might internally handle basic retries, // but we initialize the client here. this.client = new plivo.Client(authId, authToken); this.logger.log('Plivo Client Initialized'); } /** * Sends an SMS message using Plivo. * @param to The destination phone number (E.164 format recommended). * @param text The message content. * @returns The message UUID from Plivo upon successful queuing. * @throws InternalServerErrorException if sending fails. */ async sendSms(to: string, text: string): Promise<string> { const sourceNumber = this.configService.get<string>('config.plivo.sourceNumber'); if (!sourceNumber) { this.logger.error('Plivo Source Number missing in configuration.'); throw new InternalServerErrorException('Plivo source number is not configured.'); } this.logger.debug(`Attempting to send SMS to ${to}`); try { const response = await this.client.messages.create({ src: sourceNumber, dst: to, text: text, // You can add a URL for delivery reports if needed (See Section 8 & 11): // url: 'https://yourapp.com/plivo/delivery-report', // method: 'POST' }); // NOTE: Verify the structure of the response object with your SDK version. // Plivo API returns messageUuid as an array. Accessing [0] assumes success and a non-empty array. // Error handling might need to inspect the response structure more carefully. if (response.messageUuid && Array.isArray(response.messageUuid) && response.messageUuid.length > 0) { this.logger.log(`SMS queued successfully to ${to}. Message UUID: ${response.messageUuid[0]}`); return response.messageUuid[0]; } else { // Log unexpected response structure this.logger.error(`Unexpected response structure from Plivo for ${to}: ${JSON.stringify(response)}`); throw new InternalServerErrorException('Unexpected response from Plivo after sending SMS.'); } } catch (error) { this.logger.error(`Failed to send SMS to ${to}: ${error.message}`, error.stack); // Consider more specific error handling based on Plivo error codes/types if available throw new InternalServerErrorException(`Failed to send SMS via Plivo: ${error.message}`); } } // Potential future methods: // async getMessageStatus(messageUuid: string) { ... } // async handleIncomingSmsWebhook(payload: any) { ... } // For handling incoming messages/opt-outs // async handleDeliveryReportWebhook(payload: any) { ... } // For handling DLRs }Understanding Plivo Message Status and Error Codes:
Plivo messages go through several states (source: Plivo Message Object docs):
Message States:
queued– Initial state when message is acceptedsent– Successfully passed to downstream carrierdelivered– Confirmed delivery (requires carrier support)undelivered– Failed to deliver after being sentfailed– Internal error before reaching carrierread– User read the message (WhatsApp only)Common Error Codes (source: Plivo Error Codes):
Why this approach?
ConfigServiceto securely access credentials from environment variables.try...catchblocks, logs errors, and throws standard NestJS exceptions for consistent API error responses.Designing the database schema with TypeORM
Store campaigns and subscribers using TypeORM entities.
Define Entities
// src/database/entities/campaign.entity.ts import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm'; import { Subscriber } from './subscriber.entity'; @Entity('campaigns') export class Campaign { @PrimaryGeneratedColumn('uuid') id: string; @Column({ length: 255 }) name: string; @Column('text') message: string; @Column({ default: false }) isSent: boolean; // Or could track status: 'draft', 'sending', 'sent', 'failed' // Relationship: A campaign can have many subscribers (if tracking sends per campaign) // Or subscribers might be independent and targeted during the send operation. // For simplicity here, we won't link directly but query subscribers separately. // @OneToMany(() => CampaignSubscriber, campaignSubscriber => campaignSubscriber.campaign) // campaignSubscribers: CampaignSubscriber[]; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; }// src/database/entities/subscriber.entity.ts import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; export enum SubscriberStatus { ACTIVE = 'active', INACTIVE = 'inactive', // e.g., opted-out PENDING = 'pending', // e.g., needs confirmation } @Entity('subscribers') export class Subscriber { @PrimaryGeneratedColumn('uuid') id: string; @Index({ unique: true }) // Ensure phone numbers are unique @Column({ length: 20, unique: true }) // E.164 max length is around 15, add buffer phoneNumber: string; @Column({ length: 100, nullable: true }) firstName?: string; @Column({ length: 100, nullable: true }) lastName?: string; @Column({ type: 'enum', enum: SubscriberStatus, default: SubscriberStatus.ACTIVE, }) status: SubscriberStatus; // Add tags or list associations if needed for segmentation later // @Column('simple-array', { nullable: true }) // tags?: string[]; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; }Configure Database Module
Use TypeORM's
forRootAsyncto injectConfigServiceand retrieve the database URL.// src/database/database.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { Campaign } from './entities/campaign.entity'; import { Subscriber } from './entities/subscriber.entity'; @Module({ imports: [ TypeOrmModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], useFactory: (configService: ConfigService) => { const isProduction = configService.get<string>('config.nodeEnv') === 'production'; return { type: 'postgres', url: configService.get<string>('config.database.url'), entities: [Campaign, Subscriber], synchronize: !isProduction, // Auto-create schema in dev, use migrations in prod logging: !isProduction ? 'all' : ['error'], // Log SQL in dev migrations: [__dirname + '/migrations/*{.ts,.js}'], migrationsTableName: 'migrations_history', // Optional: custom migrations table name ssl: isProduction ? { /** * WARNING: rejectUnauthorized: false is insecure for production environments * as it disables SSL certificate validation. * This should only be used if your database provider requires it AND * you understand the risks (e.g., man-in-the-middle attacks). * Prefer using CA certificates or platform-specific secure connection methods. * Example for Heroku/AWS RDS might need this, but investigate secure options first. */ rejectUnauthorized: false, // <--- SECURITY WARNING // Example using CA cert: // ca: configService.get<string>('DATABASE_CA_CERT'), } : false, } }, }), // Optionally, re-export TypeOrmModule.forFeature here if needed by services in this module ], // No providers or exports needed unless you have database-specific services }) export class DatabaseModule {}Why use
synchronize: falsein production?synchronize: trueautomatically updates your schema based on entities. This is convenient for development but risky in production:Example data loss scenario: Temporarily commenting out a column in your entity and deploying with
synchronize: truewill drop that column and all its data permanently.Use migrations for controlled schema updates in production environments.
Setup TypeORM CLI and Migrations
Add TypeORM CLI commands to your
package.json.// package.json (add to "scripts") { "scripts": { // ... other scripts "build": "nest build", "start": "node dist/main", "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "jest", "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js --dataSource src/database/data-source.ts", "migration:generate": "npm run typeorm -- migration:generate src/database/migrations/%npm_config_name%", "migration:run": "npm run typeorm -- migration:run", "migration:revert": "npm run typeorm -- migration:revert" } }(Note: Ensure
ts-nodeand potentiallytsconfig-pathsare installed as dev dependencies:npm install --save-dev ts-node tsconfig-pathsoryarn add --dev ts-node tsconfig-paths)Create a data source file required by the TypeORM CLI.
// src/database/data-source.ts import { DataSource, DataSourceOptions } from 'typeorm'; import * as dotenv from 'dotenv'; import * as path from 'path'; // Load environment variables from .env file relative to project root dotenv.config({ path: path.resolve(__dirname, '../../.env') }); export const dataSourceOptions: DataSourceOptions = { type: 'postgres', url: process.env.DATABASE_URL, entities: [path.join(__dirname, '/entities/*.entity{.ts,.js}')], // Use path.join for robustness migrations: [path.join(__dirname, '/migrations/*{.ts,.js}')], logging: process.env.NODE_ENV !== 'production' ? ['query', 'error'] : ['error'], synchronize: false, // Never use synchronize true for migrations generation/running migrationsTableName: 'migrations_history', ssl: process.env.NODE_ENV === 'production' ? { /** * WARNING: rejectUnauthorized: false is insecure for production environments. * See warning in database.module.ts. Match the configuration there. */ rejectUnauthorized: false, // <--- SECURITY WARNING (Match app module config) // ca: process.env.DATABASE_CA_CERT, // Example } : false, }; const dataSource = new DataSource(dataSourceOptions); export default dataSource;Create Database (Docker Example):
Create a
docker-compose.ymlin your project root for local development:version: '3.8' services: postgres: image: postgres:14-alpine container_name: plivo-campaign-db restart: always environment: POSTGRES_DB: campaign_db POSTGRES_USER: user POSTGRES_PASSWORD: password ports: - '5432:5432' volumes: - postgres_data:/var/lib/postgresql/data volumes: postgres_data:Start the database:
docker-compose up -dGenerate Initial Migration:
# Ensure your .env file has the correct DATABASE_URL # The --name argument (-n) specifies the migration file name npm run migration:generate --name=InitialSchema # or # yarn migration:generate -n InitialSchemaCommon migration generation failures:
data-source.tsDATABASE_URLis correctnpm run buildfirst to compile entitiesThis creates a migration file in
src/database/migrations/. Review the generated SQL before running.Run Migration:
npm run migration:run # or # yarn migration:runVerify migration success:
Connect to your database and verify tables were created:
# Using Docker: docker exec -it plivo-campaign-db psql -U user -d campaign_db -c "\dt" # Should show: campaigns, subscribers, migrations_history tablesThis applies the migration, creating the
campaignsandsubscriberstables.Building RESTful API endpoints for campaign management
Create the
CampaignModuleto handle API requests for managing campaigns and triggering sends.Define DTOs (Data Transfer Objects)
Define DTOs with
class-validatordecorators for request validation.// src/campaign/dto/create-campaign.dto.ts import { IsString, MinLength, MaxLength, IsNotEmpty } from 'class-validator'; export class CreateCampaignDto { @IsString() @IsNotEmpty() @MinLength(3) @MaxLength(255) name: string; @IsString() @IsNotEmpty() @MinLength(5) @MaxLength(1600) // Max length for concatenated SMS (10 segments × 160 GSM-7 chars) message: string; }SMS Message Encoding Limits:
The 1,600 character limit is calculated based on SMS encoding standards:
GSM-7 supports: Standard Latin letters (A-Z, a-z), digits (0-9), basic punctuation, and limited special characters.
UCS-2 required for: Emojis (😊, 🎉), non-Latin scripts (中文, العربية, हिन्दी), and special symbols (€, ™).
Even a single emoji or non-GSM character forces the entire message into UCS-2 encoding, reducing your character limit from 1,600 to 737. Plivo automatically handles concatenation and encoding detection (source: Plivo error code 110).
// src/campaign/dto/add-subscriber.dto.ts import { IsString, IsNotEmpty, IsPhoneNumber, IsOptional, MaxLength } from 'class-validator'; export class AddSubscriberDto { @IsPhoneNumber(null) // Use null for region-agnostic phone number validation (basic format check) @IsNotEmpty() phoneNumber: string; // Should be E.164 format @IsString() @IsOptional() @MaxLength(100) firstName?: string; @IsString() @IsOptional() @MaxLength(100) lastName?: string; }E.164 Phone Number Format:
E.164 is the international telephone numbering standard that ensures each device has a globally unique number (source: E.164 standard).
Format structure:
+(plus sign) +country code(1-3 digits) +subscriber number(up to 12 digits)Maximum total length: 15 digits (excluding the + symbol)
Examples:
+14155552671(country code: 1)+442071838750(country code: 44)+551155256325(country code: 55)+919876543210(country code: 91)Phone number normalization helper:
Add this utility function to normalize phone numbers to E.164:
// src/common/utils/phone.util.ts import { parsePhoneNumber, CountryCode } from 'libphonenumber-js'; export function normalizeToE164(phoneNumber: string, defaultCountry: CountryCode = 'US'): string { try { const parsed = parsePhoneNumber(phoneNumber, defaultCountry); if (!parsed || !parsed.isValid()) { throw new Error('Invalid phone number'); } return parsed.format('E.164'); } catch (error) { throw new Error(`Failed to normalize phone number: ${error.message}`); } } // Install: npm install libphonenumber-jsCreate Campaign Service
This service handles campaign and subscriber business logic.
// src/campaign/campaign.service.ts import { Injectable, Logger, NotFoundException, BadRequestException, InternalServerErrorException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Campaign } from '../database/entities/campaign.entity'; import { Subscriber, SubscriberStatus } from '../database/entities/subscriber.entity'; import { PlivoService } from '../plivo/plivo.service'; import { CreateCampaignDto } from './dto/create-campaign.dto'; import { AddSubscriberDto } from './dto/add-subscriber.dto'; // Import Queue if using advanced queuing (See Section 9) // import { Queue } from 'bull'; // import { InjectQueue } from '@nestjs/bull'; @Injectable() export class CampaignService { private readonly logger = new Logger(CampaignService.name); constructor( @InjectRepository(Campaign) private campaignRepository: Repository<Campaign>, @InjectRepository(Subscriber) private subscriberRepository: Repository<Subscriber>, private plivoService: PlivoService, // Inject Queue if using BullMQ (See Section 9) // @InjectQueue('sms-queue') private smsQueue: Queue, ) {} async createCampaign(createCampaignDto: CreateCampaignDto): Promise<Campaign> { const campaign = this.campaignRepository.create(createCampaignDto); const savedCampaign = await this.campaignRepository.save(campaign); this.logger.log(`Campaign created with ID: ${savedCampaign.id}`); return savedCampaign; } async findAllCampaigns(page: number = 1, limit: number = 20): Promise<{ campaigns: Campaign[]; total: number; page: number; totalPages: number }> { const [campaigns, total] = await this.campaignRepository.findAndCount({ order: { createdAt: 'DESC' }, skip: (page - 1) * limit, take: limit, }); return { campaigns, total, page, totalPages: Math.ceil(total / limit), }; } async findCampaignById(id: string): Promise<Campaign> { const campaign = await this.campaignRepository.findOneBy({ id }); if (!campaign) { throw new NotFoundException(`Campaign with ID ${id} not found`); } return campaign; } async addSubscriber(addSubscriberDto: AddSubscriberDto): Promise<Subscriber> { // Normalize phone number to E.164 format // In production, use normalizeToE164() helper here const subscriber = this.subscriberRepository.create({ ...addSubscriberDto, status: SubscriberStatus.ACTIVE, }); try { const savedSubscriber = await this.subscriberRepository.save(subscriber); this.logger.log(`Subscriber added: ${savedSubscriber.phoneNumber}`); return savedSubscriber; } catch (error) { if (error.code === '23505') { // PostgreSQL unique violation throw new BadRequestException(`Phone number ${addSubscriberDto.phoneNumber} already exists`); } throw error; } } async findAllSubscribers(page: number = 1, limit: number = 50): Promise<{ subscribers: Subscriber[]; total: number; page: number; totalPages: number }> { const [subscribers, total] = await this.subscriberRepository.findAndCount({ order: { createdAt: 'DESC' }, skip: (page - 1) * limit, take: limit, }); return { subscribers, total, page, totalPages: Math.ceil(total / limit), }; } async updateSubscriberStatus(phoneNumber: string, status: SubscriberStatus): Promise<Subscriber> { const subscriber = await this.subscriberRepository.findOne({ where: { phoneNumber } }); if (!subscriber) { throw new NotFoundException(`Subscriber ${phoneNumber} not found`); } subscriber.status = status; return this.subscriberRepository.save(subscriber); } async handleOptOut(phoneNumber: string): Promise<void> { this.logger.log(`Processing opt-out for ${phoneNumber}`); await this.updateSubscriberStatus(phoneNumber, SubscriberStatus.INACTIVE); } async sendCampaign(campaignId: string): Promise<{ message: string; totalSent: number; failedNumbers: string[] }> { this.logger.log(`Initiating send for campaign ID: ${campaignId}`); const campaign = await this.findCampaignById(campaignId); if (campaign.isSent) { throw new BadRequestException(`Campaign ${campaignId} has already been sent.`); } // Fetch active subscribers with pagination for large lists const subscribers = await this.subscriberRepository.find({ where: { status: SubscriberStatus.ACTIVE }, select: ['phoneNumber'], }); if (subscribers.length === 0) { this.logger.warn(`No active subscribers found for campaign ${campaignId}.`); return { message: 'No active subscribers to send to.', totalSent: 0, failedNumbers: [] }; } this.logger.log(`Found ${subscribers.length} active subscribers for campaign ${campaignId}.`); let sentCount = 0; const failedNumbers: string[] = []; // --- Approach 1: Parallel Sending with Promise.all --- // WARNING: This approach sends all messages concurrently. It WILL likely hit Plivo's rate limits // for any reasonably sized list. It can overwhelm the Plivo API and lead to failures. // USE WITH EXTREME CAUTION and only for very small lists or low rate limits. // The QUEUE approach below is STRONGLY RECOMMENDED for production. this.logger.warn(`Using Promise.all for sending – This is NOT recommended for production due to rate limits!`); const sendPromises = subscribers.map(async (subscriber) => { try { await this.plivoService.sendSms(subscriber.phoneNumber, campaign.message); return { success: true, number: subscriber.phoneNumber }; } catch (error) { this.logger.error(`Failed sending to ${subscriber.phoneNumber}: ${error.message}`); return { success: false, number: subscriber.phoneNumber }; } }); const results = await Promise.all(sendPromises); results.forEach(result => { if (result.success) { sentCount++; } else { failedNumbers.push(result.number); } }); // --- End Approach 1 --- // --- Approach 2: Basic Sequential Sending (Better than Promise.all for rate limits, but SLOW) --- /* this.logger.log(`Using sequential sending loop.`); for (const subscriber of subscribers) { try { await this.plivoService.sendSms(subscriber.phoneNumber, campaign.message); sentCount++; // Basic rate limiting delay – adjust based on your Plivo MPS limit // Example: If limit is 1 MPS, delay by 1000 ms. If 10 MPS, delay by 100 ms. await new Promise(resolve => setTimeout(resolve, 100)); // e.g., 100 ms delay for ~10 MPS } catch (error) { this.logger.error(`Failed sending to ${subscriber.phoneNumber}: ${error.message}`); failedNumbers.push(subscriber.phoneNumber); } } */ // --- End Approach 2 --- // --- Approach 3: Recommended Production Approach – Using a Queue (See Section 9) --- /* this.logger.log(`Adding ${subscribers.length} SMS jobs to the queue.`); for (const subscriber of subscribers) { try { await this.smsQueue.add('send-sms-job', { to: subscriber.phoneNumber, text: campaign.message, campaignId: campaignId, }, { attempts: 3, backoff: { type: 'exponential', delay: 1000 }, removeOnComplete: true, removeOnFail: 5000, }); } catch (error) { this.logger.error(`Failed to add job for ${subscriber.phoneNumber} to queue: ${error.message}`); failedNumbers.push(subscriber.phoneNumber); } } sentCount = subscribers.length - failedNumbers.length; this.logger.log(`Added ${sentCount} SMS jobs to the queue for campaign ${campaignId}. Failures adding: ${failedNumbers.length}`); */ // --- End Approach 3 --- // Mark campaign as sent (use transaction in production) campaign.isSent = true; await this.campaignRepository.save(campaign); return { message: 'Campaign send initiated.', totalSent: sentCount, failedNumbers }; } }Transaction Management for Campaign Sending:
For production, wrap the campaign send operation in a database transaction to ensure data consistency:
// Example using TypeORM transactions async sendCampaignWithTransaction(campaignId: string): Promise<any> { return await this.campaignRepository.manager.transaction(async (transactionalEntityManager) => { const campaign = await transactionalEntityManager.findOne(Campaign, { where: { id: campaignId } }); if (!campaign || campaign.isSent) { throw new BadRequestException('Campaign already sent or not found'); } // Send messages... campaign.isSent = true; await transactionalEntityManager.save(campaign); return { message: 'Campaign sent successfully' }; }); }Frequently Asked Questions (FAQ)
How do I send bulk SMS messages using Plivo and NestJS?
Integrate the Plivo Node.js SDK into your NestJS application by creating a dedicated Plivo service that wraps the SDK functionality. For bulk sending, implement a queue-based approach using
@nestjs/bullwith Redis to handle rate limiting and retries. Queue each SMS job individually with configurable retry attempts and exponential backoff. This prevents overwhelming the Plivo API and ensures reliable message delivery at scale.What's the best way to handle Plivo rate limits in NestJS?
Use a message queue system like BullMQ with Redis to control sending rate. Configure your queue processor to respect Plivo's messages per second (MPS) limit by processing jobs with appropriate delays. Avoid using
Promise.all()for bulk sending as it sends all messages concurrently and will quickly hit rate limits. Sequential sending with delays works but is slow – queues provide the optimal balance of speed, reliability, and rate limit compliance.How do I store SMS campaign data in NestJS with TypeORM?
Define TypeORM entities for campaigns and subscribers with proper relationships and indexes. Use
@PrimaryGeneratedColumn('uuid')for unique identifiers,@Columndecorators for fields, and@CreateDateColumn/@UpdateDateColumnfor timestamps. Configure TypeORM asynchronously usingTypeOrmModule.forRootAsync()to inject ConfigService for environment-based configuration. Use migrations (npm run migration:generate) for schema changes in production rather thansynchronize: true.What environment variables do I need for Plivo integration in NestJS?
You need three essential Plivo credentials:
PLIVO_AUTH_ID,PLIVO_AUTH_TOKEN, andPLIVO_SOURCE_NUMBER. Find your Auth ID and Auth Token on the Plivo console dashboard. Your source number must be an SMS-enabled Plivo phone number purchased through the console. Local numbers cost $0.50/month, toll-free numbers cost $1.00/month. Store these in a.envfile (never commit to version control) and load them using@nestjs/configwith theConfigModule.forRoot()method.How do I handle SMS delivery failures in a Plivo NestJS application?
Implement comprehensive error handling in your Plivo service using try-catch blocks. Log failures with contextual information (phone number, error message, campaign ID). When using queues, configure automatic retries with exponential backoff (e.g., 3 attempts with increasing delays). Track failed phone numbers in your response object and store delivery status in your database. Consider implementing webhook endpoints to receive Plivo delivery reports for real-time status updates. Common Plivo error codes include: 30 (spam detected), 40 (invalid source), 50 (invalid destination), 200 (opt-out), and 420 (message expired).
Should I use TypeORM synchronize in production for my SMS campaign app?
Never use
synchronize: truein production. This setting automatically modifies your database schema based on entity changes, which can cause data loss during unintended schema updates. Instead, use TypeORM migrations for controlled schema changes. Generate migrations withnpm run migration:generate, review the SQL, and apply them withnpm run migration:run. This provides version control for your database schema and prevents accidental data loss.How do I validate phone numbers in NestJS DTOs for SMS campaigns?
Use the
class-validatorpackage with the@IsPhoneNumber()decorator in your DTOs. Import validators:@IsPhoneNumber(null)for region-agnostic validation,@IsNotEmpty()to require the field, and@IsString()for type checking. Store phone numbers in E.164 format (e.g., +14155551234) in your database using a@Column({ length: 20 })with a unique index. E.164 format consists of a plus sign, country code (1-3 digits), and subscriber number, with a maximum total length of 15 digits. Use thelibphonenumber-jslibrary to normalize user input to E.164 format before storing.What's the difference between development and production database configuration in NestJS?
In development, use
synchronize: truefor automatic schema updates andlogging: 'all'to see SQL queries. In production, setsynchronize: false, use migrations for schema changes, and limit logging to errors (logging: ['error']). Configure SSL for production database connections, though be cautious withrejectUnauthorized: falseas it disables certificate validation. Use environment variables to toggle these settings based onNODE_ENV.Related Resources
NestJS SMS Integration Guides:
Plivo Platform Guides:
NestJS Development Resources:
SMS Marketing Best Practices:
Next Steps
You now have a solid foundation for building production-ready SMS marketing campaigns with NestJS and Plivo. Here's what to implement next:
Implementation order rationale:
This guide provides the core infrastructure – extend it based on your specific business requirements and scale considerations.