code examples
code examples
Build SMS Marketing Campaigns with Plivo, Node.js & Express: Complete Guide
Build production-ready SMS marketing campaigns using Plivo API, Node.js, Express, and BullMQ. Learn subscriber management, bulk messaging, TCPA compliance, webhooks, and deployment with complete code examples.
Build Production-Ready SMS Campaigns with Node.js, Express, and Plivo
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 plivoand 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
.envfile - 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
+-----------------+ +-----------------+ +----------------+ +----------------+
| User/Admin | ---> | Express API | ---> | BullMQ | ---> | Plivo SMS API |
| (API Client) | | (Node.js) | | (Redis Job Q) | | |
+-----------------+ +-------+---------+ +-------+--------+ +-------+--------+
| | | | | |
| Prisma ORM |<----->| PostgreSQL DB | | Plivo Webhooks
| | | (Subscribers, | | (Incoming SMS,
+-------+---------+ | Campaigns) | <--------------+ Status Updates)
+----------------+Prerequisites:
- Node.js 16+ and npm (or yarn) installed
- PostgreSQL 12+ database access
- Redis 6+ instance (local or cloud-based)
- A Plivo account with API credentials
- Basic understanding of Node.js, Express, REST APIs, and databases
ngrokinstalled 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.
-
Create Project Directory:
bashmkdir sms-campaign-app cd sms-campaign-app -
Initialize Node.js Project:
bashnpm init -y -
Install Dependencies:
bashnpm install express plivo@4 @prisma/client dotenv bullmq@4 ioredis express-validator winston express-rate-limitVersion 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)- Use exact versions in production
package.jsonfor stability
Dependency 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 middleware
-
Install Development Dependencies:
bashnpm install --save-dev prisma nodemon jest supertestprisma: For Prisma CLI commands (migrations, generation)nodemon: Automatically restarts the server during developmentjest,supertest: For testing
-
Initialize Prisma:
bashnpx prisma init --datasource-provider postgresqlThis 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:- Never commit
.envfiles to version control (add to.gitignore) - Use different
.envfiles 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 .envon Unix systems
dotenv# .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 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.
- Never commit
-
Add
nodemonscript topackage.json: Update thescriptssection in yourpackage.json:json{ "scripts": { "start": "node src/server.js", "dev": "nodemon src/server.js", "test": "jest" } } -
Create Project Structure: Organize your project for maintainability:
textsms-campaign-app/ ├── prisma/ │ ├── schema.prisma │ └── migrations/ ├── src/ │ ├── config/ # Configuration files (db, plivo, redis) │ ├── controllers/ # Request handlers │ ├── jobs/ # BullMQ job definitions │ ├── middleware/ # Express middleware (auth, validation, error handling) │ ├── models/ # (Optional) Business logic models if needed beyond Prisma │ ├── routes/ # API route definitions │ ├── services/ # Business logic (interacting with DB, Plivo, etc.) │ ├── utils/ # Utility functions (logging, etc.) │ ├── workers/ # BullMQ worker processes │ ├── app.js # Express app configuration │ └── server.js # Server entry point ├── tests/ # Unit and integration tests ├── .env ├── .gitignore └── package.jsonCreate these directories.
-
Basic Server Setup (
src/server.js):javascript// 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):javascript// 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:
- 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
.envfile forPLIVO_AUTH_IDandPLIVO_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.envfile asPLIVO_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 yourPORT) - 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_URLto this ngrok URL during development
- Start
- 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 toPOST. - 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
ngrokis running andBASE_URLmatchesngrokURL - 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.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, };- Initialize the Plivo client using credentials from
.env sendSmshandles 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:
- Error 15003 (Invalid From Number): Verify
PLIVO_SENDER_IDis 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_IDandPLIVO_AUTH_TOKENare correct - Error 20006 (Insufficient Balance): Add credits to your Plivo account
- Initialize the Plivo client using credentials from
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. AddedonDelete: Cascadefor 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
deletedAttimestamp field) - Archive campaigns older than 90 days to separate
archived_campaignstable - Implement retention policies compliant with GDPR (right to be forgotten)
- Use partitioning for
SentMessagetable when exceeding 10M records - Schedule periodic cleanup jobs for bounced/failed messages older than 1 year
- Subscriber: Stores contact information and opt-out status (
-
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.
bashnpx prisma generateThis updates the
@prisma/clientlibrary with typesafe methods based on your schema. -
Database Client Configuration (
src/config/db.js):javascript// 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:
- Pool Size: Default is
num_physical_cpus * 2 + 1. Adjust viaconnection_limitinDATABASE_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) andpool_timeout(default 10s) - Health Checks: Implement periodic connection validation with
prisma.$queryRaw - Monitoring: Track active connections, query duration, and connection pool exhaustion
- Pool Size: Default is
4. Implementing Core Functionality (API & Services)
Build the API endpoints and service logic for managing subscribers and campaigns, and for sending messages.
Structure:
- Routes (
src/routes/): Define API endpoints and link them to controllers. Useexpress.Router - Controllers (
src/controllers/): Handle HTTP requests, perform validation, call services, and send responses - Services (
src/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):javascript// 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):javascript// 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):javascript// 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:
- Use environment variables for all configuration
- Implement proper monitoring and alerting
- Set up automated backups for your database
- Use a process manager like PM2 for Node.js
- Configure SSL/TLS for secure connections
- Implement proper logging and error tracking
Platform-Specific Deployment Examples:
AWS Deployment:
- Deploy application on EC2 or ECS with Application Load Balancer
- Use RDS PostgreSQL with automated backups and Multi-AZ for high availability
- Use ElastiCache Redis for BullMQ job queue
- Store secrets in AWS Secrets Manager, access via IAM roles
- Configure SSL certificates through AWS Certificate Manager
Heroku Deployment:
- Use Heroku Postgres add-on (Standard tier minimum for production)
- Use Heroku Redis add-on for BullMQ
- Configure dyno formation: web dyno for API, worker dyno for BullMQ workers
- Set environment variables via
heroku config:set - Enable automatic SSL with Heroku ACM
DigitalOcean Deployment:
- Deploy on App Platform with managed PostgreSQL database
- Use DigitalOcean Managed Redis for job queue
- Configure health checks on
/healthendpoint - Set environment variables in App Platform console
- Enable automatic HTTPS with Let's Encrypt
Docker Containerization:
# Dockerfile
FROM node:16-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npx prisma generate
EXPOSE 3000
CMD ["node", "src/server.js"]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 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:
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:
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 (
scheduledAtfield) - Accept user input with timezone identifier (e.g., "America/New_York")
- Use
date-fns-tzorluxonfor 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
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:
- 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
Related Resources:
- Plivo API Documentation
- BullMQ Documentation
- Prisma Documentation
- Express Best Practices
- TCPA Compliance Guidelines
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
What are the prerequisites for building this SMS campaign application?
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.
How to send bulk SMS messages using Plivo?
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.
What is the role of Express.js in an SMS campaign application?
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.
Why does this setup use PostgreSQL and Prisma?
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.
When should I use ngrok in SMS campaign development?
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.
Can I use an Alphanumeric Sender ID with Plivo?
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.
How to manage subscriber opt-outs in the SMS campaign?
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.
What is the purpose of BullMQ in this architecture?
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.
How to handle Plivo webhooks securely?
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.
What is the project setup process for building this application?
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.
How to configure Plivo for receiving incoming messages?
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.
Why is a production webhook URL important?
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.
How does the system architecture handle message 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.
What's the role of the SentMessage model in the database schema?
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`.