code examples

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

Building Production-Ready SMS Marketing Campaigns with Plivo, Node.js, and Vite (React/Vue)

Complete guide to building a full-stack SMS marketing campaign platform with Plivo SMS API, Node.js backend, and Vite frontend using React or Vue.

Building Production-Ready SMS Marketing Campaigns with Plivo, Node.js, and Vite (React/Vue)

This comprehensive guide walks you through building a complete SMS marketing campaign system using the Plivo SMS API, Node.js for the backend, and Vite with React or Vue for the frontend dashboard. You'll learn how to manage contacts, create campaigns, schedule bulk sends, track analytics, handle opt-outs, and maintain compliance with TCPA regulations.

Project Overview and Goals

Goal: Create a full-stack SMS marketing platform that enables businesses to manage campaigns, segment audiences, schedule bulk messages, track engagement metrics, and maintain regulatory compliance.

Problem Solved: Addresses the challenges of managing large subscriber lists, coordinating campaign schedules, tracking delivery and engagement, preventing spam complaints, ensuring TCPA/GDPR compliance, and providing a user-friendly interface for campaign management.

Technologies Used:

Backend:

  • Node.js: JavaScript runtime for server-side execution
  • Express: Minimal web framework for REST API endpoints
  • Plivo Node.js SDK (plivo): Official SDK for interacting with Plivo's SMS API
  • PostgreSQL with TypeORM: Database for storing campaigns, contacts, and analytics
  • BullMQ: Redis-based queue system for reliable bulk message delivery with rate limiting
  • dotenv: Environment variable management for secure credential storage

Frontend:

  • Vite: Fast build tool and development server (official docs)
  • React or Vue 3: Modern JavaScript frameworks for building interactive UIs
  • Axios: HTTP client for API communication
  • React Router / Vue Router: Client-side routing
  • TailwindCSS: Utility-first CSS framework for responsive design
  • Chart.js or Recharts: Data visualization for campaign analytics

System Architecture:

text
┌─────────────────┐      ┌──────────────────────┐      ┌─────────────────┐
│  Vite Frontend  │──────│   Express API        │──────│  PostgreSQL DB  │
│ (React/Vue UI)  │ HTTP │   (Campaign Mgmt)    │      │  (Contacts,     │
│                 │      │                      │      │   Campaigns)    │
└─────────────────┘      └──────────────────────┘      └─────────────────┘
        │                         │
        │                         │
        │                 ┌───────▼──────────┐      ┌─────────────────┐
        │                 │   BullMQ Queue   │──────│   Redis Store   │
        │                 │ (Bulk Processor) │      │                 │
        │                 └───────┬──────────┘      └─────────────────┘
        │                         │
        │                         │ Rate-limited sends
        │                         │
        │                 ┌───────▼──────────┐      ┌─────────────────┐
        └─────────────────│  Plivo SMS API   │──────│  User's Phone   │
                          │  (Delivery)      │      │                 │
                          └──────────────────┘      └─────────────────┘

Flow:
1. User creates campaign in frontend UI
2. Frontend sends campaign config to Express API
3. API stores campaign in PostgreSQL
4. Campaign scheduled → jobs queued in BullMQ
5. Queue workers process messages with rate limiting
6. Plivo API delivers SMS to recipients
7. Delivery reports collected and stored
8. Analytics displayed in frontend dashboard

Expected Outcome: A complete SMS marketing platform featuring:

  • Campaign creation and management dashboard
  • Contact list import (CSV) and segmentation
  • Message template editor with personalization tokens
  • Scheduling system for future sends
  • Real-time delivery tracking and analytics
  • Opt-out management compliant with TCPA requirements
  • Analytics dashboard with delivery rates, engagement metrics, and ROI tracking

