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 Copy
┌─────────────────┐ ┌──────────────────────┐ ┌─────────────────┐
│ 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
Create Project Structure:
Bash Copy
mkdir sms-campaign-platform
cd sms-campaign-platform
mkdir backend frontend
cd backend
npm init -y
Install Backend Dependencies:
Bash Copy
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
Initialize TypeScript:
Configure tsconfig.json:
JSON Copy
{
"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:
Bash Copy
mkdir -p src/{entities,services,controllers,queues,middleware,utils}
touch src/index.ts
touch .env .gitignore
Project structure:
Text Copy
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
Configure Environment Variables (.env):
.env Copy
# 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).
Update package.json Scripts:
JSON Copy
{
"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:
Bash Copy
cd ../frontend
npm create vite@latest . -- --template react-ts
For Vue:
Bash Copy
cd ../frontend
npm create vite@latest . -- --template vue-ts
Install Frontend Dependencies:
Bash Copy
npm install
npm install axios react-router-dom
npm install @tanstack/react-query
npm install vue-router pinia
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
npm install recharts
npm install chart.js vue-chartjs
npm install date-fns
npm install react-hook-form zod @hookform/resolvers
npm install vee-validate yup
Configure Tailwind CSS:
Update tailwind.config.js:
JavaScript Copy
export default {
content : [
"./index.html" ,
"./src/**/*.{js,ts,jsx,tsx,vue}" ,
],
theme : {
extend : {},
},
plugins : [],
}
Add to src/index.css:
CSS Copy
@tailwind base;
@tailwind components;
@tailwind utilities;
Frontend Folder Structure:
Text Copy
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
Create Database Entities:
src/entities/Contact.ts:
TypeScript Copy
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 ;
@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 Copy
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 Copy
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 Copy
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 ;
@CreateDateColumn ()
optedOutAt : Date ;
}
Initialize Database Connection:
src/config/database.ts:
TypeScript Copy
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 Copy
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);
}
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` ,
});
return response;
} catch (error : any ) {
console .error ('Plivo send error:' , error);
throw new Error (`Failed to send SMS: ${error.message} ` );
}
}
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 {
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} ` );
}
}
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 Copy
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 ();
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);
}
async getCampaignRecipients (campaignId : string ): Promise <Contact []> {
const campaign = await this .campaignRepo .findOneBy ({ id : campaignId });
if (!campaign) {
throw new Error ('Campaign not found' );
}
const optOuts = await this .optOutRepo .find ();
const optedOutNumbers = optOuts.map (o => o.phoneNumber );
let query = this .contactRepo .createQueryBuilder ('contact' )
.where ('contact.isActive = :isActive' , { isActive : true })
.andWhere ('contact.phoneNumber NOT IN (:...optedOut)' , {
optedOut : optedOutNumbers.length > 0 ? optedOutNumbers : ['' ]
});
if (campaign.segmentCriteria ) {
const { tags } = campaign.segmentCriteria ;
if (tags && tags.length > 0 ) {
query = query.andWhere ('contact.tags && ARRAY[:...tags]' , { tags });
}
}
return await query.getMany ();
}
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 || '' );
if (contact.customFields ) {
Object .entries (contact.customFields ).forEach (([key, value] ) => {
message = message.replace (
new RegExp (`\\{\\{${key} \\}\\}` , 'g' ),
String (value)
);
});
}
return message.trim ();
}
async scheduleCampaign (campaignId : string ): Promise <void > {
const campaign = await this .campaignRepo .findOneBy ({ id : campaignId });
if (!campaign) {
throw new Error ('Campaign not found' );
}
const recipients = await this .getCampaignRecipients (campaignId);
if (recipients.length === 0 ) {
throw new Error ('No eligible recipients found for campaign' );
}
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);
campaign.status = CampaignStatus .SCHEDULED ;
campaign.totalRecipients = recipients.length ;
await this .campaignRepo .save (campaign);
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 ,
});
}
}
}
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 Copy
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 ,
},
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);
});
}
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 ,
},
});
}
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` );
}
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) {
const tomorrow = new Date ();
tomorrow.setDate (tomorrow.getDate () + 1 );
tomorrow.setHours (9 , 0 , 0 , 0 );
throw new Error ('Quiet hours - rescheduling' );
}
try {
const response = await this .plivoService .sendSingleMessage (data.to , data.text );
message.status = MessageStatus .SENT ;
message.plivoMessageUuid = response.message_uuid ?.[0 ];
message.sentAt = new Date ();
await messageRepo.save (message);
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);
const campaign = await campaignRepo.findOneBy ({ id : data.campaignId });
if (campaign) {
campaign.failedCount += 1 ;
await campaignRepo.save (campaign);
}
throw error;
}
}
async close ( ) {
await this .queue .close ();
await this .worker .close ();
this .connection .disconnect ();
}
}
Campaign Controller
src/controllers/CampaignController.ts:
TypeScript Copy
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 ;
if (!name || !messageTemplate) {
return res.status (400 ).json ({
error : 'Name and message template are required'
});
}
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:
TypeScript Copy
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 );
async processOptOut (phoneNumber : string , source : string = 'reply_stop' ): Promise <void > {
const existing = await this .optOutRepo .findOneBy ({ phoneNumber });
if (existing) {
return ;
}
const optOut = this .optOutRepo .create ({
phoneNumber,
source,
});
await this .optOutRepo .save (optOut);
const contact = await this .contactRepo .findOneBy ({ phoneNumber });
if (contact) {
contact.isActive = false ;
await this .contactRepo .save (contact);
}
console .log (`Opt-out processed for ${phoneNumber} ` );
}
async isOptedOut (phoneNumber : string ): Promise <boolean > {
const optOut = await this .optOutRepo .findOneBy ({ phoneNumber });
return !!optOut;
}
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 Copy
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 ();
handleInboundMessage = async (req : Request , res : Response ) => {
try {
const { From , To , Text } = req.body ;
const optOutKeywords = ['stop' , 'unsubscribe' , 'cancel' , 'end' , 'quit' ];
const messageText = (Text || '' ).toLowerCase ().trim ();
if (optOutKeywords.some (keyword => messageText.includes (keyword))) {
await this .optOutService .processOptOut (From , 'reply_stop' );
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 });
}
};
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) {
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);
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 Copy
import axios from 'axios' ;
const api = axios.create ({
baseURL : import .meta .env .VITE_API_URL || 'http://localhost:3000/api' ,
headers : {
'Content-Type' : 'application/json' ,
},
});
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` ),
};
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 Copy
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 >
);
};
frontend/src/components/CampaignForm.tsx:
TSX Copy
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 {
const hasOptOut = formData.messageTemplate .toLowerCase ().includes ('stop' );
if (!hasOptOut) {
newErrors.messageTemplate = 'Message must include opt-out instructions (e.g., "Reply STOP to opt-out")' ;
}
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 >
);
};
frontend/src/components/ContactImport.tsx:
TSX Copy
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 Copy
import rateLimit from 'express-rate-limit' ;
const limiter = rateLimit ({
windowMs : 15 * 60 * 1000 ,
max : 100 ,
});
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
TypeScript Copy
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 () });
}
});
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
Bash Copy
cd backend
npm run dev
Test endpoints with curl:
Bash Copy
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"
}'
curl http://localhost:3000/api/campaigns
curl -X POST http://localhost:3000/api/campaigns/{campaign-id}/schedule
Frontend Testing
Bash Copy
cd frontend
npm run dev
Access 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:
.env Copy
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 Copy
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:
Configure environment variable:
Code Copy
VITE_API_URL=https://api.your-domain.com/api
Database Migrations
Bash Copy
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:
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:
This foundation provides everything needed to build a compliant, scalable SMS marketing platform that respects user privacy while delivering effective campaigns.
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:
plivo): Official SDK for interacting with Plivo's SMS APIFrontend:
System Architecture:
Expected Outcome: A complete SMS marketing platform featuring:
Prerequisites:
+14155550100)1. Setting Up the Project
Backend Setup
Create Project Structure:
mkdir sms-campaign-platform cd sms-campaign-platform mkdir backend frontend cd backend npm init -yInstall Backend Dependencies:
Key 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 schedulingInitialize TypeScript:
Configure
tsconfig.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:
mkdir -p src/{entities,services,controllers,queues,middleware,utils} touch src/index.ts touch .env .gitignoreProject structure:
Configure Environment Variables (.env):
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).
Update package.json Scripts:
{ "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:
cd ../frontend npm create vite@latest . -- --template react-tsFor Vue:
cd ../frontend npm create vite@latest . -- --template vue-tsInstall Frontend Dependencies:
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 yupConfigure Tailwind CSS:
Update
tailwind.config.js:/** @type {import('tailwindcss').Config} */ export default { content: [ "./index.html", "./src/**/*.{js,ts,jsx,tsx,vue}", ], theme: { extend: {}, }, plugins: [], }Add to
src/index.css:@tailwind base; @tailwind components; @tailwind utilities;Frontend Folder Structure:
2. Configuring Database and Plivo
Database Setup
Create Database Entities:
src/entities/Contact.ts: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: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: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: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; }Initialize Database Connection:
src/config/database.ts: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: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:
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
.envfiles to version controlInput 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
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:5173Integration Testing
8. Deployment
Environment Configuration
Production .env:
Backend Deployment (Docker)
Dockerfile:Frontend Deployment (Vercel/Netlify)
Build command:
Configure environment variable:
Database Migrations
Monitoring and Logging
9. Troubleshooting
Common Issues
Message Delivery Failures:
^\+[1-9]\d{1,14}$TCPA Violations:
CampaignControllerto require "STOP" keywordQueue Processing Delays:
MAX_MESSAGES_PER_SECONDin.envredis-cli pingPlivo Authentication Errors:
curl -u AUTH_ID:AUTH_TOKEN https://api.plivo.com/v1/Account/AUTH_ID/Database Connection Failures:
psql -h localhost -U postgres -d sms_campaignsConclusion
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:
Next Steps:
Resources:
This foundation provides everything needed to build a compliant, scalable SMS marketing platform that respects user privacy while delivering effective campaigns.