code examples

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

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:

  1. Campaign Management: Create, retrieve, and manage marketing campaigns (name, message content).
  2. Subscriber Management: Store and manage subscriber phone numbers associated with campaigns.
  3. Plivo Integration: Integrate the Plivo Node.js SDK to send SMS messages reliably.
  4. Bulk Sending: Implement logic to send campaign messages to multiple subscribers.
  5. API Layer: Expose RESTful endpoints for interacting with the application.
  6. Production Readiness: Incorporate logging, error handling, configuration management, security measures, basic performance optimizations, and deployment considerations.

Technologies Used:

TechnologyVersionPurpose
NestJS11+Progressive Node.js framework with modular architecture and TypeScript support
TypeScript5.0+Static typing for improved code quality and maintainability
Plivo Node.js SDKLatestOfficial library for Plivo API integration
PostgreSQL12+Robust open-source relational database
TypeORM0.3+Object-Relational Mapper for database interactions
Docker20+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:

bash
npm install -g @nestjs/cli
# or
yarn global add @nestjs/cli

Create New Project

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

Install Dependencies

Install several packages for configuration, database interaction, Plivo integration, validation, logging, and potentially queuing/scheduling.

bash
# 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.ts
    • app.module.ts
    • app.service.ts
    • main.ts
    • config/ # Configuration setup
      • configuration.ts
    • database/ # Database module and entities
      • database.module.ts
      • entities/
        • campaign.entity.ts
        • subscriber.entity.ts
      • migrations/ # TypeORM migrations (generated later)
      • data-source.ts # Data source for CLI
    • plivo/ # Plivo integration module
      • plivo.module.ts
      • plivo.service.ts
    • campaign/ # Campaign management module
      • campaign.module.ts
      • campaign.controller.ts
      • campaign.service.ts
      • dto/
        • create-campaign.dto.ts
        • add-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.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.

ini
# .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=6379

How to get Plivo Credentials and Phone Numbers:

  1. 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.

  2. Purchase a phone number: Navigate to MessagingPhone NumbersYour Numbers to find or purchase a Plivo number to use as the PLIVO_SOURCE_NUMBER.

  3. 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)
  4. 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.

typescript
// 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:

typescript
// 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.

typescript
// 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

typescript
// 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 {}
typescript
// 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 accepted
  • sent – Successfully passed to downstream carrier
  • delivered – Confirmed delivery (requires carrier support)
  • undelivered – Failed to deliver after being sent
  • failed – Internal error before reaching carrier
  • read – User read the message (WhatsApp only)

Common Error Codes (source: Plivo Error Codes):

Error CodeMeaningAction Required
000SuccessNo action needed
10Invalid MessageCheck message content format
20Network ErrorRetry after carrier network recovers
30Spam DetectedReview content, use short codes for bulk
40Invalid Source NumberVerify source number is SMS-enabled
50Invalid DestinationVerify destination format (E.164)
70Destination Permanently UnavailableRemove from subscriber list
110Message Too LongMax 1,600 chars (GSM) or 737 (UCS-2)
200Recipient Opted OutHonor opt-out, remove from list
420Message ExpiredCheck 10DLC registration for US numbers
900Insufficient CreditAdd funds to Plivo account
1000Unknown ErrorContact Plivo support with message UUID

Why this approach?

  • Encapsulation: Isolates Plivo logic, making the application independent of the specific SMS provider SDK.
  • Configuration: Uses ConfigService to securely access credentials from environment variables.
  • Error Handling: Includes try...catch blocks, 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

typescript
// 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;
}
typescript
// 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.

typescript
// 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.

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.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:

yaml
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:

bash
# 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.

typescript
// 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:

EncodingSingle SegmentConcatenated SegmentMax SegmentsTotal Chars
GSM-7160 chars153 chars101,600
UCS-2 (Unicode)70 chars67 chars11737

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.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:

typescript
// 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-js

Create Campaign Service

This service handles campaign and subscriber business logic.

typescript
// 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:

typescript
// 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.

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:

StepTaskEstimated TimeDependencies
1Add Queue Processing4-6 hoursRedis installed
2Configure Webhooks2-3 hoursPublic URL/ngrok
3Implement Security3-4 hoursStep 1 complete
4Add Monitoring2-3 hours-
5Create Campaign Scheduler2-3 hoursStep 1 complete
6Build Admin Dashboard8-12 hoursAll backend complete
7Add Segmentation4-6 hoursStep 2 complete
8Set Up Testing6-8 hours-
9Deploy to Production4-6 hoursDocker configured
10Monitor PerformanceOngoingSteps 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.