Prerequisites:

  • Node.js and npm: Version 18+ LTS recommended (download from nodejs.org)
  • PostgreSQL: Database server (v14+) for persistent storage
  • Redis: In-memory store for BullMQ queue system
  • Plivo Account: Sign up at Plivo Dashboard
  • Plivo Auth ID and Auth Token: Available from your Plivo console
  • Plivo Phone Number: Purchase a number from the Plivo dashboard in E.164 format (e.g., +14155550100)
  • 10DLC Registration (US): Required for application-to-person messaging compliance (see Plivo's 10DLC guide)
  • Text Editor: VS Code, WebStorm, or similar
  • Basic Knowledge: TypeScript/JavaScript, REST APIs, React or Vue fundamentals
  • Compliance Knowledge: Understanding of TCPA consent requirements, opt-out regulations, and SMS marketing best practices (TCPA compliance guide)

1. Setting Up the Project

Backend Setup

  1. Create Project Structure:

    bash
    mkdir sms-campaign-platform
    cd sms-campaign-platform
    mkdir backend frontend
    cd backend
    npm init -y
  2. Install Backend Dependencies:

    bash
    npm install express plivo dotenv
    npm install typeorm pg reflect-metadata
    npm install bullmq ioredis
    npm install cors helmet express-rate-limit
    npm install csv-parser multer
    npm install date-fns
    npm install --save-dev typescript @types/node @types/express
    npm install --save-dev ts-node nodemon

    Key dependencies explained:

    • plivo: Official Plivo Node.js SDK (documentation)
    • typeorm + pg: ORM and PostgreSQL driver for database operations
    • bullmq + ioredis: Queue system for reliable bulk message processing
    • csv-parser + multer: CSV contact import functionality
    • date-fns: Date manipulation for campaign scheduling
  3. Initialize TypeScript:

    bash
    npx tsc --init

    Configure tsconfig.json:

    json
    {
      "compilerOptions": {
        "target": "ES2022",
        "module": "commonjs",
        "lib": ["ES2022"],
        "outDir": "./dist",
        "rootDir": "./src",
        "strict": true,
        "esModuleInterop": true,
        "experimentalDecorators": true,
        "emitDecoratorMetadata": true,
        "skipLibCheck": true
      },
      "include": ["src/**/*"],
      "exclude": ["node_modules"]
    }
  4. Create Backend Folder Structure:

    bash
    mkdir -p src/{entities,services,controllers,queues,middleware,utils}
    touch src/index.ts
    touch .env .gitignore

    Project structure:

    text
    backend/
    ├── src/
    │   ├── entities/          # TypeORM database models
    │   │   ├── Campaign.ts
    │   │   ├── Contact.ts
    │   │   ├── Message.ts
    │   │   └── OptOut.ts
    │   ├── services/          # Business logic
    │   │   ├── PlivoService.ts
    │   │   └── CampaignService.ts
    │   ├── controllers/       # API route handlers
    │   │   ├── CampaignController.ts
    │   │   └── ContactController.ts
    │   ├── queues/            # BullMQ queue workers
    │   │   └── SmsWorker.ts
    │   ├── middleware/        # Express middleware
    │   │   └── validation.ts
    │   ├── utils/             # Helper functions
    │   │   └── csvParser.ts
    │   └── index.ts           # Application entry point
    ├── .env
    ├── .gitignore
    ├── package.json
    └── tsconfig.json
  5. Configure Environment Variables (.env):

    env
    # Server Configuration
    PORT=3000
    NODE_ENV=development
    
    # Plivo Credentials
    PLIVO_AUTH_ID=your_auth_id_here
    PLIVO_AUTH_TOKEN=your_auth_token_here
    PLIVO_PHONE_NUMBER=+14155550100
    
    # Database Configuration
    DB_HOST=localhost
    DB_PORT=5432
    DB_USERNAME=postgres
    DB_PASSWORD=your_db_password
    DB_DATABASE=sms_campaigns
    
    # Redis Configuration
    REDIS_HOST=localhost
    REDIS_PORT=6379
    REDIS_PASSWORD=
    
    # Campaign Settings
    MAX_MESSAGES_PER_SECOND=10
    QUIET_HOURS_START=21
    QUIET_HOURS_END=8

    IMPORTANT: According to TCPA regulations, messages must not be sent before 8 AM or after 9 PM in the recipient's local time zone to avoid violations (penalties up to $1,500 per message).

  6. Update package.json Scripts:

    json
    {
      "scripts": {
        "dev": "nodemon --exec ts-node src/index.ts",
        "build": "tsc",
        "start": "node dist/index.js",
        "typeorm": "typeorm-ts-node-commonjs"
      }
    }

Frontend Setup

  1. Create Vite Project with React or Vue:

    For React:

    bash
    cd ../frontend
    npm create vite@latest . -- --template react-ts

    For Vue:

    bash
    cd ../frontend
    npm create vite@latest . -- --template vue-ts
  2. Install Frontend Dependencies:

    bash
    npm install
    npm install axios react-router-dom
    npm install @tanstack/react-query  # For React
    # OR for Vue:
    npm install vue-router pinia
    
    # UI and Styling
    npm install -D tailwindcss postcss autoprefixer
    npx tailwindcss init -p
    
    # Charts and Visualization
    npm install recharts  # For React
    # OR for Vue:
    npm install chart.js vue-chartjs
    
    # Date handling
    npm install date-fns
    
    # Forms and validation
    npm install react-hook-form zod @hookform/resolvers  # For React
    # OR for Vue:
    npm install vee-validate yup
  3. Configure Tailwind CSS:

    Update tailwind.config.js:

    javascript
    /** @type {import('tailwindcss').Config} */
    export default {
      content: [
        "./index.html",
        "./src/**/*.{js,ts,jsx,tsx,vue}",
      ],
      theme: {
        extend: {},
      },
      plugins: [],
    }

    Add to src/index.css:

    css
    @tailwind base;
    @tailwind components;
    @tailwind utilities;
  4. Frontend Folder Structure:

    text
    frontend/
    ├── src/
    │   ├── components/        # Reusable UI components
    │   │   ├── CampaignList.tsx
    │   │   ├── ContactImport.tsx
    │   │   ├── MessageEditor.tsx
    │   │   └── Analytics.tsx
    │   ├── pages/             # Route pages
    │   │   ├── Dashboard.tsx
    │   │   ├── Campaigns.tsx
    │   │   └── Contacts.tsx
    │   ├── services/          # API client
    │   │   └── api.ts
    │   ├── hooks/             # Custom React hooks
    │   │   └── useCampaigns.ts
    │   ├── types/             # TypeScript interfaces
    │   │   └── index.ts
    │   ├── App.tsx
    │   └── main.tsx
    ├── index.html
    ├── package.json
    ├── vite.config.ts
    └── tailwind.config.js

2. Configuring Database and Plivo

Database Setup

  1. Create Database Entities:

    src/entities/Contact.ts:

    typescript
    import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm';
    
    @Entity('contacts')
    export class Contact {
      @PrimaryGeneratedColumn('uuid')
      id: string;
    
      @Column({ type: 'varchar', length: 20 })
      @Index()
      phoneNumber: string; // E.164 format
    
      @Column({ type: 'varchar', length: 100, nullable: true })
      firstName?: string;
    
      @Column({ type: 'varchar', length: 100, nullable: true })
      lastName?: string;
    
      @Column({ type: 'varchar', length: 255, nullable: true })
      email?: string;
    
      @Column({ type: 'jsonb', nullable: true })
      customFields?: Record<string, any>;
    
      @Column({ type: 'boolean', default: true })
      isActive: boolean;
    
      @Column({ type: 'varchar', array: true, default: [] })
      tags: string[];
    
      @CreateDateColumn()
      createdAt: Date;
    }

    src/entities/Campaign.ts:

    typescript
    import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
    
    export enum CampaignStatus {
      DRAFT = 'draft',
      SCHEDULED = 'scheduled',
      SENDING = 'sending',
      COMPLETED = 'completed',
      FAILED = 'failed'
    }
    
    @Entity('campaigns')
    export class Campaign {
      @PrimaryGeneratedColumn('uuid')
      id: string;
    
      @Column({ type: 'varchar', length: 255 })
      name: string;
    
      @Column({ type: 'text' })
      messageTemplate: string;
    
      @Column({ type: 'enum', enum: CampaignStatus, default: CampaignStatus.DRAFT })
      status: CampaignStatus;
    
      @Column({ type: 'timestamp', nullable: true })
      scheduledAt?: Date;
    
      @Column({ type: 'jsonb', nullable: true })
      segmentCriteria?: Record<string, any>;
    
      @Column({ type: 'int', default: 0 })
      totalRecipients: number;
    
      @Column({ type: 'int', default: 0 })
      sentCount: number;
    
      @Column({ type: 'int', default: 0 })
      deliveredCount: number;
    
      @Column({ type: 'int', default: 0 })
      failedCount: number;
    
      @CreateDateColumn()
      createdAt: Date;
    
      @UpdateDateColumn()
      updatedAt: Date;
    }

    src/entities/Message.ts:

    typescript
    import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn, Index } from 'typeorm';
    import { Campaign } from './Campaign';
    import { Contact } from './Contact';
    
    export enum MessageStatus {
      QUEUED = 'queued',
      SENT = 'sent',
      DELIVERED = 'delivered',
      FAILED = 'failed',
      UNDELIVERED = 'undelivered'
    }
    
    @Entity('messages')
    export class Message {
      @PrimaryGeneratedColumn('uuid')
      id: string;
    
      @Column({ type: 'uuid' })
      @Index()
      campaignId: string;
    
      @ManyToOne(() => Campaign)
      @JoinColumn({ name: 'campaignId' })
      campaign: Campaign;
    
      @Column({ type: 'uuid' })
      @Index()
      contactId: string;
    
      @ManyToOne(() => Contact)
      @JoinColumn({ name: 'contactId' })
      contact: Contact;
    
      @Column({ type: 'text' })
      messageBody: string;
    
      @Column({ type: 'varchar', length: 255, nullable: true })
      plivoMessageUuid?: string;
    
      @Column({ type: 'enum', enum: MessageStatus, default: MessageStatus.QUEUED })
      status: MessageStatus;
    
      @Column({ type: 'text', nullable: true })
      errorMessage?: string;
    
      @Column({ type: 'timestamp', nullable: true })
      sentAt?: Date;
    
      @Column({ type: 'timestamp', nullable: true })
      deliveredAt?: Date;
    
      @CreateDateColumn()
      createdAt: Date;
    }

    src/entities/OptOut.ts:

    typescript
    import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm';
    
    @Entity('opt_outs')
    export class OptOut {
      @PrimaryGeneratedColumn('uuid')
      id: string;
    
      @Column({ type: 'varchar', length: 20, unique: true })
      @Index()
      phoneNumber: string;
    
      @Column({ type: 'varchar', length: 50 })
      source: string; // 'user_request', 'reply_stop', 'complaint'
    
      @CreateDateColumn()
      optedOutAt: Date;
    }
  2. Initialize Database Connection:

    src/config/database.ts:

    typescript
    import 'reflect-metadata';
    import { DataSource } from 'typeorm';
    import { Contact } from '../entities/Contact';
    import { Campaign } from '../entities/Campaign';
    import { Message } from '../entities/Message';
    import { OptOut } from '../entities/OptOut';
    
    export const AppDataSource = new DataSource({
      type: 'postgres',
      host: process.env.DB_HOST || 'localhost',
      port: parseInt(process.env.DB_PORT || '5432'),
      username: process.env.DB_USERNAME,
      password: process.env.DB_PASSWORD,
      database: process.env.DB_DATABASE,
      synchronize: process.env.NODE_ENV === 'development',
      logging: process.env.NODE_ENV === 'development',
      entities: [Contact, Campaign, Message, OptOut],
      migrations: ['src/migrations/**/*.ts'],
    });

