Build a robust SMS marketing campaign application using Node.js, the Express framework, and the Plivo communication platform API. Learn how to set up the project, send bulk SMS messages, handle replies and opt-outs, manage subscribers, and deploy your application securely and reliably.
Build a system capable of managing subscriber lists, creating SMS campaigns, sending messages asynchronously via a job queue, handling incoming messages (like replies or STOP requests), and logging message statuses. This approach solves the challenge of sending personalized or bulk SMS messages reliably and scalably, directly from your application backend.
Common Use Cases:
E-commerce: Order confirmations, shipping notifications, and promotional campaigns
Healthcare: Appointment reminders, prescription notifications, and health alerts
Financial Services: Transaction alerts, fraud detection, and account updates
Events & Hospitality: Booking confirmations, event reminders, and last-minute updates
SaaS Applications: User onboarding sequences, feature announcements, and engagement campaigns
Technologies Used:
Node.js: JavaScript runtime environment
Express.js: Minimalist web framework for Node.js, used for building the API layer
Plivo Node.js SDK: Simplifies interaction with the Plivo SMS API. Note: This guide uses Plivo SDK v4.x (plivo@4.x.x). Plivo SDK v5+ introduces breaking changes in initialization and API methods. Verify your installed version with npm list plivo and consult Plivo's migration guide if using v5+
PostgreSQL: Relational database for storing subscriber and campaign data
Prisma: Modern ORM for Node.js and TypeScript, simplifying database interactions
BullMQ: Robust job queue system based on Redis, for handling asynchronous SMS sending
Redis: In-memory data structure store, used by BullMQ
dotenv: Module for loading environment variables from a .env file
ngrok (for development): Tool to expose local servers to the internet for webhook testing
System Architecture:
The architecture follows a modular request-response pattern with asynchronous job processing:
API Layer: Express receives campaign creation requests from clients
Database Layer: Prisma ORM stores subscribers, campaigns, and message logs in PostgreSQL
Job Queue: BullMQ queues SMS jobs to Redis for asynchronous processing
Worker Process: BullMQ workers consume jobs and send messages via Plivo API
Webhook Handler: Express endpoints receive delivery status and inbound SMS from Plivo
Basic understanding of Node.js, Express, REST APIs, and databases
ngrok installed for local development webhook testing
Production Webhook URL: For production deployment, you need a stable, publicly accessible URL for Plivo webhooks. This is typically provided by your hosting platform (e.g., Heroku app URL, AWS Load Balancer URL) or a dedicated tunneling service. ngrok's free tier URLs are temporary and not suitable for production.
TCPA Compliance (US): If sending marketing SMS to US numbers, you must comply with the Telephone Consumer Protection Act (TCPA). Requirements include: (1) obtaining prior express written consent from recipients, (2) providing clear opt-out instructions in every message, (3) maintaining consent records, and (4) honoring opt-outs immediately. Non-compliance carries penalties up to $1,500 per violation. Similar regulations exist in other jurisdictions (e.g., GDPR in EU, CASL in Canada).
By the end of this guide, you'll have a functional backend application capable of managing and executing SMS campaigns, ready for further enhancement and deployment.
1. Project Setup and Configuration
Initialize the Node.js project and install necessary dependencies.
plivo@4: Ensures SDK v4.x compatibility with code examples (v5+ has breaking changes)
bullmq@4: Stable version compatible with the worker patterns shown (v5+ changes queue initialization)
Use exact versions in production package.json for stability
Dependency Purposes:
express: Web framework for API endpoints
plivo@4: Plivo SDK for sending SMS
@prisma/client: Auto-generated database client
dotenv: Load environment variables from .env file
bullmq@4: Job queue for asynchronous SMS processing
ioredis: Redis client for BullMQ
express-validator: Request validation middleware
winston: Production-grade logging library
express-rate-limit: Rate limiting middleware
Install Development Dependencies:
Bash
npm install --save-dev prisma nodemon jest supertest
prisma: For Prisma CLI commands (migrations, generation)
nodemon: Automatically restarts the server during development
jest, supertest: For testing
Initialize Prisma:
Bash
npx prisma init --datasource-provider postgresql
This creates a prisma directory with a schema.prisma file and a .env file.
Configure Environment Variables (.env):
Open the .env file created by Prisma and add the following variables. Remove any unnecessary quotes.
Security Best Practices for .env Files:
Never commit .env files to version control (add to .gitignore)
Use different .env files for development, staging, and production
Rotate credentials regularly, especially after team member departures
Use secrets management services (AWS Secrets Manager, HashiCorp Vault) in production
Restrict file permissions: chmod 600 .env on Unix systems
.env
# .env
# Database
# Example: postgresql://user:password@host:port/database?schema=public
# Use quotes only if password contains special characters like @, #, $, %, etc.
DATABASE_URL="postgresql://your_db_user:your_db_password@localhost:5432/sms_campaigns"
# Plivo Credentials
PLIVO_AUTH_ID=YOUR_PLIVO_AUTH_ID
PLIVO_AUTH_TOKEN=YOUR_PLIVO_AUTH_TOKEN
PLIVO_SENDER_ID=YOUR_PLIVO_PHONE_NUMBER_OR_SENDER_ID # Plivo phone number in E.164 format or Alphanumeric Sender ID
# Redis Connection (for BullMQ)
# Example for local Redis: redis://localhost:6379
# Example for cloud Redis with password: redis://:password@host:port
REDIS_URL="redis://localhost:6379"
# Application Settings
PORT=3000
API_KEY=YOUR_SECRET_API_KEY # Simple API key for securing endpoints
BASE_URL=http://localhost:3000 # Base URL for generating webhook URLs (update for development/production)
DATABASE_URL: Connection string for your PostgreSQL database. Replace placeholders with your actual credentials. Use quotes if your password or user contains special characters.
PLIVO_AUTH_ID, PLIVO_AUTH_TOKEN: Your Plivo API credentials
PLIVO_SENDER_ID: The Plivo phone number (in E.164 format, e.g., +14155551212) or Alphanumeric Sender ID you'll use to send messages. Numbers are required for US/Canada.
REDIS_URL: Connection string for your Redis instance
PORT: Port your Express application will run on
API_KEY: A simple secret key for basic API authentication (use more robust methods like JWT for production)
BASE_URL: The public-facing base URL of your application. Critical for webhook configuration. Use your ngrok URL during development, and your production URL otherwise.
Add nodemon script to package.json:
Update the scripts section in your package.json:
// src/server.jsrequire('dotenv').config();
const app = require('./app');
const logger = require('./utils/logger'); // We'll create this laterconstPORT = process.env.PORT || 3000;
app.listen(PORT, () => {
logger.info(`Server running on port ${PORT}`);
logger.info(`API base URL: ${process.env.BASE_URL}`);
// Note: BullMQ workers should typically run in a separate process.
});
Basic App Setup (src/app.js):
JavaScript
// src/app.jsconst express = require('express');
const rateLimit = require('express-rate-limit');
// Routes and middleware will be imported and used as defined in later sectionsconst app = express();
// Middleware (Order matters!)// Apply rate limiting earlyconst limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutesmax: 100, // Limit each IP to 100 requests per windowMsstandardHeaders: true, // Return rate limit info in the `RateLimit-*` headerslegacyHeaders: false, // Disable the `X-RateLimit-*` headersmessage: 'Too many requests from this IP, please try again after 15 minutes',
});
app.use(limiter);
// We will mount webhook routes *before* express.json() if raw body is needed (See Section 6.4)// Parse JSON bodies for most routes
app.use(express.json());
// Parse URL-encoded bodies
app.use(express.urlencoded({ extended: true }));
// Request logging, Authentication, API routes, and Error Handling will be added later.// Health check endpoint
app.get('/health', (req, res) => {
res.status(200).json({ status: 'UP', timestamp: newDate().toISOString() });
});
// Routes and error handler will be mounted here in subsequent sections.module.exports = app;
This initial setup provides a solid foundation with essential dependencies, environment configuration, and a basic project structure.
2. Integrating with Plivo
Configure Plivo and set up the necessary components to interact with its API.
Sign Up/Log In to Plivo:
Go to plivo.com and sign up for an account or log in.
Trial Account Limitation: If using a trial account, you can only send SMS to phone numbers verified in your Plivo console (Phone Numbers > Sandbox Numbers). You'll also have a ""[Plivo Trial]"" prefix added to messages. Purchase credits to remove these limitations.
Get API Credentials:
Navigate to your Plivo Console Dashboard
Locate the Auth ID and Auth Token on the right side of the dashboard
Copy these values and paste them into your .env file for PLIVO_AUTH_ID and PLIVO_AUTH_TOKEN. Keep these secret! Do not commit them to version control.
Trial Account Limitations: Trial accounts have restricted capabilities: (1) SMS can only be sent to phone numbers verified in Plivo Console > Phone Numbers > Sandbox Numbers, (2) All messages include a "[Plivo Trial]" prefix, (3) Limited to 20 free messages. Purchase Plivo credits to remove these restrictions and enable production use.
Get a Plivo Phone Number:
You need an SMS-enabled Plivo phone number to send and receive messages (required for US/Canada)
Go to Phone Numbers > Buy Numbers in the console
Search for numbers with SMS capability in your desired country
Purchase a number. Pricing: Plivo phone numbers typically cost $0.80–$2.00/month (varies by country). SMS rates vary by destination: US domestic SMS costs approximately $0.0035/segment, international rates range $0.005–$0.20/segment. Check Plivo's pricing page for current rates.
Copy the full number in E.164 format (e.g., +14155551212) and add it to your .env file as PLIVO_SENDER_ID
Alternatively, for supported countries outside North America, you might use an Alphanumeric Sender ID configured in your Plivo account under Messaging > Sender IDs.
Alphanumeric Sender ID Notes:
Available in 200+ countries excluding US, Canada, and China
Limited to 11 characters, alphanumeric only
Supports one-way messaging (outbound only, no replies)
Requires registration in some countries (e.g., India, Saudi Arabia)
Check Plivo's Alphanumeric Sender ID documentation for country-specific requirements
Configure Plivo Application for Webhooks:
Plivo uses webhooks to notify your application about incoming messages and delivery status updates. You need to create a Plivo Application and link your Plivo number to it.
Go to Messaging > Applications > XML
Click ""Add New Application""
Application Name: Give it a descriptive name (e.g., ""Node Campaign App"")
Message URL: This is where Plivo sends incoming SMS data. Enter the publicly accessible URL for your incoming message handler. During development, use ngrok.
Start ngrok: ngrok http 3000 (or your PORT)
Copy the HTTPS forwarding URL provided by ngrok (e.g., https://<unique_id>.ngrok.io)
Your Message URL will be: https://<unique_id>.ngrok.io/webhooks/plivo/incoming (we'll create this endpoint later)
Crucially update your .envBASE_URL to this ngrok URL during development
Method: Set to POST
Delivery Report URL (Optional but Recommended): Where Plivo sends message status updates (sent, failed, delivered). Set this similarly: https://<unique_id>.ngrok.io/webhooks/plivo/status. Set method to POST.
Click ""Create Application""
Link Number to Application: Go back to Phone Numbers > Your Numbers. Click on the number you purchased. In the ""Application Type"" section, select ""XML Application"". From the ""Plivo Application"" dropdown, choose the application you just created (""Node Campaign App""). Click ""Update Number"".
Common Webhook Configuration Issues:
Webhook not receiving requests: Verify ngrok is running and BASE_URL matches ngrok URL
Signature validation failures: Ensure raw request body is preserved before JSON parsing
Timeouts: Plivo expects responses within 3 seconds; process long-running tasks asynchronously
Create Plivo Service (src/services/plivoService.js):
This service encapsulates interaction with the Plivo SDK.
JavaScript
// src/services/plivoService.jsconst plivo = require('plivo');
const logger = require('../utils/logger');
const client = new plivo.Client(process.env.PLIVO_AUTH_ID, process.env.PLIVO_AUTH_TOKEN);
constsendSms = async (to, text, callbackUrl = null) => {
const senderId = process.env.PLIVO_SENDER_ID;
if (!senderId) {
logger.error('PLIVO_SENDER_ID is not configured in .env');
thrownewError('Sender ID not configured');
}
// Basic validationif (!to || !text) {
thrownewError('Recipient number (to) and message text cannot be empty.');
}
// Add more robust E.164 validation if needed
logger.info(`Attempting to send SMS from ${senderId} to ${to}`);
try {
const params = {
src: senderId,
dst: to,
text: text,
};
// Include status callback URL if providedif (callbackUrl) {
params.url = callbackUrl;
params.method = 'POST'; // Method Plivo uses to call your callback URL
}
const response = await client.messages.create(params);
logger.info(`SMS submitted to Plivo for ${to}. Message UUID: ${response.messageUuid}`);
// Note: This response indicates submission to Plivo, not final delivery.// Delivery status comes via webhook if configured.return { success: true, messageUuid: response.messageUuid };
} catch (error) {
logger.error(`Error sending SMS via Plivo to ${to}: ${error.message}`, { error });
// Plivo errors often have more details in error.response or error.messagethrow error; // Re-throw for handling upstream (e.g., in the job queue worker)
}
};
// Function to generate Plivo XML for replying to incoming messagesconstcreateReplyXml = (responseText) => {
const response = new plivo.Response();
response.addMessage(responseText); // Simple text replyreturn response.toXML();
};
// Function to validate incoming Plivo webhooksconstvalidateWebhookSignature = (req) => {
const signature = req.headers['x-plivo-signature-v3'];
const nonce = req.headers['x-plivo-signature-v3-nonce'];
// Construct the full URL Plivo used.// Note: This might be unreliable if behind certain proxies that rewrite URL components.// Ensure BASE_URL is correctly set to the public-facing URL Plivo hits.const url = process.env.BASE_URL + req.originalUrl;
const method = req.method;
const authToken = process.env.PLIVO_AUTH_TOKEN;
if (!signature || !nonce) {
logger.warn('Missing Plivo signature headers.');
returnfalse;
}
try {
// CRITICAL: Plivo's validation requires the raw, unparsed request body for POST/PUT.// If using express.json() globally *before* this check, `req.body` will be parsed JSON,// which WILL cause validation failure.// You MUST capture the raw body *before* JSON parsing for webhook routes.// See `plivoWebhookAuth.js` and `app.js` setup for handling this.// We assume here that the raw body (Buffer) is available via `req.rawBody` (or similar).const postParams = req.method === 'POST' ? (req.rawBody || req.body) : {}; // Prioritize rawBody if available// Note: Plivo SDK v4's validateV3Signature expects body params as an object for POST.// If using rawBody (Buffer), you might need to parse it *conditionally* ONLY for validation// if the SDK helper requires an object map, or check if the SDK offers a way to pass the raw buffer directly.// For simplicity, we pass `postParams` but emphasize the raw body requirement.const isValid = plivo.validateV3Signature(method, url, nonce, authToken, signature, postParams);
// const isValid = plivo.Utils.validatesignature(url, nonce, signature, authToken); // Alternative if simpler validation is sufficient/availableif (!isValid) {
logger.warn('Invalid Plivo webhook signature received.', { url, method, nonce, signature, headers: req.headers, bodyUsed: postParams === req.rawBody ? 'rawBody' : 'req.body' });
}
return isValid;
} catch (error) {
logger.error('Error validating Plivo webhook signature:', { error, url, method });
returnfalse;
}
};
module.exports = {
sendSms,
createReplyXml,
validateWebhookSignature,
};
Initialize the Plivo client using credentials from .env
sendSms handles sending a single message, including optional status callback configuration
createReplyXml generates the XML Plivo expects for automatic replies via webhooks
validateWebhookSignature secures your webhook endpoints. Implementation Note: This function requires the raw request body for validation to work correctly with POST requests. Ensure your middleware setup provides this (see Section 6). The URL construction using BASE_URL + req.originalUrl might also need adjustment if your application runs behind complex proxies.
Common Plivo API Errors:
Error 15003 (Invalid From Number): Verify PLIVO_SENDER_ID is in E.164 format and number is active
Error 15006 (Destination Not Reachable): Check recipient number format and network availability
Error 20003 (Authentication Failed): Verify PLIVO_AUTH_ID and PLIVO_AUTH_TOKEN are correct
Error 20006 (Insufficient Balance): Add credits to your Plivo account
3. Database Schema and Data Layer
Use Prisma to define your database schema and interact with PostgreSQL.
Define Schema (prisma/schema.prisma):
Update your schema file with models for Subscribers, Campaigns, and potentially Sent Messages for tracking.
Prisma
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Subscriber {
id Int @id @default(autoincrement())
phoneNumber String @unique // E.164 format
firstName String?
lastName String?
isActive Boolean @default(true) // For opt-outs
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Optional: Link to campaigns they are part of (many-to-many)
// campaigns Campaign[]
sentMessages SentMessage[] // Track messages sent to this subscriber
}
model Campaign {
id Int @id @default(autoincrement())
name String
messageBody String
status String @default("draft") // e.g., draft, scheduled, sending, sent, failed
scheduledAt DateTime? // For future scheduling
sentAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Track messages sent for this campaign
sentMessages SentMessage[]
}
model SentMessage {
id Int @id @default(autoincrement())
campaignId Int
subscriberId Int
messageUuid String? @unique // Plivo's message identifier
status String // e.g., queued, processing, submitted, delivered, failed, undelivered
statusCallback Json? // Store the full callback payload from Plivo
sentAt DateTime @default(now()) // Time job was queued/processed
lastStatusAt DateTime? // Time of last status update from Plivo
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade) // Cascade delete if campaign deleted
subscriber Subscriber @relation(fields: [subscriberId], references: [id], onDelete: Cascade) // Cascade delete if subscriber deleted
@@index([campaignId])
@@index([subscriberId])
@@index([status])
@@index([messageUuid])
}
Subscriber: Stores contact information and opt-out status (isActive)
Campaign: Defines the message content and tracks the overall campaign status
SentMessage: Logs each individual message attempt, linking Campaigns and Subscribers, storing the Plivo messageUuid, and tracking delivery status. Added onDelete: Cascade for referential integrity.
Status Field Design Considerations:
Approach
Pros
Cons
Best For
String
Flexible, easy to add values
No type safety, prone to typos
Rapid prototyping, changing requirements
Enum
Type-safe, prevents invalid values
Requires migration to add values
Stable, well-defined status flows
For production applications, consider using enums for better type safety and validation. This guide uses strings for flexibility during development.
Data Retention and Archival:
Implement soft deletes for subscribers (add deletedAt timestamp field)
Archive campaigns older than 90 days to separate archived_campaigns table
Implement retention policies compliant with GDPR (right to be forgotten)
Use partitioning for SentMessage table when exceeding 10M records
Schedule periodic cleanup jobs for bounced/failed messages older than 1 year
Run Database Migration:
Apply the schema changes to your database. Prisma creates SQL migration files.
Bash
# Create the migration files based on schema changes
npx prisma migrate dev --name init_campaign_schema
# This command will also apply the migration to your database.# Ensure your DATABASE_URL in .env is correct and the database server is running.
Generate Prisma Client:
Whenever you change your schema, regenerate the Prisma Client.
Bash
npx prisma generate
This updates the @prisma/client library with typesafe methods based on your schema.
Database Client Configuration (src/config/db.js):
JavaScript
// src/config/db.jsconst { PrismaClient } = require('@prisma/client');
const logger = require('../utils/logger'); // Assuming logger is set upconst prisma = newPrismaClient({
log: [
{ emit: 'event', level: 'query' },
{ emit: 'stdout', level: 'info' },
{ emit: 'stdout', level: 'warn' },
{ emit: 'stdout', level: 'error' },
],
});
prisma.$on('query', (e) => {
// Log query performance, useful for debugging slow queriesif (e.duration > 100) { // Log queries taking longer than 100ms
logger.warn(`Slow Query (${e.duration}ms): ${e.query}`, { params: e.params });
}
// logger.debug(`Query: ${e.query}`, { duration: e.duration, params: e.params }); // More verbose logging
});
// Optional: Test connection on startup (can add complexity)asyncfunctiontestDbConnection() {
try {
await prisma.$connect();
logger.info('Database connection successful.');
} catch (error) {
logger.error('Database connection failed:', error);
process.exit(1); // Exit if DB connection fails on startup
} finally {
// $connect is idempotent, no need to disconnect here if used normally// await prisma.$disconnect();
}
}
// Call the test function if needed, e.g., in server.js before starting the server// testDbConnection();module.exports = prisma;
This sets up a singleton Prisma client instance and includes basic query logging.
Connection Pool Configuration Best Practices:
Pool Size: Default is num_physical_cpus * 2 + 1. Adjust via connection_limit in DATABASE_URL
Production Example:postgresql://user:pass@host:5432/db?connection_limit=10&pool_timeout=30
Connection Limits: Set based on database server capacity and concurrent worker processes
Timeout Settings: Configure connect_timeout (default 5s) and pool_timeout (default 10s)
Health Checks: Implement periodic connection validation with prisma.$queryRaw
Monitoring: Track active connections, query duration, and connection pool exhaustion
name:Deployon:push:branches: [main]
jobs:deploy:runs-on:ubuntu-lateststeps:-uses:actions/checkout@v2-name:Installdependenciesrun:npmci-name:Runtestsrun:npmtest-name:Deploytoproductionrun:|
# Your deployment commands here
Frequently Asked Questions About Plivo SMS Marketing Campaigns
How do I set up Plivo for SMS campaigns in Node.js?
Set up Plivo SMS campaigns by: (1) creating a Plivo account and obtaining API credentials (Auth ID and Auth Token), (2) purchasing a Plivo phone number ($0.80–$2.00/month), (3) installing Plivo SDK v4 with npm install plivo@4, (4) initializing the client with new plivo.Client(authId, authToken), and (5) implementing subscriber management with PostgreSQL/Prisma and job queuing with BullMQ. This guide provides complete code examples for production-ready implementation.
What is TCPA compliance and do I need it for SMS marketing?
TCPA (Telephone Consumer Protection Act) compliance is required when sending marketing SMS to US numbers. Requirements include: (1) obtaining prior express written consent from recipients, (2) providing clear opt-out instructions in every message (e.g., "Reply STOP to unsubscribe"), (3) maintaining consent records with timestamps, and (4) honoring opt-outs immediately. Non-compliance carries penalties up to $1,500 per violation. Similar regulations exist globally: GDPR in EU and CASL in Canada. Consult legal counsel for compliance guidance.
Compliant vs Non-Compliant Message Examples:
✅ Compliant:
Code
ACME Store: Flash sale! 50% off all items today. Shop now: acme.co/sale
Reply STOP to unsubscribe, HELP for info. Msg&data rates may apply.
❌ Non-Compliant:
Code
ACME Store: Buy now! Limited time offer!
Issues: Missing opt-out instructions, no consent reference, vague sender identification.
How much does Plivo SMS cost?
Plivo phone numbers cost $0.80–$2.00/month depending on country. SMS rates vary by destination: US domestic SMS costs approximately $0.0035 per segment (160 characters), while international rates range $0.005–$0.20 per segment. Trial accounts include 20 free messages but have restrictions (verified numbers only, "[Plivo Trial]" prefix). Check Plivo's pricing page for current rates and purchase credits to remove trial limitations.
What's the difference between Plivo SDK v4 and v5?
Plivo SDK v5+ introduces breaking changes in client initialization and API methods compared to v4. This guide uses SDK v4 (plivo@4.x.x) syntax. If you install v5+, client initialization changes from new plivo.Client() to different patterns, and method signatures differ. Verify your installed version with npm list plivo. Consult Plivo's migration guide when upgrading from v4 to v5. Pin versions in package.json for production stability.
How do I handle SMS opt-outs with Plivo webhooks?
Handle opt-outs by: (1) configuring a webhook endpoint in your Plivo number settings, (2) implementing Express.js POST route to receive inbound messages, (3) validating webhook signatures using Plivo's signature verification, (4) checking message content for opt-out keywords (STOP, UNSUBSCRIBE, CANCEL, END, QUIT), (5) updating subscriber status to 'unsubscribed' in database, and (6) sending confirmation reply. This guide includes complete webhook implementation with signature validation and automatic opt-out processing.
Can I use Plivo trial account for production?
No, trial accounts have restrictions unsuitable for production: (1) SMS only sends to numbers verified in Plivo Console > Phone Numbers > Sandbox Numbers, (2) all messages include "[Plivo Trial]" prefix, and (3) limited to 20 free messages. Purchase Plivo credits to remove restrictions and enable production use. Trial accounts work well for development and testing before deploying to production.
How do I validate phone numbers for Plivo SMS?
Validate phone numbers using E.164 format: + followed by country code and subscriber number (1-15 digits total). Basic regex validation: /^\+[1-9]\d{1,14}$/ checks format but doesn't validate country code validity or number length per country. For production, use libphonenumber-js library for comprehensive validation including country-specific rules. This guide includes Express.js validation middleware with express-validator for API endpoints.
What is BullMQ and why use it for SMS campaigns?
BullMQ is a Redis-based job queue for Node.js that handles asynchronous SMS sending. Use BullMQ to: (1) process bulk messages without blocking API responses, (2) implement retry logic with exponential backoff for failed deliveries, (3) rate limit API calls to comply with Plivo's throttling, (4) scale horizontally by adding worker processes, and (5) track job status and delivery attempts. This guide uses BullMQ v4 (bullmq@4) with complete worker implementation and error handling.
BullMQ Alternatives Comparison:
Library
Pros
Cons
Best For
BullMQ
Modern API, TypeScript support, Redis Cluster support
Requires Redis 6+
Production apps, high-scale campaigns
Agenda
MongoDB-based, no Redis needed
Slower than Redis solutions
Apps already using MongoDB
Bee-Queue
Simple, fast, minimal features
No delayed jobs, basic retry
Simple use cases, prototypes
How do Plivo webhooks work for delivery status?
Plivo webhooks send HTTP POST requests to your configured endpoint when message status changes. Configure webhooks in Plivo Console > Phone Numbers > select number > Message URL. Your endpoint receives delivery status (queued, sent, delivered, undelivered, failed) and metadata (MessageUUID, timestamp, error codes). Always validate webhook signatures using Plivo's signature verification to prevent unauthorized requests. This guide includes complete webhook handling with Express.js routes and signature validation.
What database should I use for SMS campaigns?
Use PostgreSQL for SMS campaigns due to: (1) robust ACID compliance for transactional data, (2) excellent support for JSON data types for campaign metadata, (3) powerful indexing for phone number lookups, (4) reliable connection pooling for high concurrency, and (5) strong ecosystem with ORMs like Prisma. This guide uses PostgreSQL with Prisma ORM for type-safe database queries, complete schema definitions for subscribers and campaigns, and proper indexing for performance.
How do I deploy Plivo SMS campaigns to production?
Deploy to production by: (1) using environment variables for all credentials (never commit .env files), (2) implementing process managers like PM2 for automatic restarts, (3) configuring SSL/TLS certificates for webhook endpoints, (4) setting up monitoring with Winston logging and health check endpoints, (5) implementing rate limiting with express-rate-limit, (6) using connection pooling for PostgreSQL and Redis, and (7) setting up automated database backups. Deploy to platforms like AWS, DigitalOcean, or Heroku with managed PostgreSQL and Redis services.
Can I schedule SMS campaigns for specific times?
Yes, schedule campaigns using BullMQ's delayed job feature. Add jobs with delay option: await smsQueue.add('send-sms', data, { delay: millisecondsUntilSend }). Calculate delay based on target timestamp minus current time. For recurring campaigns, implement CRON-based scheduling or use BullMQ's repeat option. Store campaign schedules in PostgreSQL campaigns table with sendAt timestamps and process with scheduled workers. This approach handles timezone conversions and respects user preferences.
Timezone Handling Best Practices:
Store all timestamps in UTC in database (scheduledAt field)
Accept user input with timezone identifier (e.g., "America/New_York")
Use date-fns-tz or luxon for timezone conversions
Calculate UTC offset at job creation time: targetTime - Date.now()
Store subscriber timezone preferences in database for personalized scheduling
Handle DST transitions by using timezone identifiers, not offsets
You've built a production-ready SMS marketing campaign system using Plivo, Node.js, Express, PostgreSQL, and BullMQ. This implementation includes:
Subscriber Management: Complete CRUD operations with E.164 phone number validation
Campaign Creation: Database-backed campaign management with status tracking
Asynchronous Processing: BullMQ job queue with retry logic and error handling
Webhook Integration: Delivery status tracking and automated opt-out handling
Security Features: API key authentication, rate limiting, and webhook signature verification
TCPA Compliance: Opt-out management and consent tracking capabilities
Production-Ready Code: Comprehensive error handling, logging with Winston, and deployment guidelines
Note: This guide demonstrates the foundation for subscriber management. Complete implementation requires additional sections for campaign creation, BullMQ worker processes, webhook handlers, authentication middleware, validation middleware, error handling, and logging utilities. Use these patterns as building blocks to complete your SMS campaign system.
Next Steps:
Implement campaign management API endpoints and services
Set up BullMQ workers for asynchronous SMS sending
Create webhook handlers for delivery status and inbound messages
Add authentication and validation middleware
Implement Winston logger utility
Build campaign analytics and reporting dashboards
Add scheduled campaign functionality using BullMQ's delayed jobs
Integrate additional channels (email, push notifications) for omnichannel campaigns
Implement A/B testing for message variations
Add message templates and personalization variables
Set up monitoring and alerting with tools like Prometheus or Datadog
Implement comprehensive testing with Jest and Supertest
Deploy to production using Docker containers and orchestration platforms
This foundation provides the essential building blocks for a scalable SMS marketing platform. Customize and extend these components to meet your specific business requirements while maintaining code quality and compliance standards.
Frequently asked questions
Utilize the Plivo Node.js SDK along with a job queue system like BullMQ and Redis. This allows asynchronous sending of bulk SMS messages directly from your Node.js backend, ensuring reliable and scalable delivery, especially for personalized campaigns. The provided example uses a job queue to handle sending in the background, improving responsiveness.
Express.js acts as the API layer, handling HTTP requests and responses. It routes incoming requests to appropriate controllers, which then interact with services for business logic and Plivo for SMS functionality. Express.js provides the framework for structuring your backend application and routes communication through clear entrypoints like controllers.
PostgreSQL is a relational database used to store subscriber data, campaign details, and message logs, offering structured data management. Prisma simplifies database interactions by providing a modern ORM (Object-Relational Mapper), making it easier to work with the database in Node.js and providing typesafe methods for database access.
ngrok is crucial during development to expose your local server to the internet so Plivo webhooks can reach it. Plivo needs a public URL for incoming messages and status updates, hence ngrok acts as the temporary tunnel during development. Remember to replace the ngrok URL with your production URL when deploying.
Yes, for supported countries outside the US and Canada, you can use an Alphanumeric Sender ID instead of a phone number. Configure this in your Plivo account under Messaging > Sender IDs. Alphanumeric Sender IDs enable message personalization and branding but are limited by country support, so US/Canada usage generally requires a phone number instead.
The subscriberService.setOptOutStatus function handles opt-outs by updating the isActive flag in the subscriber record. The system can process incoming "STOP" messages via webhooks and automatically opt-out users. Ensure E.164 format is used consistently for matching.
BullMQ is a powerful job queue system that handles the asynchronous sending of SMS messages using Redis. By offloading message sending to a queue, the API remains responsive and avoids blocking on potentially long-running SMS operations, especially during bulk campaigns.
The plivoService.validateWebhookSignature function validates incoming webhooks using Plivo's signature. This is crucial to prevent unauthorized requests. The function requires access to the raw (unparsed) request body. The code example shows how to configure this, but it relies on the req.rawBody being populated correctly by middleware before any JSON body parsing.
Start by creating a directory, initializing npm, and installing required packages like Express, Plivo SDK, Prisma, BullMQ, Redis, and dotenv. Then, initialize Prisma, configure environment variables in the .env file, and set up nodemon for development. The article provides detailed commands for this initial setup.
You need Node.js, npm (or yarn), access to a PostgreSQL database, a Redis instance, a Plivo account, and a basic understanding of Node.js, Express, REST APIs, and databases. ngrok is essential for local development and testing.
Create a Plivo Application in the Plivo console (Messaging > Applications > XML), set the Message URL to your webhook endpoint (use ngrok during development), and link your purchased Plivo number to this application. This enables Plivo to forward incoming messages to your backend.
A production webhook URL is essential because ngrok URLs are temporary. For your deployed application, you need a stable, publicly accessible URL (provided by your hosting platform) so Plivo can consistently reach your webhooks for incoming messages and status updates.
Plivo sends message status updates (e.g., sent, failed, delivered) to the Delivery Report URL you configured in your Plivo Application settings. Your application receives these updates via webhooks, and you can then update the status of SentMessage records for logging and monitoring.
The SentMessage model logs individual message attempts, associating them with a specific Campaign and Subscriber. It stores Plivo's messageUuid, delivery status, and timestamps, enabling detailed tracking and reporting. The article also recommends including the full callback payload for status updates from Plivo via statusCallback.
Build a robust SMS marketing campaign application using Node.js, the Express framework, and the Plivo communication platform API. Learn how to set up the project, send bulk SMS messages, handle replies and opt-outs, manage subscribers, and deploy your application securely and reliably.
Build a system capable of managing subscriber lists, creating SMS campaigns, sending messages asynchronously via a job queue, handling incoming messages (like replies or STOP requests), and logging message statuses. This approach solves the challenge of sending personalized or bulk SMS messages reliably and scalably, directly from your application backend.
Common Use Cases:
Technologies Used:
npm list plivoand consult Plivo's migration guide if using v5+.envfileSystem Architecture:
The architecture follows a modular request-response pattern with asynchronous job processing:
Prerequisites:
ngrokinstalled for local development webhook testingngrok's free tier URLs are temporary and not suitable for production.By the end of this guide, you'll have a functional backend application capable of managing and executing SMS campaigns, ready for further enhancement and deployment.
1. Project Setup and Configuration
Initialize the Node.js project and install necessary dependencies.
Create Project Directory:
mkdir sms-campaign-app cd sms-campaign-appInitialize Node.js Project:
Install Dependencies:
Version Notes:
plivo@4: Ensures SDK v4.x compatibility with code examples (v5+ has breaking changes)bullmq@4: Stable version compatible with the worker patterns shown (v5+ changes queue initialization)package.jsonfor stabilityDependency Purposes:
express: Web framework for API endpointsplivo@4: Plivo SDK for sending SMS@prisma/client: Auto-generated database clientdotenv: Load environment variables from.envfilebullmq@4: Job queue for asynchronous SMS processingioredis: Redis client for BullMQexpress-validator: Request validation middlewarewinston: Production-grade logging libraryexpress-rate-limit: Rate limiting middlewareInstall Development Dependencies:
prisma: For Prisma CLI commands (migrations, generation)nodemon: Automatically restarts the server during developmentjest,supertest: For testingInitialize Prisma:
This creates a
prismadirectory with aschema.prismafile and a.envfile.Configure Environment Variables (
.env): Open the.envfile created by Prisma and add the following variables. Remove any unnecessary quotes.Security Best Practices for
.envFiles:.envfiles to version control (add to.gitignore).envfiles for development, staging, and productionchmod 600 .envon Unix systemsDATABASE_URL: Connection string for your PostgreSQL database. Replace placeholders with your actual credentials. Use quotes if your password or user contains special characters.PLIVO_AUTH_ID,PLIVO_AUTH_TOKEN: Your Plivo API credentialsPLIVO_SENDER_ID: The Plivo phone number (in E.164 format, e.g.,+14155551212) or Alphanumeric Sender ID you'll use to send messages. Numbers are required for US/Canada.REDIS_URL: Connection string for your Redis instancePORT: Port your Express application will run onAPI_KEY: A simple secret key for basic API authentication (use more robust methods like JWT for production)BASE_URL: The public-facing base URL of your application. Critical for webhook configuration. Use yourngrokURL during development, and your production URL otherwise.Add
nodemonscript topackage.json: Update thescriptssection in yourpackage.json:{ "scripts": { "start": "node src/server.js", "dev": "nodemon src/server.js", "test": "jest" } }Create Project Structure: Organize your project for maintainability:
Create these directories.
Basic Server Setup (
src/server.js):// src/server.js require('dotenv').config(); const app = require('./app'); const logger = require('./utils/logger'); // We'll create this later const PORT = process.env.PORT || 3000; app.listen(PORT, () => { logger.info(`Server running on port ${PORT}`); logger.info(`API base URL: ${process.env.BASE_URL}`); // Note: BullMQ workers should typically run in a separate process. });Basic App Setup (
src/app.js):// src/app.js const express = require('express'); const rateLimit = require('express-rate-limit'); // Routes and middleware will be imported and used as defined in later sections const app = express(); // Middleware (Order matters!) // Apply rate limiting early const limiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // Limit each IP to 100 requests per windowMs standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // Disable the `X-RateLimit-*` headers message: 'Too many requests from this IP, please try again after 15 minutes', }); app.use(limiter); // We will mount webhook routes *before* express.json() if raw body is needed (See Section 6.4) // Parse JSON bodies for most routes app.use(express.json()); // Parse URL-encoded bodies app.use(express.urlencoded({ extended: true })); // Request logging, Authentication, API routes, and Error Handling will be added later. // Health check endpoint app.get('/health', (req, res) => { res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() }); }); // Routes and error handler will be mounted here in subsequent sections. module.exports = app;This initial setup provides a solid foundation with essential dependencies, environment configuration, and a basic project structure.
2. Integrating with Plivo
Configure Plivo and set up the necessary components to interact with its API.
Sign Up/Log In to Plivo:
Get API Credentials:
.envfile forPLIVO_AUTH_IDandPLIVO_AUTH_TOKEN. Keep these secret! Do not commit them to version control.Get a Plivo Phone Number:
+14155551212) and add it to your.envfile asPLIVO_SENDER_IDAlphanumeric Sender ID Notes:
Configure Plivo Application for Webhooks: Plivo uses webhooks to notify your application about incoming messages and delivery status updates. You need to create a Plivo Application and link your Plivo number to it.
ngrok.ngrok:ngrok http 3000(or yourPORT)ngrok(e.g.,https://<unique_id>.ngrok.io)https://<unique_id>.ngrok.io/webhooks/plivo/incoming(we'll create this endpoint later).envBASE_URLto this ngrok URL during developmentPOSThttps://<unique_id>.ngrok.io/webhooks/plivo/status. Set method toPOST.Common Webhook Configuration Issues:
ngrokis running andBASE_URLmatchesngrokURLCreate Plivo Service (
src/services/plivoService.js): This service encapsulates interaction with the Plivo SDK.// src/services/plivoService.js const plivo = require('plivo'); const logger = require('../utils/logger'); const client = new plivo.Client(process.env.PLIVO_AUTH_ID, process.env.PLIVO_AUTH_TOKEN); const sendSms = async (to, text, callbackUrl = null) => { const senderId = process.env.PLIVO_SENDER_ID; if (!senderId) { logger.error('PLIVO_SENDER_ID is not configured in .env'); throw new Error('Sender ID not configured'); } // Basic validation if (!to || !text) { throw new Error('Recipient number (to) and message text cannot be empty.'); } // Add more robust E.164 validation if needed logger.info(`Attempting to send SMS from ${senderId} to ${to}`); try { const params = { src: senderId, dst: to, text: text, }; // Include status callback URL if provided if (callbackUrl) { params.url = callbackUrl; params.method = 'POST'; // Method Plivo uses to call your callback URL } const response = await client.messages.create(params); logger.info(`SMS submitted to Plivo for ${to}. Message UUID: ${response.messageUuid}`); // Note: This response indicates submission to Plivo, not final delivery. // Delivery status comes via webhook if configured. return { success: true, messageUuid: response.messageUuid }; } catch (error) { logger.error(`Error sending SMS via Plivo to ${to}: ${error.message}`, { error }); // Plivo errors often have more details in error.response or error.message throw error; // Re-throw for handling upstream (e.g., in the job queue worker) } }; // Function to generate Plivo XML for replying to incoming messages const createReplyXml = (responseText) => { const response = new plivo.Response(); response.addMessage(responseText); // Simple text reply return response.toXML(); }; // Function to validate incoming Plivo webhooks const validateWebhookSignature = (req) => { const signature = req.headers['x-plivo-signature-v3']; const nonce = req.headers['x-plivo-signature-v3-nonce']; // Construct the full URL Plivo used. // Note: This might be unreliable if behind certain proxies that rewrite URL components. // Ensure BASE_URL is correctly set to the public-facing URL Plivo hits. const url = process.env.BASE_URL + req.originalUrl; const method = req.method; const authToken = process.env.PLIVO_AUTH_TOKEN; if (!signature || !nonce) { logger.warn('Missing Plivo signature headers.'); return false; } try { // CRITICAL: Plivo's validation requires the raw, unparsed request body for POST/PUT. // If using express.json() globally *before* this check, `req.body` will be parsed JSON, // which WILL cause validation failure. // You MUST capture the raw body *before* JSON parsing for webhook routes. // See `plivoWebhookAuth.js` and `app.js` setup for handling this. // We assume here that the raw body (Buffer) is available via `req.rawBody` (or similar). const postParams = req.method === 'POST' ? (req.rawBody || req.body) : {}; // Prioritize rawBody if available // Note: Plivo SDK v4's validateV3Signature expects body params as an object for POST. // If using rawBody (Buffer), you might need to parse it *conditionally* ONLY for validation // if the SDK helper requires an object map, or check if the SDK offers a way to pass the raw buffer directly. // For simplicity, we pass `postParams` but emphasize the raw body requirement. const isValid = plivo.validateV3Signature(method, url, nonce, authToken, signature, postParams); // const isValid = plivo.Utils.validatesignature(url, nonce, signature, authToken); // Alternative if simpler validation is sufficient/available if (!isValid) { logger.warn('Invalid Plivo webhook signature received.', { url, method, nonce, signature, headers: req.headers, bodyUsed: postParams === req.rawBody ? 'rawBody' : 'req.body' }); } return isValid; } catch (error) { logger.error('Error validating Plivo webhook signature:', { error, url, method }); return false; } }; module.exports = { sendSms, createReplyXml, validateWebhookSignature, };.envsendSmshandles sending a single message, including optional status callback configurationcreateReplyXmlgenerates the XML Plivo expects for automatic replies via webhooksvalidateWebhookSignaturesecures your webhook endpoints. Implementation Note: This function requires the raw request body for validation to work correctly with POST requests. Ensure your middleware setup provides this (see Section 6). The URL construction usingBASE_URL + req.originalUrlmight also need adjustment if your application runs behind complex proxies.Common Plivo API Errors:
PLIVO_SENDER_IDis in E.164 format and number is activePLIVO_AUTH_IDandPLIVO_AUTH_TOKENare correct3. Database Schema and Data Layer
Use Prisma to define your database schema and interact with PostgreSQL.
Define Schema (
prisma/schema.prisma): Update your schema file with models for Subscribers, Campaigns, and potentially Sent Messages for tracking.isActive)messageUuid, and tracking delivery status. AddedonDelete: Cascadefor referential integrity.Status Field Design Considerations:
For production applications, consider using enums for better type safety and validation. This guide uses strings for flexibility during development.
Data Retention and Archival:
deletedAttimestamp field)archived_campaignstableSentMessagetable when exceeding 10M recordsRun Database Migration: Apply the schema changes to your database. Prisma creates SQL migration files.
# Create the migration files based on schema changes npx prisma migrate dev --name init_campaign_schema # This command will also apply the migration to your database. # Ensure your DATABASE_URL in .env is correct and the database server is running.Generate Prisma Client: Whenever you change your schema, regenerate the Prisma Client.
This updates the
@prisma/clientlibrary with typesafe methods based on your schema.Database Client Configuration (
src/config/db.js):// src/config/db.js const { PrismaClient } = require('@prisma/client'); const logger = require('../utils/logger'); // Assuming logger is set up const prisma = new PrismaClient({ log: [ { emit: 'event', level: 'query' }, { emit: 'stdout', level: 'info' }, { emit: 'stdout', level: 'warn' }, { emit: 'stdout', level: 'error' }, ], }); prisma.$on('query', (e) => { // Log query performance, useful for debugging slow queries if (e.duration > 100) { // Log queries taking longer than 100ms logger.warn(`Slow Query (${e.duration}ms): ${e.query}`, { params: e.params }); } // logger.debug(`Query: ${e.query}`, { duration: e.duration, params: e.params }); // More verbose logging }); // Optional: Test connection on startup (can add complexity) async function testDbConnection() { try { await prisma.$connect(); logger.info('Database connection successful.'); } catch (error) { logger.error('Database connection failed:', error); process.exit(1); // Exit if DB connection fails on startup } finally { // $connect is idempotent, no need to disconnect here if used normally // await prisma.$disconnect(); } } // Call the test function if needed, e.g., in server.js before starting the server // testDbConnection(); module.exports = prisma;This sets up a singleton Prisma client instance and includes basic query logging.
Connection Pool Configuration Best Practices:
num_physical_cpus * 2 + 1. Adjust viaconnection_limitinDATABASE_URLpostgresql://user:pass@host:5432/db?connection_limit=10&pool_timeout=30connect_timeout(default 5s) andpool_timeout(default 10s)prisma.$queryRaw4. Implementing Core Functionality (API & Services)
Build the API endpoints and service logic for managing subscribers and campaigns, and for sending messages.
Structure:
src/routes/): Define API endpoints and link them to controllers. Useexpress.Routersrc/controllers/): Handle HTTP requests, perform validation, call services, and send responsessrc/services/): Contain the core business logic, interacting with the database (Prisma) and external services (Plivo)4.1. Subscriber Management
Subscriber Routes (
src/routes/subscriberRoutes.js):// src/routes/subscriberRoutes.js const express = require('express'); const subscriberController = require('../controllers/subscriberController'); const { validateSubscriber, validateIdParam } = require('../middleware/validators'); // We'll create this const authenticateApiKey = require('../middleware/authMiddleware'); // We'll create this const router = express.Router(); // Protect all subscriber routes with API key auth router.use(authenticateApiKey); router.post('/', validateSubscriber, subscriberController.createSubscriber); router.get('/', subscriberController.getAllSubscribers); router.get('/:id', validateIdParam, subscriberController.getSubscriberById); // Use PUT for full updates, PATCH for partial updates (like opt-out) router.patch('/:id', validateIdParam, validateSubscriber, subscriberController.updateSubscriber); // Allow partial updates router.delete('/:id', validateIdParam, subscriberController.deleteSubscriber); module.exports = router;Subscriber Service (
src/services/subscriberService.js):// src/services/subscriberService.js const prisma = require('../config/db'); const logger = require('../utils/logger'); const create = async (data) => { try { // Basic E.164 format check: + followed by 1-15 digits, starting with non-zero // Limitations: Doesn't validate country code validity or number length per country // For production: Use libphonenumber-js for comprehensive validation if (!/^\+[1-9]\d{1,14}$/.test(data.phoneNumber)) { throw new Error('Invalid phone number format. Use E.164 (e.g., +14155551212)'); } const subscriber = await prisma.subscriber.create({ data }); logger.info(`Subscriber created: ${subscriber.id} - ${subscriber.phoneNumber}`); return subscriber; } catch (error) { if (error.code === 'P2002' && error.meta?.target?.includes('phoneNumber')) { logger.warn(`Attempted to create duplicate subscriber: ${data.phoneNumber}`); throw new Error(`Subscriber with phone number ${data.phoneNumber} already exists.`); } logger.error('Error creating subscriber:', error); throw error; } }; const findAll = async (activeOnly = true) => { const whereClause = activeOnly ? { isActive: true } : {}; return prisma.subscriber.findMany({ where: whereClause, orderBy: { createdAt: 'desc' } }); }; const findById = async (id) => { const subscriber = await prisma.subscriber.findUnique({ where: { id: parseInt(id, 10) } }); if (!subscriber) { throw new Error(`Subscriber with ID ${id} not found.`); } return subscriber; }; const update = async (id, data) => { try { if (data.phoneNumber && !/^\+[1-9]\d{1,14}$/.test(data.phoneNumber)) { throw new Error('Invalid phone number format. Use E.164.'); } // Ensure boolean values are handled correctly if passed as strings from form data if (data.isActive !== undefined && typeof data.isActive !== 'boolean') { data.isActive = !(data.isActive === 'false' || data.isActive === '0' || !data.isActive); } const subscriber = await prisma.subscriber.update({ where: { id: parseInt(id, 10) }, data, // Prisma handles partial updates automatically with `update` }); logger.info(`Subscriber updated: ${subscriber.id}`); return subscriber; } catch (error) { if (error.code === 'P2025') { // Record to update not found throw new Error(`Subscriber with ID ${id} not found for update.`); } if (error.code === 'P2002' && error.meta?.target?.includes('phoneNumber')) { logger.warn(`Attempted to update subscriber to duplicate number: ${data.phoneNumber}`); throw new Error(`Another subscriber with phone number ${data.phoneNumber} already exists.`); } logger.error(`Error updating subscriber ${id}:`, error); throw error; } }; // Handle opt-out specifically via phone number (used by webhook) const setOptOutStatus = async (phoneNumber, isOptedOut) => { try { // Ensure E.164 format for lookup if (!/^\+[1-9]\d{1,14}$/.test(phoneNumber)) { logger.warn(`Received opt-out/in request for invalid number format: ${phoneNumber}`); return null; // Or throw error } const subscriber = await prisma.subscriber.update({ where: { phoneNumber: phoneNumber }, data: { isActive: !isOptedOut }, }); logger.info(`Subscriber ${phoneNumber} opt-out status set to ${isOptedOut}`); return subscriber; } catch (error) { if (error.code === 'P2025') { // Record to update not found logger.warn(`Received opt-out/in request for unknown number: ${phoneNumber}`); // Decide if you want to create a subscriber record here or just log // Example: Create if opting in, ignore if opting out from unknown number // if (!isOptedOut) { ... create subscriber ... } return null; } logger.error(`Error setting opt-out status for ${phoneNumber}:`, error); throw error; // Rethrow to indicate failure } }; const remove = async (id) => { try { // Consider implications: deleting a subscriber might orphan SentMessage records // or violate constraints if not handled carefully (e.g., using onDelete: Cascade in schema) await prisma.subscriber.delete({ where: { id: parseInt(id, 10) } }); logger.info(`Subscriber deleted: ${id}`); return true; } catch (error) { if (error.code === 'P2025') { // Record to delete not found throw new Error(`Subscriber with ID ${id} not found for deletion.`); } // Handle foreign key constraint errors if cascade delete isn't set up (P2003) if (error.code === 'P2003') { logger.error(`Cannot delete subscriber ${id} due to related records (e.g., sent messages).`); throw new Error(`Cannot delete subscriber ${id} as they have related message history.`); } logger.error(`Error deleting subscriber ${id}:`, error); throw error; } }; module.exports = { create, findAll, findById, update, remove, setOptOutStatus };Subscriber Controller (
src/controllers/subscriberController.js):// src/controllers/subscriberController.js const subscriberService = require('../services/subscriberService'); const { validationResult } = require('express-validator'); exports.createSubscriber = async (req, res, next) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ errors: errors.array() }); } try { // Ensure only allowed fields are passed to the service const allowedData = { phoneNumber: req.body.phoneNumber, firstName: req.body.firstName, lastName: req.body.lastName, isActive: req.body.isActive // Let service handle default if undefined }; const subscriber = await subscriberService.create(allowedData); res.status(201).json(subscriber); } catch (error) { // Handle specific "already exists" error if (error.message.includes('already exists')) { return res.status(409).json({ message: error.message }); // Conflict } next(error); // Pass other errors to global handler } }; exports.getAllSubscribers = async (req, res, next) => { try { // Allow filtering active subscribers via query param ?activeOnly=false const activeOnly = req.query.activeOnly !== 'false'; const subscribers = await subscriberService.findAll(activeOnly); res.status(200).json(subscribers); } catch (error) { next(error); } }; exports.getSubscriberById = async (req, res, next) => { const errors = validationResult(req); // Check param validation if (!errors.isEmpty()) { return res.status(400).json({ errors: errors.array() }); } try { const subscriber = await subscriberService.findById(req.params.id); res.status(200).json(subscriber); } catch (error) { // Handle specific "not found" error if (error.message.includes('not found')) { return res.status(404).json({ message: error.message }); } next(error); } }; exports.updateSubscriber = async (req, res, next) => { const errors = validationResult(req); // Check param and body validation if (!errors.isEmpty()) { return res.status(400).json({ errors: errors.array() }); } try { // Ensure only allowed fields are passed to the service const allowedData = {}; if (req.body.phoneNumber !== undefined) allowedData.phoneNumber = req.body.phoneNumber; if (req.body.firstName !== undefined) allowedData.firstName = req.body.firstName; if (req.body.lastName !== undefined) allowedData.lastName = req.body.lastName; if (req.body.isActive !== undefined) allowedData.isActive = req.body.isActive; if (Object.keys(allowedData).length === 0) { return res.status(400).json({ message: 'No valid fields provided for update.' }); } const subscriber = await subscriberService.update(req.params.id, allowedData); res.status(200).json(subscriber); } catch (error) { // Handle specific "not found" and "already exists" errors if (error.message.includes('not found')) { return res.status(404).json({ message: error.message }); } if (error.message.includes('already exists')) { return res.status(409).json({ message: error.message }); // Conflict } next(error); } }; exports.deleteSubscriber = async (req, res, next) => { const errors = validationResult(req); // Check param validation if (!errors.isEmpty()) { return res.status(400).json({ errors: errors.array() }); } try { await subscriberService.remove(req.params.id); res.status(204).send(); // No Content } catch (error) { // Handle specific "not found" and constraint violation errors if (error.message.includes('not found')) { return res.status(404).json({ message: error.message }); } if (error.message.includes('related message history')) { return res.status(409).json({ message: error.message }); // Conflict } next(error); } };Deploy this application to production:
Platform-Specific Deployment Examples:
AWS Deployment:
Heroku Deployment:
heroku config:setDigitalOcean Deployment:
/healthendpointDocker Containerization:
Docker Compose for Local Development:
version: '3.8' services: app: build: . ports: - "3000:3000" environment: - DATABASE_URL=postgresql://postgres:password@db:5432/sms_campaigns - REDIS_URL=redis://redis:6379 depends_on: - db - redis db: image: postgres:14-alpine environment: - POSTGRES_PASSWORD=password - POSTGRES_DB=sms_campaigns redis: image: redis:6-alpineCI/CD Pipeline Setup (GitHub Actions Example):
name: Deploy on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Install dependencies run: npm ci - name: Run tests run: npm test - name: Deploy to production run: | # Your deployment commands hereFrequently Asked Questions About Plivo SMS Marketing Campaigns
How do I set up Plivo for SMS campaigns in Node.js?
Set up Plivo SMS campaigns by: (1) creating a Plivo account and obtaining API credentials (Auth ID and Auth Token), (2) purchasing a Plivo phone number ($0.80–$2.00/month), (3) installing Plivo SDK v4 with
npm install plivo@4, (4) initializing the client withnew plivo.Client(authId, authToken), and (5) implementing subscriber management with PostgreSQL/Prisma and job queuing with BullMQ. This guide provides complete code examples for production-ready implementation.What is TCPA compliance and do I need it for SMS marketing?
TCPA (Telephone Consumer Protection Act) compliance is required when sending marketing SMS to US numbers. Requirements include: (1) obtaining prior express written consent from recipients, (2) providing clear opt-out instructions in every message (e.g., "Reply STOP to unsubscribe"), (3) maintaining consent records with timestamps, and (4) honoring opt-outs immediately. Non-compliance carries penalties up to $1,500 per violation. Similar regulations exist globally: GDPR in EU and CASL in Canada. Consult legal counsel for compliance guidance.
Compliant vs Non-Compliant Message Examples:
✅ Compliant:
❌ Non-Compliant:
Issues: Missing opt-out instructions, no consent reference, vague sender identification.
How much does Plivo SMS cost?
Plivo phone numbers cost $0.80–$2.00/month depending on country. SMS rates vary by destination: US domestic SMS costs approximately $0.0035 per segment (160 characters), while international rates range $0.005–$0.20 per segment. Trial accounts include 20 free messages but have restrictions (verified numbers only, "[Plivo Trial]" prefix). Check Plivo's pricing page for current rates and purchase credits to remove trial limitations.
What's the difference between Plivo SDK v4 and v5?
Plivo SDK v5+ introduces breaking changes in client initialization and API methods compared to v4. This guide uses SDK v4 (plivo@4.x.x) syntax. If you install v5+, client initialization changes from
new plivo.Client()to different patterns, and method signatures differ. Verify your installed version withnpm list plivo. Consult Plivo's migration guide when upgrading from v4 to v5. Pin versions inpackage.jsonfor production stability.How do I handle SMS opt-outs with Plivo webhooks?
Handle opt-outs by: (1) configuring a webhook endpoint in your Plivo number settings, (2) implementing Express.js POST route to receive inbound messages, (3) validating webhook signatures using Plivo's signature verification, (4) checking message content for opt-out keywords (STOP, UNSUBSCRIBE, CANCEL, END, QUIT), (5) updating subscriber status to 'unsubscribed' in database, and (6) sending confirmation reply. This guide includes complete webhook implementation with signature validation and automatic opt-out processing.
Can I use Plivo trial account for production?
No, trial accounts have restrictions unsuitable for production: (1) SMS only sends to numbers verified in Plivo Console > Phone Numbers > Sandbox Numbers, (2) all messages include "[Plivo Trial]" prefix, and (3) limited to 20 free messages. Purchase Plivo credits to remove restrictions and enable production use. Trial accounts work well for development and testing before deploying to production.
How do I validate phone numbers for Plivo SMS?
Validate phone numbers using E.164 format:
+followed by country code and subscriber number (1-15 digits total). Basic regex validation:/^\+[1-9]\d{1,14}$/checks format but doesn't validate country code validity or number length per country. For production, uselibphonenumber-jslibrary for comprehensive validation including country-specific rules. This guide includes Express.js validation middleware withexpress-validatorfor API endpoints.What is BullMQ and why use it for SMS campaigns?
BullMQ is a Redis-based job queue for Node.js that handles asynchronous SMS sending. Use BullMQ to: (1) process bulk messages without blocking API responses, (2) implement retry logic with exponential backoff for failed deliveries, (3) rate limit API calls to comply with Plivo's throttling, (4) scale horizontally by adding worker processes, and (5) track job status and delivery attempts. This guide uses BullMQ v4 (bullmq@4) with complete worker implementation and error handling.
BullMQ Alternatives Comparison:
How do Plivo webhooks work for delivery status?
Plivo webhooks send HTTP POST requests to your configured endpoint when message status changes. Configure webhooks in Plivo Console > Phone Numbers > select number > Message URL. Your endpoint receives delivery status (queued, sent, delivered, undelivered, failed) and metadata (MessageUUID, timestamp, error codes). Always validate webhook signatures using Plivo's signature verification to prevent unauthorized requests. This guide includes complete webhook handling with Express.js routes and signature validation.
What database should I use for SMS campaigns?
Use PostgreSQL for SMS campaigns due to: (1) robust ACID compliance for transactional data, (2) excellent support for JSON data types for campaign metadata, (3) powerful indexing for phone number lookups, (4) reliable connection pooling for high concurrency, and (5) strong ecosystem with ORMs like Prisma. This guide uses PostgreSQL with Prisma ORM for type-safe database queries, complete schema definitions for subscribers and campaigns, and proper indexing for performance.
How do I deploy Plivo SMS campaigns to production?
Deploy to production by: (1) using environment variables for all credentials (never commit .env files), (2) implementing process managers like PM2 for automatic restarts, (3) configuring SSL/TLS certificates for webhook endpoints, (4) setting up monitoring with Winston logging and health check endpoints, (5) implementing rate limiting with express-rate-limit, (6) using connection pooling for PostgreSQL and Redis, and (7) setting up automated database backups. Deploy to platforms like AWS, DigitalOcean, or Heroku with managed PostgreSQL and Redis services.
Can I schedule SMS campaigns for specific times?
Yes, schedule campaigns using BullMQ's delayed job feature. Add jobs with delay option:
await smsQueue.add('send-sms', data, { delay: millisecondsUntilSend }). Calculate delay based on target timestamp minus current time. For recurring campaigns, implement CRON-based scheduling or use BullMQ's repeat option. Store campaign schedules in PostgreSQL campaigns table with sendAt timestamps and process with scheduled workers. This approach handles timezone conversions and respects user preferences.Timezone Handling Best Practices:
scheduledAtfield)date-fns-tzorluxonfor timezone conversionstargetTime - Date.now()Example:
const { zonedTimeToUtc } = require('date-fns-tz'); const scheduledTime = zonedTimeToUtc('2025-10-10 09:00:00', 'America/New_York'); const delay = scheduledTime.getTime() - Date.now(); await smsQueue.add('send-sms', messageData, { delay });Conclusion
You've built a production-ready SMS marketing campaign system using Plivo, Node.js, Express, PostgreSQL, and BullMQ. This implementation includes:
Note: This guide demonstrates the foundation for subscriber management. Complete implementation requires additional sections for campaign creation, BullMQ worker processes, webhook handlers, authentication middleware, validation middleware, error handling, and logging utilities. Use these patterns as building blocks to complete your SMS campaign system.
Next Steps:
Related Resources:
This foundation provides the essential building blocks for a scalable SMS marketing platform. Customize and extend these components to meet your specific business requirements while maintaining code quality and compliance standards.