code examples
code examples
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:
┌─────────────────┐ ┌──────────────────────┐ ┌─────────────────┐
│ 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 dashboardExpected 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
-
Create Project Structure:
bashmkdir sms-campaign-platform cd sms-campaign-platform mkdir backend frontend cd backend npm init -y -
Install Backend Dependencies:
bashnpm 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 nodemonKey dependencies explained:
plivo: Official Plivo Node.js SDK (documentation)typeorm+pg: ORM and PostgreSQL driver for database operationsbullmq+ioredis: Queue system for reliable bulk message processingcsv-parser+multer: CSV contact import functionalitydate-fns: Date manipulation for campaign scheduling
-
Initialize TypeScript:
bashnpx tsc --initConfigure
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"] } -
Create Backend Folder Structure:
bashmkdir -p src/{entities,services,controllers,queues,middleware,utils} touch src/index.ts touch .env .gitignoreProject structure:
textbackend/ ├── 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 -
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=8IMPORTANT: 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).
-
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
-
Create Vite Project with React or Vue:
For React:
bashcd ../frontend npm create vite@latest . -- --template react-tsFor Vue:
bashcd ../frontend npm create vite@latest . -- --template vue-ts -
Install Frontend Dependencies:
bashnpm 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 -
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; -
Frontend Folder Structure:
textfrontend/ ├── 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
-
Create Database Entities:
src/entities/Contact.ts:typescriptimport { 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:typescriptimport { 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:typescriptimport { 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:typescriptimport { 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; } -
Initialize Database Connection:
src/config/database.ts:typescriptimport '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:
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:
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:
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:
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:
- Obtain express written consent before sending marketing messages
- Provide clear opt-out instructions in every message
- Honor opt-out requests immediately (within 10 business days as of 2025)
- Respect quiet hours (no messages before 8 AM or after 9 PM local time)
- 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:
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:
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:
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:
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:
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:
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:
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
.envfiles 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
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
- Consent Records: Store timestamp and method of consent for each contact
- Opt-Out Blacklist: Maintain and check against opt-out list before every send
- Quiet Hours: Enforce 8 AM - 9 PM sending window based on recipient time zone
- Audit Logs: Log all campaign sends, opt-outs, and delivery status for compliance audits
7. Testing the Application
Backend Testing
cd backend
npm run devTest endpoints with curl:
# 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}/scheduleFrontend Testing
cd frontend
npm run devAccess the application at http://localhost:5173
Integration Testing
- Create Test Contact: Add a test contact with your own phone number
- Create Test Campaign: Use the UI to create a campaign targeting the test contact
- Schedule and Send: Schedule the campaign and verify message delivery
- Test Opt-Out: Reply "STOP" to the received message
- Verify Opt-Out: Confirm the contact is marked as opted-out in the database
8. Deployment
Environment Configuration
Production .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_passwordBackend Deployment (Docker)
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:
npm run buildConfigure environment variable:
VITE_API_URL=https://api.your-domain.com/api
Database Migrations
npm run typeorm migration:generate -- -n InitialSchema
npm run typeorm migration:runMonitoring 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
CampaignControllerto 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_SECONDin.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:
- Enhanced Analytics: Implement click tracking, conversion tracking, and ROI calculation
- A/B Testing: Add split testing functionality for message variations
- Advanced Segmentation: Implement behavioral targeting and RFM analysis
- Multi-Channel: Integrate WhatsApp Business API via Plivo for additional channels
- AI Personalization: Use machine learning for send-time optimization and content recommendations
- Compliance Automation: Implement automatic 10DLC registration and carrier compliance monitoring
- White Labeling: Add multi-tenant support for agency use cases
Resources:
- Plivo SMS API Documentation
- Plivo Node.js SDK Reference
- TCPA Compliance Guide 2024
- FCC TCPA Guidelines
- Plivo 10DLC Registration
- BullMQ Documentation
- TypeORM Documentation
- Vite Documentation
This foundation provides everything needed to build a compliant, scalable SMS marketing platform that respects user privacy while delivering effective campaigns.