Plivo Service Implementation

src/services/PlivoService.ts:

typescript
import * as plivo from 'plivo';

export class PlivoService {
  private client: plivo.Client;
  private sourceNumber: string;

  constructor() {
    const authId = process.env.PLIVO_AUTH_ID;
    const authToken = process.env.PLIVO_AUTH_TOKEN;
    this.sourceNumber = process.env.PLIVO_PHONE_NUMBER!;

    if (!authId || !authToken) {
      throw new Error('Plivo credentials not configured');
    }

    this.client = new plivo.Client(authId, authToken);
  }

  /**
   * Send a single SMS message
   * @param to - Recipient phone number in E.164 format
   * @param text - Message content (max 160 characters for single SMS)
   * @returns Plivo message response with message UUID
   */
  async sendSingleMessage(to: string, text: string): Promise<any> {
    try {
      const response = await this.client.messages.create({
        src: this.sourceNumber,
        dst: to,
        text: text,
        url: `${process.env.BASE_URL}/webhooks/plivo/status`, // Delivery callback
      });

      return response;
    } catch (error: any) {
      console.error('Plivo send error:', error);
      throw new Error(`Failed to send SMS: ${error.message}`);
    }
  }

  /**
   * Send bulk messages using Plivo's bulk messaging API
   * Supports up to 1,000 recipients per request
   * Reference: https://www.plivo.com/docs/messaging/api/message/bulk-messaging
   * @param recipients - Array of phone numbers (E.164 format)
   * @param text - Message content
   * @returns Array of message UUIDs
   */
  async sendBulkMessage(recipients: string[], text: string): Promise<string[]> {
    if (recipients.length === 0) {
      throw new Error('No recipients provided');
    }

    if (recipients.length > 1000) {
      throw new Error('Bulk messaging limited to 1,000 recipients per request');
    }

    try {
      // Join recipients with < delimiter as required by Plivo
      const dstString = recipients.join('<');

      const response = await this.client.messages.create({
        src: this.sourceNumber,
        dst: dstString,
        text: text,
        url: `${process.env.BASE_URL}/webhooks/plivo/status`,
      });

      return response.message_uuid || [];
    } catch (error: any) {
      console.error('Plivo bulk send error:', error);
      throw new Error(`Failed to send bulk SMS: ${error.message}`);
    }
  }

  /**
   * Retrieve message details and delivery status
   * @param messageUuid - Plivo message UUID
   */
  async getMessageStatus(messageUuid: string): Promise<any> {
    try {
      const response = await this.client.messages.get(messageUuid);
      return response;
    } catch (error: any) {
      console.error('Error retrieving message status:', error);
      throw error;
    }
  }
}

3. Implementing Campaign Management Backend

Campaign Service with Queue Integration

src/services/CampaignService.ts:

typescript
import { AppDataSource } from '../config/database';
import { Campaign, CampaignStatus } from '../entities/Campaign';
import { Contact } from '../entities/Contact';
import { Message, MessageStatus } from '../entities/Message';
import { OptOut } from '../entities/OptOut';
import { SmsQueue } from '../queues/SmsQueue';

export class CampaignService {
  private campaignRepo = AppDataSource.getRepository(Campaign);
  private contactRepo = AppDataSource.getRepository(Contact);
  private messageRepo = AppDataSource.getRepository(Message);
  private optOutRepo = AppDataSource.getRepository(OptOut);
  private smsQueue = new SmsQueue();

  /**
   * Create a new campaign
   */
  async createCampaign(data: {
    name: string;
    messageTemplate: string;
    scheduledAt?: Date;
    segmentCriteria?: Record<string, any>;
  }): Promise<Campaign> {
    const campaign = this.campaignRepo.create({
      ...data,
      status: CampaignStatus.DRAFT,
    });

    return await this.campaignRepo.save(campaign);
  }

