code examples
code examples
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 NestJS SMS Marketing Campaigns with Plivo
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.
- npm 9+ or yarn 3+ package manager
- A Plivo account – Sign up at https://www.plivo.com/ to get Auth ID and Auth Token
- PostgreSQL 12+ database server – Provides robust data persistence for campaigns and subscribers
- TypeScript 5.0+ – Required by NestJS 11 for type safety and modern ECMAScript features
- Docker (optional, for containerization and local database setup)
- Basic understanding of TypeScript, NestJS (Modules, Services, Controllers, Dependency Injection, Decorators), and REST APIs
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:
- Campaign Management: Create, retrieve, and manage marketing campaigns (name, message content).
- Subscriber Management: Store and manage subscriber phone numbers associated with campaigns.
- Plivo Integration: Integrate the Plivo Node.js SDK to send SMS messages reliably.
- Bulk Sending: Implement logic to send campaign messages to multiple subscribers.
- API Layer: Expose RESTful endpoints for interacting with the application.
- Production Readiness: Incorporate logging, error handling, configuration management, security measures, basic performance optimizations, and deployment considerations.
Technologies Used:
| Technology | Version | Purpose |
|---|---|---|
| NestJS | 11+ | Progressive Node.js framework with modular architecture and TypeScript support |
| TypeScript | 5.0+ | Static typing for improved code quality and maintainability |
| Plivo Node.js SDK | Latest | Official library for Plivo API integration |
| PostgreSQL | 12+ | Robust open-source relational database |
| TypeORM | 0.3+ | Object-Relational Mapper for database interactions |
| Docker | 20+ | Containerization for consistent deployment environments |
System Architecture:
(This is a simplified text-based representation)
+-----------------+ +---------------------+ +-------------------+ +----------------+
| Client (e.g., | ---> | NestJS API Gateway | ---> | Campaign Service | ---> | Plivo Service | ---> | Plivo SMS API |
| Admin UI/CLI) | | (Controller) | | (Business Logic) | | (SDK Wrapper) | +----------------+
+-----------------+ +---------------------+ +-------------------+ +----------------+
| ^
| |
v |
+---------------------+ |
| Database (Postgres) | --+
| (Campaigns, |
| Subscribers) |
+---------------------+
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.ts
database/# Database module and entitiesdatabase.module.tsentities/campaign.entity.tssubscriber.entity.ts
migrations/# TypeORM migrations (generated later)data-source.ts# Data source for CLI
plivo/# Plivo integration moduleplivo.module.tsplivo.service.ts
campaign/# Campaign management modulecampaign.module.tscampaign.controller.tscampaign.service.tsdto/create-campaign.dto.tsadd-subscriber.dto.ts# Added for subscriber creation
sms-queue.processor.ts# Optional: If using BullMQ (Section 9)
shared/# Shared utilities/modules (e.g., logging)logger/logger.module.ts
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.tshealth.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.
# .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):
- 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).
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.ts after 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 AppModule to use ConfigModule.
// 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):
| Error Code | Meaning | Action Required |
|---|---|---|
| 000 | Success | No action needed |
| 10 | Invalid Message | Check message content format |
| 20 | Network Error | Retry after carrier network recovers |
| 30 | Spam Detected | Review content, use short codes for bulk |
| 40 | Invalid Source Number | Verify source number is SMS-enabled |
| 50 | Invalid Destination | Verify destination format (E.164) |
| 70 | Destination Permanently Unavailable | Remove from subscriber list |
| 110 | Message Too Long | Max 1,600 chars (GSM) or 737 (UCS-2) |
| 200 | Recipient Opted Out | Honor opt-out, remove from list |
| 420 | Message Expired | Check 10DLC registration for US numbers |
| 900 | Insufficient Credit | Add funds to Plivo account |
| 1000 | Unknown Error | Contact Plivo support with message UUID |
Why this approach?
- Encapsulation: Isolates Plivo logic, making the application independent of the specific SMS provider SDK.
- Configuration: Uses
ConfigServiceto securely access credentials from environment variables. - Error Handling: Includes
try...catchblocks, logs errors, and throws standard NestJS exceptions for consistent API error responses. - Logging: Provides informative logs about initialization and sending attempts.
- Extensibility: Add more Plivo methods later (checking message status, handling incoming messages/delivery reports).
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 forRootAsync to inject ConfigService and 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: 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.
// 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.
// 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.yml in 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 -d
Generate 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:
- No changes detected: Ensure entities are properly imported in
data-source.ts - Connection refused: Verify database is running and
DATABASE_URLis correct - TypeScript errors: Run
npm run buildfirst to compile entities
This 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 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.
// 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:
| 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).
// 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:
- 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:
// 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/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.
Related Resources
NestJS SMS Integration Guides:
- Twilio SMS with NestJS Tutorial
- MessageBird NestJS Integration Guide
- Infobip NestJS SMS Setup
- Sinch SMS NestJS Configuration
Plivo Platform Guides:
- Plivo SMS API Documentation
- Plivo Pricing and Rate Limits
- Plivo Phone Number Purchase Guide
- Plivo Webhook Configuration
NestJS Development Resources:
- NestJS TypeORM Integration Best Practices
- NestJS Configuration Management Guide
- NestJS Queue Processing with Bull
- NestJS Production Deployment Checklist
SMS Marketing Best Practices:
- SMS Marketing Compliance Guide
- A2P SMS Best Practices
- SMS Campaign Optimization Strategies
- SMS Delivery Rate Optimization
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:
| 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.