  /**
   * Get campaign recipients based on segment criteria
   * Filters out opted-out contacts
   */
  async getCampaignRecipients(campaignId: string): Promise<Contact[]> {
    const campaign = await this.campaignRepo.findOneBy({ id: campaignId });
    if (!campaign) {
      throw new Error('Campaign not found');
    }

    // Get all opted-out phone numbers
    const optOuts = await this.optOutRepo.find();
    const optedOutNumbers = optOuts.map(o => o.phoneNumber);

    // Build query based on segment criteria
    let query = this.contactRepo.createQueryBuilder('contact')
      .where('contact.isActive = :isActive', { isActive: true })
      .andWhere('contact.phoneNumber NOT IN (:...optedOut)', {
        optedOut: optedOutNumbers.length > 0 ? optedOutNumbers : ['']
      });

    // Apply segment filters
    if (campaign.segmentCriteria) {
      const { tags } = campaign.segmentCriteria;
      if (tags && tags.length > 0) {
        query = query.andWhere('contact.tags && ARRAY[:...tags]', { tags });
      }
    }

    return await query.getMany();
  }

  /**
   * Personalize message template with contact data
   */
  personalizeMessage(template: string, contact: Contact): string {
    let message = template;
    message = message.replace(/\{\{firstName\}\}/g, contact.firstName || '');
    message = message.replace(/\{\{lastName\}\}/g, contact.lastName || '');
    message = message.replace(/\{\{email\}\}/g, contact.email || '');

    // Replace custom fields
    if (contact.customFields) {
      Object.entries(contact.customFields).forEach(([key, value]) => {
        message = message.replace(
          new RegExp(`\\{\\{${key}\\}\\}`, 'g'),
          String(value)
        );
      });
    }

    return message.trim();
  }

  /**
   * Schedule campaign for sending
   * Creates message records and queues them in BullMQ
   */
  async scheduleCampaign(campaignId: string): Promise<void> {
    const campaign = await this.campaignRepo.findOneBy({ id: campaignId });
    if (!campaign) {
      throw new Error('Campaign not found');
    }

    // Get recipients
    const recipients = await this.getCampaignRecipients(campaignId);

    if (recipients.length === 0) {
      throw new Error('No eligible recipients found for campaign');
    }

    // Create message records
    const messages = recipients.map(contact => {
      const messageBody = this.personalizeMessage(campaign.messageTemplate, contact);

      return this.messageRepo.create({
        campaignId: campaign.id,
        contactId: contact.id,
        messageBody,
        status: MessageStatus.QUEUED,
      });
    });

    await this.messageRepo.save(messages);

    // Update campaign
    campaign.status = CampaignStatus.SCHEDULED;
    campaign.totalRecipients = recipients.length;
    await this.campaignRepo.save(campaign);

    // Queue messages in BullMQ
    for (const message of messages) {
      const contact = recipients.find(c => c.id === message.contactId);
      if (contact) {
        await this.smsQueue.addMessage({
          messageId: message.id,
          campaignId: campaign.id,
          to: contact.phoneNumber,
          text: message.messageBody,
        }, {
          delay: campaign.scheduledAt
            ? campaign.scheduledAt.getTime() - Date.now()
            : 0,
        });
      }
    }
  }

  /**
   * Get campaign analytics
   */
  async getCampaignAnalytics(campaignId: string) {
    const campaign = await this.campaignRepo.findOneBy({ id: campaignId });
    if (!campaign) {
      throw new Error('Campaign not found');
    }

    const messages = await this.messageRepo.findBy({ campaignId });

    const analytics = {
      totalRecipients: campaign.totalRecipients,
      queued: messages.filter(m => m.status === MessageStatus.QUEUED).length,
      sent: messages.filter(m => m.status === MessageStatus.SENT).length,
      delivered: messages.filter(m => m.status === MessageStatus.DELIVERED).length,
      failed: messages.filter(m => m.status === MessageStatus.FAILED).length,
      deliveryRate: campaign.totalRecipients > 0
        ? (campaign.deliveredCount / campaign.totalRecipients * 100).toFixed(2)
        : 0,
    };

    return analytics;
  }
}

BullMQ Queue Worker

src/queues/SmsQueue.ts:

typescript
import { Queue, Worker, Job } from 'bullmq';
import IORedis from 'ioredis';
import { PlivoService } from '../services/PlivoService';
import { AppDataSource } from '../config/database';
import { Message, MessageStatus } from '../entities/Message';
import { Campaign } from '../entities/Campaign';

interface SmsJobData {
  messageId: string;
  campaignId: string;
  to: string;
  text: string;
}

export class SmsQueue {
  private queue: Queue;
  private worker: Worker;
  private plivoService: PlivoService;
  private connection: IORedis;

  constructor() {
    this.connection = new IORedis({
      host: process.env.REDIS_HOST || 'localhost',
      port: parseInt(process.env.REDIS_PORT || '6379'),
      password: process.env.REDIS_PASSWORD || undefined,
      maxRetriesPerRequest: null,
    });

    this.queue = new Queue('sms-queue', { connection: this.connection });
    this.plivoService = new PlivoService();

    this.initWorker();
  }

  private initWorker() {
    const maxMessagesPerSecond = parseInt(process.env.MAX_MESSAGES_PER_SECOND || '10');

    this.worker = new Worker('sms-queue', async (job: Job<SmsJobData>) => {
      return await this.processMessage(job.data);
    }, {
      connection: this.connection,
      limiter: {
        max: maxMessagesPerSecond,
        duration: 1000, // Per second
      },
      concurrency: 5,
    });

    this.worker.on('completed', (job) => {
      console.log(`Job ${job.id} completed successfully`);
    });

    this.worker.on('failed', (job, err) => {
      console.error(`Job ${job?.id} failed:`, err);
    });
  }

  /**
   * Add message to queue
   */
  async addMessage(data: SmsJobData, options?: { delay?: number }) {
    return await this.queue.add('send-sms', data, {
      delay: options?.delay || 0,
      attempts: 3,
      backoff: {
        type: 'exponential',
        delay: 5000,
      },
      removeOnComplete: {
        count: 1000,
      },
      removeOnFail: {
        count: 5000,
      },
    });
  }

  /**
   * Process individual message
   * Respects quiet hours (8 AM - 9 PM local time per TCPA)
   */
  private async processMessage(data: SmsJobData): Promise<void> {
    const messageRepo = AppDataSource.getRepository(Message);
    const campaignRepo = AppDataSource.getRepository(Campaign);

    const message = await messageRepo.findOneBy({ id: data.messageId });
    if (!message) {
      throw new Error(`Message ${data.messageId} not found`);
    }

    // Check quiet hours (TCPA compliance)
    const currentHour = new Date().getHours();
    const quietStart = parseInt(process.env.QUIET_HOURS_START || '21');
    const quietEnd = parseInt(process.env.QUIET_HOURS_END || '8');

    if (currentHour >= quietStart || currentHour < quietEnd) {
      // Reschedule for next morning at 9 AM
      const tomorrow = new Date();
      tomorrow.setDate(tomorrow.getDate() + 1);
      tomorrow.setHours(9, 0, 0, 0);

      throw new Error('Quiet hours - rescheduling'); // Will trigger retry
    }

    try {
      // Send via Plivo
      const response = await this.plivoService.sendSingleMessage(data.to, data.text);

      // Update message record
      message.status = MessageStatus.SENT;
      message.plivoMessageUuid = response.message_uuid?.[0];
      message.sentAt = new Date();
      await messageRepo.save(message);

      // Update campaign counters
      const campaign = await campaignRepo.findOneBy({ id: data.campaignId });
      if (campaign) {
        campaign.sentCount += 1;
        await campaignRepo.save(campaign);
      }

    } catch (error: any) {
      message.status = MessageStatus.FAILED;
      message.errorMessage = error.message;
      await messageRepo.save(message);

      // Update campaign
      const campaign = await campaignRepo.findOneBy({ id: data.campaignId });
      if (campaign) {
        campaign.failedCount += 1;
        await campaignRepo.save(campaign);
      }

      throw error;
    }
  }

  /**
   * Close connections
   */
  async close() {
    await this.queue.close();
    await this.worker.close();
    this.connection.disconnect();
  }
}

Campaign Controller

src/controllers/CampaignController.ts:

typescript
import { Request, Response } from 'express';
import { CampaignService } from '../services/CampaignService';

export class CampaignController {
  private campaignService = new CampaignService();

  createCampaign = async (req: Request, res: Response) => {
    try {
      const { name, messageTemplate, scheduledAt, segmentCriteria } = req.body;

      // Validation
      if (!name || !messageTemplate) {
        return res.status(400).json({
          error: 'Name and message template are required'
        });
      }

      // Validate message includes opt-out instruction (TCPA requirement)
      const hasOptOut = messageTemplate.toLowerCase().includes('stop') ||
                        messageTemplate.toLowerCase().includes('opt-out');

      if (!hasOptOut) {
        return res.status(400).json({
          error: 'Message must include opt-out instructions (e.g., "Reply STOP to opt-out")'
        });
      }

      const campaign = await this.campaignService.createCampaign({
        name,
        messageTemplate,
        scheduledAt: scheduledAt ? new Date(scheduledAt) : undefined,
        segmentCriteria,
      });

      res.status(201).json(campaign);
    } catch (error: any) {
      res.status(500).json({ error: error.message });
    }
  };

  scheduleCampaign = async (req: Request, res: Response) => {
    try {
      const { id } = req.params;
      await this.campaignService.scheduleCampaign(id);

      res.json({ message: 'Campaign scheduled successfully' });
    } catch (error: any) {
      res.status(500).json({ error: error.message });
    }
  };

  getCampaignAnalytics = async (req: Request, res: Response) => {
    try {
      const { id } = req.params;
      const analytics = await this.campaignService.getCampaignAnalytics(id);

      res.json(analytics);
    } catch (error: any) {
      res.status(500).json({ error: error.message });
    }
  };

  listCampaigns = async (req: Request, res: Response) => {
    try {
      const campaignRepo = AppDataSource.getRepository(Campaign);
      const campaigns = await campaignRepo.find({
        order: { createdAt: 'DESC' },
      });

      res.json(campaigns);
    } catch (error: any) {
      res.status(500).json({ error: error.message });
    }
  };
}

4. TCPA Compliance and Opt-Out Management

According to TCPA regulations enforced by the FCC, businesses must:

  1. Obtain express written consent before sending marketing messages
  2. Provide clear opt-out instructions in every message
  3. Honor opt-out requests immediately (within 10 business days as of 2025)
  4. Respect quiet hours (no messages before 8 AM or after 9 PM local time)
  5. Maintain opt-out records for compliance audits

Penalties for violations range from $500 to $1,500 per message (source).

Opt-Out Handler

src/services/OptOutService.ts:

typescript
import { AppDataSource } from '../config/database';
import { OptOut } from '../entities/OptOut';
import { Contact } from '../entities/Contact';

export class OptOutService {
  private optOutRepo = AppDataSource.getRepository(OptOut);
  private contactRepo = AppDataSource.getRepository(Contact);

  /**
   * Process opt-out request
   * Must be honored immediately per TCPA
   */
  async processOptOut(phoneNumber: string, source: string = 'reply_stop'): Promise<void> {
    // Check if already opted out
    const existing = await this.optOutRepo.findOneBy({ phoneNumber });
    if (existing) {
      return; // Already opted out
    }

    // Create opt-out record
    const optOut = this.optOutRepo.create({
      phoneNumber,
      source,
    });
    await this.optOutRepo.save(optOut);

    // Deactivate contact
    const contact = await this.contactRepo.findOneBy({ phoneNumber });
    if (contact) {
      contact.isActive = false;
      await this.contactRepo.save(contact);
    }

    console.log(`Opt-out processed for ${phoneNumber}`);
  }

  /**
   * Check if phone number is opted out
   */
  async isOptedOut(phoneNumber: string): Promise<boolean> {
    const optOut = await this.optOutRepo.findOneBy({ phoneNumber });
    return !!optOut;
  }

  /**
   * Get all opted-out numbers
   */
  async getAllOptedOutNumbers(): Promise<string[]> {
    const optOuts = await this.optOutRepo.find();
    return optOuts.map(o => o.phoneNumber);
  }
}

Webhook Handler for Inbound Messages

src/controllers/WebhookController.ts:

typescript
import { Request, Response } from 'express';
import { OptOutService } from '../services/OptOutService';
import { PlivoService } from '../services/PlivoService';

export class WebhookController {
  private optOutService = new OptOutService();
  private plivoService = new PlivoService();

  /**
   * Handle inbound SMS messages
   * Automatically process STOP keywords for opt-out
   */
  handleInboundMessage = async (req: Request, res: Response) => {
    try {
      const { From, To, Text } = req.body;

      // Check for opt-out keywords
      const optOutKeywords = ['stop', 'unsubscribe', 'cancel', 'end', 'quit'];
      const messageText = (Text || '').toLowerCase().trim();

      if (optOutKeywords.some(keyword => messageText.includes(keyword))) {
        // Process opt-out
        await this.optOutService.processOptOut(From, 'reply_stop');

        // Send confirmation message (TCPA best practice)
        await this.plivoService.sendSingleMessage(
          From,
          'You have been unsubscribed and will no longer receive messages from us.'
        );

        console.log(`Opt-out processed for ${From}`);
      }

      res.status(200).send('OK');
    } catch (error: any) {
      console.error('Webhook error:', error);
      res.status(500).json({ error: error.message });
    }
  };

  /**
   * Handle delivery status callbacks from Plivo
   */
  handleDeliveryStatus = async (req: Request, res: Response) => {
    try {
      const { MessageUUID, Status, To } = req.body;

      const messageRepo = AppDataSource.getRepository(Message);
      const message = await messageRepo.findOneBy({ plivoMessageUuid: MessageUUID });

      if (message) {
        // Update message status based on Plivo status
        switch (Status) {
          case 'delivered':
            message.status = MessageStatus.DELIVERED;
            message.deliveredAt = new Date();
            break;
          case 'undelivered':
            message.status = MessageStatus.UNDELIVERED;
            break;
          case 'failed':
            message.status = MessageStatus.FAILED;
            break;
        }

        await messageRepo.save(message);

        // Update campaign counters
        const campaignRepo = AppDataSource.getRepository(Campaign);
        const campaign = await campaignRepo.findOneBy({ id: message.campaignId });
        if (campaign && Status === 'delivered') {
          campaign.deliveredCount += 1;
          await campaignRepo.save(campaign);
        }
      }

      res.status(200).send('OK');
    } catch (error: any) {
      console.error('Delivery status error:', error);
      res.status(500).json({ error: error.message });
    }
  };
}

5. Frontend Implementation (React with Vite)

API Client Setup

frontend/src/services/api.ts:

typescript
import axios from 'axios';

const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api',
  headers: {
    'Content-Type': 'application/json',
  },
});

// Campaign API
export const campaignApi = {
  list: () => api.get('/campaigns'),
  get: (id: string) => api.get(`/campaigns/${id}`),
  create: (data: any) => api.post('/campaigns', data),
  schedule: (id: string) => api.post(`/campaigns/${id}/schedule`),
  analytics: (id: string) => api.get(`/campaigns/${id}/analytics`),
};

// Contact API
export const contactApi = {
  list: () => api.get('/contacts'),
  create: (data: any) => api.post('/contacts', data),
  import: (file: File) => {
    const formData = new FormData();
    formData.append('file', file);
    return api.post('/contacts/import', formData, {
      headers: { 'Content-Type': 'multipart/form-data' },
    });
  },
};

export default api;

Campaign Dashboard Component

frontend/src/components/CampaignDashboard.tsx:

tsx
import React, { useState, useEffect } from 'react';
import { campaignApi } from '../services/api';
import { format } from 'date-fns';

interface Campaign {
  id: string;
  name: string;
  status: string;
  totalRecipients: number;
  sentCount: number;
  deliveredCount: number;
  scheduledAt?: string;
  createdAt: string;
}

export const CampaignDashboard: React.FC = () => {
  const [campaigns, setCampaigns] = useState<Campaign[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    loadCampaigns();
  }, []);

  const loadCampaigns = async () => {
    try {
      const response = await campaignApi.list();
      setCampaigns(response.data);
    } catch (error) {
      console.error('Failed to load campaigns:', error);
    } finally {
      setLoading(false);
    }
  };

  const getStatusColor = (status: string) => {
    const colors = {
      draft: 'bg-gray-200 text-gray-800',
      scheduled: 'bg-blue-200 text-blue-800',
      sending: 'bg-yellow-200 text-yellow-800',
      completed: 'bg-green-200 text-green-800',
      failed: 'bg-red-200 text-red-800',
    };
    return colors[status as keyof typeof colors] || colors.draft;
  };

  if (loading) {
    return <div className="flex justify-center p-8">Loading campaigns...</div>;
  }

  return (
    <div className="max-w-7xl mx-auto p-6">
      <div className="flex justify-between items-center mb-6">
        <h1 className="text-3xl font-bold">SMS Campaigns</h1>
        <button
          onClick={() => window.location.href = '/campaigns/new'}
          className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700"
        >
          Create Campaign
        </button>
      </div>

      <div className="grid gap-4">
        {campaigns.map(campaign => (
          <div key={campaign.id} className="bg-white rounded-lg shadow p-6">
            <div className="flex justify-between items-start mb-4">
              <div>
                <h2 className="text-xl font-semibold">{campaign.name}</h2>
                <p className="text-gray-600 text-sm">
                  Created {format(new Date(campaign.createdAt), 'MMM d, yyyy')}
                </p>
              </div>
              <span className={`px-3 py-1 rounded-full text-sm font-medium ${getStatusColor(campaign.status)}`}>
                {campaign.status}
              </span>
            </div>

            <div className="grid grid-cols-3 gap-4 mb-4">
              <div>
                <p className="text-gray-600 text-sm">Recipients</p>
                <p className="text-2xl font-bold">{campaign.totalRecipients}</p>
              </div>
              <div>
                <p className="text-gray-600 text-sm">Sent</p>
                <p className="text-2xl font-bold">{campaign.sentCount}</p>
              </div>
              <div>
                <p className="text-gray-600 text-sm">Delivered</p>
                <p className="text-2xl font-bold">{campaign.deliveredCount}</p>
              </div>
            </div>

            {campaign.scheduledAt && (
              <p className="text-sm text-gray-600">
                Scheduled for {format(new Date(campaign.scheduledAt), 'MMM d, yyyy h:mm a')}
              </p>
            )}

            <div className="flex gap-2 mt-4">
              <button
                onClick={() => window.location.href = `/campaigns/${campaign.id}`}
                className="text-blue-600 hover:underline"
              >
                View Details
              </button>
              <button
                onClick={() => window.location.href = `/campaigns/${campaign.id}/analytics`}
                className="text-blue-600 hover:underline"
              >
                Analytics
              </button>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
};

Campaign Creation Form

frontend/src/components/CampaignForm.tsx:

tsx
import React, { useState } from 'react';
import { campaignApi } from '../services/api';

export const CampaignForm: React.FC = () => {
  const [formData, setFormData] = useState({
    name: '',
    messageTemplate: '',
    scheduledAt: '',
    tags: [] as string[],
  });

  const [errors, setErrors] = useState<Record<string, string>>({});

  const validateForm = () => {
    const newErrors: Record<string, string> = {};

    if (!formData.name) {
      newErrors.name = 'Campaign name is required';
    }

    if (!formData.messageTemplate) {
      newErrors.messageTemplate = 'Message template is required';
    } else {
      // TCPA compliance check
      const hasOptOut = formData.messageTemplate.toLowerCase().includes('stop');
      if (!hasOptOut) {
        newErrors.messageTemplate = 'Message must include opt-out instructions (e.g., "Reply STOP to opt-out")';
      }

      // Check message length (160 chars for single SMS)
      if (formData.messageTemplate.length > 160) {
        newErrors.messageTemplate = `Message is ${formData.messageTemplate.length} characters. SMS longer than 160 chars will be split into multiple messages.`;
      }
    }

    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    if (!validateForm()) {
      return;
    }

    try {
      const response = await campaignApi.create({
        ...formData,
        segmentCriteria: formData.tags.length > 0 ? { tags: formData.tags } : undefined,
      });

      alert('Campaign created successfully!');
      window.location.href = `/campaigns/${response.data.id}`;
    } catch (error: any) {
      alert(`Error: ${error.response?.data?.error || error.message}`);
    }
  };

  return (
    <div className="max-w-2xl mx-auto p-6">
      <h1 className="text-3xl font-bold mb-6">Create SMS Campaign</h1>

      <form onSubmit={handleSubmit} className="space-y-6">
        <div>
          <label className="block text-sm font-medium mb-2">Campaign Name *</label>
          <input
            type="text"
            value={formData.name}
            onChange={(e) => setFormData({ ...formData, name: e.target.value })}
            className="w-full border rounded-lg p-2"
            placeholder="Summer Sale 2024"
          />
          {errors.name && <p className="text-red-600 text-sm mt-1">{errors.name}</p>}
        </div>

        <div>
          <label className="block text-sm font-medium mb-2">Message Template *</label>
          <textarea
            value={formData.messageTemplate}
            onChange={(e) => setFormData({ ...formData, messageTemplate: e.target.value })}
            className="w-full border rounded-lg p-2"
            rows={4}
            placeholder="Hi {{firstName}}! Get 20% off this weekend. Shop now: example.com/sale Reply STOP to opt-out."
          />
          <p className="text-sm text-gray-600 mt-1">
            Character count: {formData.messageTemplate.length}/160
            {formData.messageTemplate.length > 160 && ` (${Math.ceil(formData.messageTemplate.length / 160)} segments)`}
          </p>
          <p className="text-sm text-gray-600">
            Available tokens: {'{{firstName}}'}, {'{{lastName}}'}, {'{{email}}'}
          </p>
          {errors.messageTemplate && (
            <p className="text-red-600 text-sm mt-1">{errors.messageTemplate}</p>
          )}
        </div>

        <div>
          <label className="block text-sm font-medium mb-2">Schedule Send Time (Optional)</label>
          <input
            type="datetime-local"
            value={formData.scheduledAt}
            onChange={(e) => setFormData({ ...formData, scheduledAt: e.target.value })}
            className="w-full border rounded-lg p-2"
          />
          <p className="text-sm text-gray-600 mt-1">
            Leave empty to send immediately. Note: Messages will not be sent before 8 AM or after 9 PM (TCPA compliance).
          </p>
        </div>

        <div>
          <label className="block text-sm font-medium mb-2">Target Tags (Optional)</label>
          <input
            type="text"
            placeholder="vip, newsletter, promotions"
            onChange={(e) => setFormData({
              ...formData,
              tags: e.target.value.split(',').map(t => t.trim()).filter(Boolean)
            })}
            className="w-full border rounded-lg p-2"
          />
          <p className="text-sm text-gray-600 mt-1">
            Comma-separated list of tags to segment recipients
          </p>
        </div>

        <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
          <h3 className="font-medium mb-2">TCPA Compliance Checklist</h3>
          <ul className="text-sm space-y-1 text-gray-700">
            <li>✓ All recipients have provided express written consent</li>
            <li>✓ Message includes clear opt-out instructions</li>
            <li>✓ Sender identity is clear</li>
            <li>✓ Respects quiet hours (8 AM - 9 PM local time)</li>
          </ul>
        </div>

        <div className="flex gap-4">
          <button
            type="submit"
            className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700"
          >
            Create Campaign
          </button>
          <button
            type="button"
            onClick={() => window.history.back()}
            className="bg-gray-200 px-6 py-2 rounded-lg hover:bg-gray-300"
          >
            Cancel
          </button>
        </div>
      </form>
    </div>
  );
};

Contact Import Component

frontend/src/components/ContactImport.tsx:

tsx
import React, { useState } from 'react';
import { contactApi } from '../services/api';

export const ContactImport: React.FC = () => {
  const [file, setFile] = useState<File | null>(null);
  const [importing, setImporting] = useState(false);
  const [result, setResult] = useState<any>(null);

  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files && e.target.files[0]) {
      setFile(e.target.files[0]);
      setResult(null);
    }
  };

  const handleImport = async () => {
    if (!file) return;

    setImporting(true);
    try {
      const response = await contactApi.import(file);
      setResult(response.data);
      alert('Import successful!');
    } catch (error: any) {
      alert(`Import failed: ${error.response?.data?.error || error.message}`);
    } finally {
      setImporting(false);
    }
  };

  return (
    <div className="max-w-2xl mx-auto p-6">
      <h1 className="text-3xl font-bold mb-6">Import Contacts</h1>

      <div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
        <h3 className="font-medium mb-2">CSV Format Requirements</h3>
        <p className="text-sm text-gray-700 mb-2">
          Your CSV file should include the following columns:
        </p>
        <code className="text-sm bg-white p-2 rounded block">
          phoneNumber,firstName,lastName,email,tags
        </code>
        <p className="text-sm text-gray-700 mt-2">
          Phone numbers must be in E.164 format (e.g., +14155550100)
        </p>
      </div>

      <div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center">
        <input
          type="file"
          accept=".csv"
          onChange={handleFileChange}
          className="hidden"
          id="file-upload"
        />
        <label
          htmlFor="file-upload"
          className="cursor-pointer text-blue-600 hover:underline"
        >
          {file ? file.name : 'Choose CSV file'}
        </label>

        {file && (
          <button
            onClick={handleImport}
            disabled={importing}
            className="mt-4 bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:bg-gray-400"
          >
            {importing ? 'Importing...' : 'Import Contacts'}
          </button>
        )}
      </div>

      {result && (
        <div className="mt-6 bg-green-50 border border-green-200 rounded-lg p-4">
          <h3 className="font-medium mb-2">Import Results</h3>
          <p>Imported: {result.imported} contacts</p>
          <p>Skipped: {result.skipped} contacts</p>
          {result.errors && result.errors.length > 0 && (
            <details className="mt-2">
              <summary className="cursor-pointer text-sm text-red-600">
                View errors ({result.errors.length})
              </summary>
              <ul className="text-sm mt-2 space-y-1">
                {result.errors.map((err: string, i: number) => (
                  <li key={i}>{err}</li>
                ))}
              </ul>
            </details>
          )}
        </div>
      )}

      <div className="mt-6 bg-yellow-50 border border-yellow-200 rounded-lg p-4">
        <h3 className="font-medium mb-2">Consent Requirements</h3>
        <p className="text-sm text-gray-700">
          Before importing contacts, ensure you have obtained express written consent
          from all recipients to receive SMS marketing messages, as required by TCPA
          regulations. Penalties for non-compliance can reach $1,500 per message.
        </p>
      </div>
    </div>
  );
};

6. Security Considerations

Rate Limiting

Implement rate limiting to prevent API abuse:

typescript
import rateLimit from 'express-rate-limit';

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // Limit each IP to 100 requests per windowMs
});

app.use('/api/', limiter);

Data Encryption

  • Environment Variables: Never commit .env files to version control
  • Database: Encrypt sensitive fields (phone numbers, email) at rest
  • API Keys: Store Plivo credentials securely in environment variables
  • HTTPS: Use TLS/SSL for all API communications in production

Input Validation

typescript
import { body, validationResult } from 'express-validator';

app.post('/api/campaigns', [
  body('name').isLength({ min: 1, max: 255 }).trim(),
  body('messageTemplate').isLength({ min: 1, max: 500 }),
], (req, res) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(400).json({ errors: errors.array() });
  }
  // Process request
});

TCPA Compliance Measures

  1. Consent Records: Store timestamp and method of consent for each contact
  2. Opt-Out Blacklist: Maintain and check against opt-out list before every send
  3. Quiet Hours: Enforce 8 AM - 9 PM sending window based on recipient time zone
  4. Audit Logs: Log all campaign sends, opt-outs, and delivery status for compliance audits

7. Testing the Application

Backend Testing

bash
cd backend
npm run dev

Test endpoints with curl:

bash
# Create a campaign
curl -X POST http://localhost:3000/api/campaigns \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Test Campaign",
    "messageTemplate": "Hi {{firstName}}! This is a test. Reply STOP to opt-out.",
    "scheduledAt": "2024-12-25T10:00:00Z"
  }'

# List campaigns
curl http://localhost:3000/api/campaigns

# Schedule campaign
curl -X POST http://localhost:3000/api/campaigns/{campaign-id}/schedule

Frontend Testing

bash
cd frontend
npm run dev

Access the application at http://localhost:5173

Integration Testing

  1. Create Test Contact: Add a test contact with your own phone number
  2. Create Test Campaign: Use the UI to create a campaign targeting the test contact
  3. Schedule and Send: Schedule the campaign and verify message delivery
  4. Test Opt-Out: Reply "STOP" to the received message
  5. Verify Opt-Out: Confirm the contact is marked as opted-out in the database

8. Deployment

Environment Configuration

Production .env:

env
NODE_ENV=production
PORT=3000
BASE_URL=https://your-domain.com

PLIVO_AUTH_ID=your_production_auth_id
PLIVO_AUTH_TOKEN=your_production_auth_token
PLIVO_PHONE_NUMBER=+1234567890

DB_HOST=your-db-host.com
DB_PORT=5432
DB_USERNAME=produser
DB_PASSWORD=strong_password
DB_DATABASE=sms_campaigns_prod

REDIS_HOST=your-redis-host.com
REDIS_PORT=6379
REDIS_PASSWORD=redis_password

Backend Deployment (Docker)

Dockerfile:

dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"]

Frontend Deployment (Vercel/Netlify)

Build command:

bash
npm run build

Configure environment variable:

VITE_API_URL=https://api.your-domain.com/api

Database Migrations

bash
npm run typeorm migration:generate -- -n InitialSchema
npm run typeorm migration:run

Monitoring and Logging

  • Use services like Sentry for error tracking
  • Configure Plivo webhooks for delivery status updates
  • Set up CloudWatch or DataDog for infrastructure monitoring
  • Implement Prometheus metrics for queue monitoring

9. Troubleshooting

Common Issues

Message Delivery Failures:

  • Cause: Invalid E.164 phone number format
  • Solution: Validate all phone numbers match pattern ^\+[1-9]\d{1,14}$
  • Reference: E.164 format guide

TCPA Violations:

  • Cause: Missing opt-out instructions in message
  • Solution: Enforce validation in CampaignController to require "STOP" keyword
  • Penalty: Up to $1,500 per non-compliant message (source)

Queue Processing Delays:

  • Cause: Rate limiting or Redis connection issues
  • Solution: Monitor BullMQ dashboard, adjust MAX_MESSAGES_PER_SECOND in .env
  • Check: Verify Redis is running: redis-cli ping

Plivo Authentication Errors:

  • Cause: Invalid Auth ID or Auth Token
  • Solution: Verify credentials in Plivo Console
  • Check: Test with curl: curl -u AUTH_ID:AUTH_TOKEN https://api.plivo.com/v1/Account/AUTH_ID/

Database Connection Failures:

  • Cause: Incorrect PostgreSQL credentials or network issues
  • Solution: Verify connection string, check PostgreSQL is running
  • Test: psql -h localhost -U postgres -d sms_campaigns

Conclusion

You have successfully built a production-ready SMS marketing campaign platform using Plivo, Node.js, and Vite with React/Vue. This system includes:

Full-stack architecture with Express backend and modern frontend ✅ Campaign management with scheduling and segmentation ✅ Contact management with CSV import and tagging ✅ Reliable bulk messaging using BullMQ queue system ✅ TCPA compliance with opt-out management and quiet hours ✅ Real-time analytics and delivery tracking ✅ Scalable infrastructure ready for production deployment

Key Compliance Reminders:

  • Always obtain express written consent before sending marketing messages
  • Include clear opt-out instructions in every message (e.g., "Reply STOP to opt-out")
  • Honor opt-out requests immediately (within 10 business days per 2025 TCPA rules)
  • Respect quiet hours (8 AM - 9 PM local time)
  • Maintain consent and opt-out records for compliance audits
  • Penalties range from $500-$1,500 per violation

Next Steps:

  1. Enhanced Analytics: Implement click tracking, conversion tracking, and ROI calculation
  2. A/B Testing: Add split testing functionality for message variations
  3. Advanced Segmentation: Implement behavioral targeting and RFM analysis
  4. Multi-Channel: Integrate WhatsApp Business API via Plivo for additional channels
  5. AI Personalization: Use machine learning for send-time optimization and content recommendations
  6. Compliance Automation: Implement automatic 10DLC registration and carrier compliance monitoring
  7. White Labeling: Add multi-tenant support for agency use cases

Resources:

This foundation provides everything needed to build a compliant, scalable SMS marketing platform that respects user privacy while delivering effective campaigns.