sms pricing
sms pricing
How to Send Bulk SMS with Node.js, Express, and Plivo API (2025 Guide)
Learn how to send bulk SMS messages with Node.js, Express, and Plivo. Complete tutorial with code examples, batch processing for 1,000+ recipients, webhook validation, and production deployment.
Last Updated: October 5, 2025
Learn how to send bulk SMS messages to hundreds or thousands of recipients using Node.js, Express, and the Plivo API. This tutorial covers everything from basic setup to production deployment, including batch processing, delivery tracking, and security best practices.
Whether you're building marketing campaigns, notification systems, or emergency alerts, this guide will help you implement a robust bulk SMS solution that handles large recipient lists efficiently.
What you'll build:
- A Node.js/Express REST API endpoint that accepts recipient lists and message content
- Automated batch processing to handle Plivo's 1,000-recipient limit per API request
- Webhook integration for real-time SMS delivery status tracking
- Security features including API key authentication and webhook signature validation
- Production-ready error handling, logging, and retry mechanisms
- Note: This guide focuses on core sending and webhook handling logic. Database persistence for tracking, advanced retry logic, and background job processing for very large lists are discussed conceptually but not fully implemented in the provided code snippets. These features require further development for production systems.
Technologies Used:
- Node.js: v18.x or later – JavaScript runtime environment for server-side development
- Express.js: v4.18.x or later – Minimal and flexible Node.js web application framework
- Plivo Node.js SDK: v4.x or later – Official SDK simplifies interaction with the Plivo REST API (verified from npmjs.com, October 2025)
- Plivo Communications Platform: SMS API infrastructure with global reach
- dotenv: v16.x or later – Loads environment variables from a
.envfile intoprocess.env - body-parser: Included for clarity, though Express >= 4.16 has built-in
express.json()/express.urlencoded() - express-rate-limit: v6.x or later – Basic rate limiting middleware for Express
- winston: v3.x or later – Versatile logging library for Node.js
- (Conceptual / Not Implemented) Database: PostgreSQL or MongoDB for persistent storage of message statuses
- (Conceptual / Not Implemented) Background Job Queue: BullMQ (Redis) or RabbitMQ for handling very large lists asynchronously
System Architecture:
+-------------+ +---------------------+ +---------------+ +-------------+
| Client | ----> | Node.js/Express API | ----> | Plivo API | ----> | Recipients |
| (e.g., App, | | (Bulk SMS Service) | | (SMS Gateway) | | (Mobile Phones)|
| Script) | +----------^----------+ +-------^-------+ +-------------+
+-------------+ | |
| Webhook (Delivery Reports)|
+---------------------------+Prerequisites:
- Node.js and npm (or yarn): v18.x or later installed on your development machine. Download Node.js
- Plivo Account: Sign up for Plivo to get a free trial or use an existing account
- Plivo Auth ID and Auth Token: Find these on your Plivo Console dashboard
- A Plivo Phone Number: Rent a number capable of sending SMS via the Plivo Console (Phone Numbers > Buy Numbers) or the API. SMS capabilities vary by country and number type (Long Code, Short Code, Toll-Free)
- Phone Number Format: Format all phone numbers in E.164 format (e.g., +14155552671 for US numbers)
- (Optional) ngrok: Test webhooks locally. Download ngrok
API Endpoint Reference:
- Base URL:
https://api.plivo.com/v1/Account/{auth_id}/Message/ - Method: POST for sending messages
- Bulk Limit: Maximum 1,000 destination numbers per API request (as of October 2025)
Source: Plivo Messaging API Documentation (plivo.com/docs/messaging/api/message), Plivo Node.js SDK (npmjs.com/package/plivo)
Final Outcome:
By the end of this guide, you'll have a functional Express API endpoint that accepts bulk SMS requests, sends messages via Plivo with appropriate batching, handles delivery reports, incorporates security and logging, and is structured for enhancement (database integration and background jobs) and deployment.
1. Setting Up the Project
Initialize your Node.js project and install the necessary dependencies.
1. Create Project Directory
Open your terminal and run:
mkdir node-plivo-bulk-sms
cd node-plivo-bulk-sms2. Initialize npm
Create a package.json file to manage dependencies and project metadata.
npm init -y3. Install Dependencies
npm install express plivo dotenv body-parser express-rate-limit winstonexpress: Web framework (v4.18.x or later)plivo: Official Plivo Node.js SDK (v4.x or later)dotenv: Loads environment variables from a.envfile (v16.x or later)body-parser: Parses incoming request bodies (JSON, URL-encoded). While Express 4.16+ includesexpress.json()andexpress.urlencoded(),body-parseris explicitly included here for clarity and historical context. The setup inapp.jsusesbody-parser.express-rate-limit: Prevents abuse of the API (v6.x or later)winston: Structured logging (v3.x or later)
4. Create Project Structure
Set up a simple directory structure:
node-plivo-bulk-sms/
├── src/
│ ├── services/
│ │ └── plivoService.js # Logic for interacting with Plivo
│ ├── routes/
│ │ └── api.js # API route definitions
│ ├── middleware/
│ │ ├── auth.js # API key authentication
│ │ └── validateWebhook.js # Plivo webhook validation
│ ├── config/
│ │ └── logger.js # Winston logger configuration
│ └── app.js # Express application setup
├── .env # Environment variables (DO NOT COMMIT)
├── .gitignore # Files/folders to ignore in Git
└── package.json5. Configure Environment Variables (.env)
Create a file named .env in the project root. Never commit this file to version control.
# .env
# 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 # e.g., +14155551212 (E.164 format)
# Application Settings
PORT=3000
API_BASE_URL=http://localhost:3000 # Change for production/ngrok
API_KEY=YOUR_SECRET_API_KEY # Generate a strong random key - REQUIRED for API access
# Webhook Validation Secret (Optional - Defaults to PLIVO_AUTH_TOKEN if not set)
# Plivo signs webhooks with your Auth Token by default using V3 signature validation.
# Set this only if you configure a *different* specific webhook secret
# in your Plivo Application settings.
# Leave this commented out or explicitly set it to your Auth Token.
# PLIVO_WEBHOOK_SECRET=YOUR_SEPARATE_WEBHOOK_SECRET_IF_ANY- Replace the
YOUR_...placeholders with your actual Plivo credentials, sender ID, and a secure, randomly generated API key. TheAPI_KEYis mandatory for the API security middleware. PLIVO_SENDER_ID: Use the "From" number or Alphanumeric Sender ID to send messages. Ensure it's registered and approved in your Plivo account for the destination countries. Use E.164 format for phone numbers (e.g.,+14155551212). Format:+[country code][number]without spaces or special characters.API_BASE_URL: The public-facing URL where your application will be accessible. Needed for configuring Plivo webhooks. If testing locally with ngrok, use the ngrok forwarding URL.PLIVO_WEBHOOK_SECRET: Optional. The validation code will default to usingPLIVO_AUTH_TOKENif this specific variable is not set, which matches Plivo's default V3 signature validation behavior.
SMS Character Limits (October 2025):
- GSM-7 encoding: 160 characters per segment
- UCS-2 encoding (Unicode/emoji): 70 characters per segment
- Concatenated SMS: 153 characters (GSM-7) or 67 characters (UCS-2) per segment for multi-part messages
6. Create .gitignore
Create a .gitignore file in the project root to prevent committing sensitive files and unnecessary folders.
# .gitignore
# Dependencies
node_modules/
# Environment Variables
.env
# Logs
logs/
*.log
# OS generated files
.DS_Store
Thumbs.db7. Configure Logger (src/config/logger.js)
Set up a Winston logger.
// src/config/logger.js
const winston = require('winston');
const fs = require('fs');
const path = require('path');
const logDir = 'logs';
// Create the log directory if it does not exist
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir);
}
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }), // Log stack traces
winston.format.splat(),
winston.format.json() // Log in JSON format
),
defaultMeta: { service: 'bulk-sms-service' },
transports: [
// Write all logs with level `error` and below to `error.log`
// Write all logs with level `info` and below to `combined.log`
new winston.transports.File({ filename: path.join(logDir, 'error.log'), level: 'error' }),
new winston.transports.File({ filename: path.join(logDir, 'combined.log') }),
],
});
// If not in production, log to the console with simplified formatting
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
),
}));
}
module.exports = logger;- This setup logs to files (
logs/error.log,logs/combined.log) and to the console during development. It creates thelogs/directory if it doesn't exist.
2. Implementing Core Functionality (Plivo Service)
Create the logic for interacting with the Plivo API, including sending bulk messages and handling batching.
src/services/plivoService.js
// src/services/plivoService.js
const plivo = require('plivo');
const logger = require('../config/logger');
const PLIVO_AUTH_ID = process.env.PLIVO_AUTH_ID;
const PLIVO_AUTH_TOKEN = process.env.PLIVO_AUTH_TOKEN;
const PLIVO_SENDER_ID = process.env.PLIVO_SENDER_ID;
const API_BASE_URL = process.env.API_BASE_URL;
if (!PLIVO_AUTH_ID || !PLIVO_AUTH_TOKEN || !PLIVO_SENDER_ID) {
logger.error('CRITICAL: Plivo credentials (Auth ID, Auth Token) or Sender ID missing in environment variables. Application cannot send SMS.');
// In a real application, throw an error here to prevent startup
// throw new Error('Missing Plivo configuration.');
}
if (!API_BASE_URL) {
logger.warn('API_BASE_URL environment variable is not set. Webhook URLs may not be generated correctly.');
}
const client = new plivo.Client(PLIVO_AUTH_ID, PLIVO_AUTH_TOKEN);
// Plivo's documented limit for destination numbers per API call (as of October 2025)
// Source: Plivo Messaging API Documentation
const PLIVO_DST_LIMIT = 1000;
/**
* Sends a single batch of SMS messages using Plivo's bulk feature.
* @param {string[]} recipientsBatch - Array of recipient phone numbers in E.164 format (max 1,000).
* @param {string} message - The text message content.
* @returns {Promise<object>} - Plivo API response for the message creation.
* @throws {Error} - If the Plivo API call fails.
*/
async function sendBulkSmsBatch(recipientsBatch, message) {
if (recipientsBatch.length === 0) {
logger.warn('Attempted to send an empty batch.');
return { message: 'Empty batch, no messages sent.', uuids: [] };
}
if (recipientsBatch.length > PLIVO_DST_LIMIT) {
logger.error(`Batch size exceeds Plivo limit of ${PLIVO_DST_LIMIT}. Size: ${recipientsBatch.length}`);
// This condition indicates a programming error in the calling function (sendBulkSms)
throw new Error(`Programming Error: Batch size ${recipientsBatch.length} exceeds limit of ${PLIVO_DST_LIMIT}`);
}
// Plivo's delimiter for bulk SMS: join phone numbers with '<' character
const destinationNumbers = recipientsBatch.join('<');
const deliveryReportUrl = `${API_BASE_URL}/api/webhooks/plivo/delivery-report`; // Your webhook URL
logger.info(`Sending batch of ${recipientsBatch.length} messages. First few recipients: ${recipientsBatch.slice(0, 3).join(', ')}…`);
try {
const response = await client.messages.create(
PLIVO_SENDER_ID, // src (must be E.164 format or approved alphanumeric sender ID)
destinationNumbers, // dst (E.164 format numbers joined with '<')
message, // text
{
url: deliveryReportUrl, // URL for delivery reports (webhook endpoint)
method: 'POST', // HTTP method for delivery reports
}
);
logger.info(`Plivo batch sent successfully. API Response Message: "${response.message}". Message UUID(s): ${response.messageUuid ? response.messageUuid.join(', ') : 'N/A'}`);
// Note on Message UUIDs: Plivo's API response structure might contain one or more UUIDs.
// Track the status of *each individual recipient* in the bulk send via the Delivery
// Report webhooks, which contain a MessageUUID specific to that recipient's message
// attempt. Ensure your webhook handler uses the DLR's UUID.
return response;
} catch (error) {
logger.error('Plivo API Error sending batch:', {
errorMessage: error.message,
errorStack: error.stack,
recipientsCount: recipientsBatch.length,
// Consider logging error details from Plivo if available (e.g., error.statusCode)
});
// Rethrow to allow the caller (sendBulkSms) to handle batch failures
throw error;
}
}
/**
* Sends bulk SMS messages, handling batching based on Plivo's limits.
* @param {string[]} allRecipients - Array of all recipient phone numbers.
* @param {string} message - The text message content.
* @returns {Promise<object[]>} - Array of PromiseSettledResult objects for each batch.
*/
async function sendBulkSms(allRecipients, message) {
if (!allRecipients || allRecipients.length === 0) {
logger.warn('No recipients provided for bulk SMS.');
return [];
}
logger.info(`Initiating bulk send request for ${allRecipients.length} recipients.`);
const batchPromises = [];
for (let i = 0; i < allRecipients.length; i += PLIVO_DST_LIMIT) {
const batch = allRecipients.slice(i, i + PLIVO_DST_LIMIT);
// Don't await here; start all batch sends concurrently.
batchPromises.push(sendBulkSmsBatch(batch, message));
}
// Wait for all batch sending promises to resolve or reject.
// Promise.allSettled is ideal here as it waits for all promises, regardless of success/failure.
const results = await Promise.allSettled(batchPromises);
const successfulBatches = [];
const failedBatches = [];
results.forEach((result, index) => {
const batchNumber = index + 1;
const batchRecipientCount = (index === Math.floor(allRecipients.length / PLIVO_DST_LIMIT))
? allRecipients.length % PLIVO_DST_LIMIT || PLIVO_DST_LIMIT // Last batch size
: PLIVO_DST_LIMIT; // Full batch size
if (result.status === 'fulfilled') {
logger.info(`Batch ${batchNumber} (${batchRecipientCount} recipients) sent successfully. Response:`, result.value);
successfulBatches.push(result.value);
} else {
// result.reason contains the error thrown by sendBulkSmsBatch
logger.error(`Batch ${batchNumber} (${batchRecipientCount} recipients) failed to send. Reason:`, result.reason?.message || result.reason);
failedBatches.push({ batchNumber, reason: result.reason?.message || result.reason });
}
});
if (failedBatches.length > 0) {
// In a production system, trigger alerts or specific retry logic for failed batches.
logger.warn(`Bulk send request processed with ${failedBatches.length} out of ${results.length} batches failing submission to Plivo.`);
} else {
logger.info(`All ${results.length} bulk send batches were submitted to Plivo successfully.`);
}
// Return the detailed results array containing status and value/reason for each batch promise.
return results;
}
/**
* Validates Plivo webhook signatures (V3).
* Uses Plivo's V3 signature validation method with HMAC-SHA256.
* @param {string} signature - The X-Plivo-Signature-V3 header value.
* @param {string} nonce - The X-Plivo-Signature-V3-Nonce header value.
* @param {string} url - The full URL Plivo posted to (as received by your server).
* @param {string} body - The raw request body string.
* @returns {boolean} - True if the signature is valid, false otherwise.
*
* Source: Plivo Node.js SDK validateV3Signature method
* (npmjs.com/package/plivo, October 2025)
*/
function validateWebhookSignature(signature, nonce, url, body) {
// Use the specific webhook secret if provided, otherwise default to the Plivo Auth Token.
// Plivo's default behavior is to sign webhooks with the Auth Token using V3 signature.
const webhookSecret = process.env.PLIVO_WEBHOOK_SECRET || PLIVO_AUTH_TOKEN;
if (!webhookSecret) {
// This should not happen if PLIVO_AUTH_TOKEN is set as checked earlier.
logger.error('CRITICAL: Cannot validate webhook: Neither PLIVO_WEBHOOK_SECRET nor PLIVO_AUTH_TOKEN is set.');
return false;
}
if (!signature || !nonce || !url || body === undefined || body === null) {
logger.warn('Webhook validation failed: Missing signature, nonce, URL, or body for validation.');
return false;
}
try {
// Use the Plivo SDK's built-in utility for V3 signature validation.
// This method uses HMAC-SHA256 to validate the webhook authenticity.
const isValid = plivo.validateV3Signature(url, nonce, signature, webhookSecret);
if (!isValid) {
logger.warn('Webhook validation failed: Invalid signature computed.');
}
return isValid;
} catch (error) {
logger.error('Error during webhook signature validation process:', error);
return false;
}
}
module.exports = {
sendBulkSms,
validateWebhookSignature,
};Explanation:
- Initialization: Imports libraries, loads environment variables, initializes Plivo client. Includes critical checks for essential credentials and warns if
API_BASE_URLis missing. PLIVO_DST_LIMIT: Defines the maximum recipients per API call (1,000 as of October 2025, verified from Plivo documentation).sendBulkSmsBatch: Sends a single batch, joins recipients with<delimiter (Plivo's bulk SMS format), constructs the webhook URL, callsclient.messages.create, logs success/failure, and clarifies the role ofmessageUuidvs. DLRs for tracking. E.164 format requirements included in documentation.sendBulkSms: Takes the full recipient list, iterates creating batches, pushessendBulkSmsBatchpromises, usesPromise.allSettledfor concurrent execution and robust results, logs outcomes.validateWebhookSignature: Retrieves signature components (signature, nonce, URL, raw body), determines the correct secret (PLIVO_WEBHOOK_SECRETor fallback toPLIVO_AUTH_TOKEN), uses the SDK'splivo.validateV3Signaturemethod (HMAC-SHA256 based), logs errors/failures, returns boolean. Source citation included.
3. Building the API Layer (Express Routes)
Create the Express application and define the API endpoint for sending bulk messages and the webhook endpoint for receiving delivery reports.
src/middleware/auth.js
// src/middleware/auth.js
const logger = require('../config/logger');
const API_KEY = process.env.API_KEY;
// CRITICAL SECURITY CHECK: API Key MUST be configured for production environments.
if (!API_KEY) {
logger.error('CRITICAL SECURITY RISK: API_KEY environment variable is not set. API endpoint is unprotected!');
// In production, throw an error here to prevent the app from starting insecurely.
// throw new Error('API_KEY environment variable is required.');
}
const apiKeyAuth = (req, res, next) => {
// If no API key is configured (despite the warning), log and deny access.
// This enforces that the key MUST be set. Remove this block ONLY if you
// intentionally want the API open without a key (highly discouraged).
if (!API_KEY) {
logger.error('API access denied: API_KEY is not configured on the server.');
return res.status(500).json({ error: 'Server configuration error: API key not set.' });
}
const providedApiKey = req.headers['x-api-key'];
if (!providedApiKey) {
logger.warn('API access attempt DENIED: Missing x-api-key header.');
return res.status(401).json({ error: 'Your API key is missing in the x-api-key header. Include a valid API key to authenticate.' });
}
if (providedApiKey !== API_KEY) {
logger.warn('API access attempt DENIED: Invalid API key provided.');
return res.status(403).json({ error: 'Your API key is invalid. Verify the key in your account settings.' });
}
// API key is valid
logger.debug('API key validated successfully.'); // Use debug level for successful auth
next();
};
module.exports = apiKeyAuth;- Security Enhancement: This middleware requires the
API_KEYenvironment variable to be set. If missing, it logs a critical error and returns a 500 status to prevent accidental unsecured deployment. It checks for thex-api-keyheader and validates it.
src/middleware/validateWebhook.js
// src/middleware/validateWebhook.js
const express = require('express');
const logger = require('../config/logger');
const { validateWebhookSignature } = require('../services/plivoService');
// Middleware to capture the raw body for signature verification
// Use this BEFORE any JSON parsing middleware for the webhook route.
const captureRawBody = express.raw({
type: '*/*', // Capture raw body for any content type Plivo might send
verify: (req, res, buf, encoding) => {
try {
if (buf && buf.length) {
req.rawBody = buf.toString(encoding || 'utf8');
logger.debug('Raw body captured for webhook validation.');
} else {
req.rawBody = ''; // Ensure rawBody exists even if empty
logger.debug('Empty raw body captured for webhook validation.');
}
} catch (e) {
logger.error('Error capturing raw body:', e);
req.rawBody = null; // Indicate failure to capture
}
},
});
const validatePlivoWebhook = (req, res, next) => {
const signature = req.headers['x-plivo-signature-v3'];
const nonce = req.headers['x-plivo-signature-v3-nonce'];
// Construct the full URL Plivo used to POST. Ensure base URL matches reality.
// Using req.protocol, req.get('host'), and req.originalUrl is generally reliable.
const url = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
// Use the raw body saved by the captureRawBody middleware
const rawBody = req.rawBody;
if (rawBody === null) {
// Error occurred during raw body capture
return res.status(500).send('Internal Server Error: Failed to process request body.');
}
if (!signature || !nonce) {
logger.warn('Webhook validation failed: Missing X-Plivo-Signature-V3 or X-Plivo-Signature-V3-Nonce header.');
// Don't reveal *why* validation failed in the response for security.
return res.status(400).send('Webhook validation failed.');
}
logger.debug(`Attempting webhook validation. URL: ${url}, Nonce: ${nonce}, Signature: ${signature ? 'Present' : 'Missing'}, RawBody Length: ${rawBody?.length}`);
if (validateWebhookSignature(signature, nonce, url, rawBody)) {
logger.info('Webhook signature validated successfully.');
next(); // Signature is valid, proceed to the handler
} else {
logger.error('Webhook validation failed: Invalid signature detected.');
// Return 403 Forbidden for invalid signatures
res.status(403).send('Forbidden: Invalid webhook signature.');
}
};
module.exports = { validatePlivoWebhook, captureRawBody };captureRawBody: Usesexpress.rawto capture the raw body before JSON parsing. Logging and error handling during capture included. Changed type to*/*for robustness.validatePlivoWebhook: UsesplivoService.validateWebhookSignature. Reconstructs the URL, retrieves headers and the raw body. Returns appropriate error codes (400for missing headers,403for invalid signature,500if body capture failed).
src/routes/api.js
// src/routes/api.js
const express = require('express');
const { sendBulkSms } = require('../services/plivoService');
const logger = require('../config/logger');
const apiKeyAuth = require('../middleware/auth');
const { validatePlivoWebhook } = require('../middleware/validateWebhook');
const router = express.Router();
// === Bulk SMS Sending Endpoint ===
router.post('/bulk-sms', apiKeyAuth, async (req, res) => {
const { recipients, message } = req.body;
// --- Input Validation ---
// Basic checks
if (!recipients || !Array.isArray(recipients) || recipients.length === 0) {
logger.warn('Bad Request (400): Invalid or missing recipients array.');
return res.status(400).json({ error: '"recipients" must be a non-empty array.' });
}
if (!message || typeof message !== 'string' || message.trim() === '') {
logger.warn('Bad Request (400): Invalid or missing message string.');
return res.status(400).json({ error: '"message" must be a non-empty string.' });
}
// **PRODUCTION TODO: Implement Robust Validation Here**
// - Iterate through `recipients`:
// - Verify each recipient string matches E.164 format (e.g., using a library like `libphonenumber-js`).
// - Reject the entire request or filter out invalid numbers based on requirements.
// - Check `message` length against SMS segment limits (160 GSM-7 characters, less for UCS-2).
// - Sanitize message content if necessary (less critical if backend-generated).
const validatedRecipients = recipients; // Replace with validated/filtered list
const recipientCount = validatedRecipients.length;
if (recipientCount === 0) {
logger.warn('Bad Request (400): No valid recipients after validation.');
return res.status(400).json({ error: 'No valid recipient phone numbers provided.' });
}
// --- End Input Validation ---
logger.info(`Received valid bulk SMS request for ${recipientCount} recipients via API Key.`);
try {
// **PERFORMANCE NOTE:** For very large lists (e.g., 10,000+), processing `sendBulkSms`
// directly in the request handler can cause timeouts.
// Implement a background job queue (e.g., BullMQ, RabbitMQ):
// 1. API receives request, validates input.
// 2. API adds a job to the queue with recipients and message.
// 3. API immediately returns 202 Accepted with a job ID.
// 4. A separate worker process picks up the job and calls `sendBulkSms`.
// This guide implements the direct synchronous approach for simplicity.
logger.info(`Calling sendBulkSms for ${recipientCount} recipients…`);
const results = await sendBulkSms(validatedRecipients, message);
// Analyze results from Promise.allSettled
const failedCount = results.filter(r => r.status === 'rejected').length;
const totalBatches = results.length;
if (failedCount > 0) {
logger.warn(`Bulk SMS request processed: ${failedCount} out of ${totalBatches} batch(es) failed during submission to Plivo.`);
// 207 Multi-Status indicates partial success/failure during submission phase.
res.status(207).json({
status: 'ProcessedWithFailures',
message: `${failedCount} out of ${totalBatches} batch(es) failed submission. Check server logs for details on failed batches.`,
totalRecipients: recipientCount,
totalBatches: totalBatches,
failedBatches: failedCount,
// Optionally include more detailed results if needed, but be mindful of response size
// results: results
});
} else {
logger.info(`Bulk SMS request processed successfully: All ${totalBatches} batches submitted to Plivo.`);
// 202 Accepted is appropriate as final delivery is asynchronous via webhooks.
res.status(202).json({
status: 'Accepted',
message: 'Bulk SMS request accepted and all batches submitted for processing by Plivo.',
totalRecipients: recipientCount,
totalBatches: totalBatches,
// Optionally return initial batch UUIDs if useful, though DLRs are more reliable per recipient.
// initialApiResponses: results.map(r => r.value) // Only includes responses from fulfilled promises
});
}
} catch (error) {
// Catch unexpected errors not handled within sendBulkSms (should be rare if service handles its errors)
logger.error('Unhandled error processing /bulk-sms request:', error);
res.status(500).json({ error: 'Internal server error processing bulk SMS request.' });
}
});
// === Plivo Delivery Report Webhook ===
// The `captureRawBody` middleware must run BEFORE `validatePlivoWebhook` and `express.json`
// (See `app.js` for middleware order). `validatePlivoWebhook` runs next.
router.post('/webhooks/plivo/delivery-report', validatePlivoWebhook, express.json(), (req, res) => {
// Signature is already validated by the middleware.
// The body is now parsed as JSON by `express.json()`.
const deliveryReport = req.body;
// Check if body parsing worked and is an object
if (!deliveryReport || typeof deliveryReport !== 'object') {
logger.warn('Webhook received valid signature but invalid/empty JSON body.');
return res.status(400).json({ error: 'Invalid request body.' });
}
logger.info('Received validated Plivo Delivery Report (DLR). Processing…', { report: deliveryReport });
// --- Process the Delivery Report ---
// Extract key information:
const messageUuid = deliveryReport.MessageUUID;
const status = deliveryReport.Status; // e.g., "delivered", "failed", "undelivered", "sent", "queued"
const recipient = deliveryReport.To;
const errorCode = deliveryReport.ErrorCode; // Present on failure/undelivered (e.g., "400", "500")
const timestamp = deliveryReport.Time; // Timestamp of the status event
const units = deliveryReport.Units; // Number of SMS segments used
const totalAmount = deliveryReport.TotalAmount; // Cost
// **PRODUCTION TODO: Implement Database/Storage Update Logic Here**
// This is the critical part for tracking message status.
// 1. **Find:** Look up the message attempt in your database/storage using `messageUuid`
// (ensure `messageUuid` from the DLR is stored when the message is initially tracked).
// You might also use `recipient` as a secondary lookup key if needed.
// 2. **Update:** Update the status of the corresponding record based on `status`.
// Store `errorCode`, `timestamp`, `units`, `totalAmount`.
// 3. **Idempotency:** Ensure this update is idempotent. If Plivo retries the webhook,
// processing the same DLR again should not cause issues (e.g., check current status
// before updating, or use database constraints).
// 4. **Logging/Alerting:** Log failures with error codes for analysis. Trigger alerts
// for specific critical error codes or high failure rates.
// 5. **Retry Logic (Advanced):** Based on `status` and `errorCode`, decide if a retry
// for *this specific recipient* is warranted (e.g., for transient errors). This
// requires more complex state management.
logger.info(`DLR Processed: UUID=${messageUuid}, To=${recipient}, Status=${status}, ErrorCode=${errorCode || 'N/A'}, Units=${units}, Cost=${totalAmount}`);
// --- Acknowledge Receipt to Plivo ---
// Plivo expects a 2xx response to stop retrying the webhook.
// Send 200 OK immediately after logging/basic processing.
// Database updates can happen asynchronously if needed for performance,
// but ensure the acknowledgment is sent promptly.
res.status(200).json({ message: 'Delivery report received and acknowledged.' });
});
module.exports = router;Explanation:
- Dependencies & Setup: Imports libraries, creates router instance.
/api/bulk-sms(POST):- Protected by
apiKeyAuth. - Parses
recipientsandmessage. - Includes basic validation and a clear
// PRODUCTION TODO:comment emphasizing the need for robust E.164 and message length validation. - Includes a performance note recommending background jobs for large lists but implements the direct call for simplicity.
- Calls
plivoService.sendBulkSms. - Analyzes
Promise.allSettledresults to determine success/partial failure/total failure during the submission phase. - Responds with
202 Accepted(all batches submitted) or207 Multi-Status(some batches failed submission). - Includes
try…catchfor unexpected errors.
- Protected by
/api/webhooks/plivo/delivery-report(POST):- Protected by
validatePlivoWebhook(which relies oncaptureRawBodyrunning first – seeapp.js). - Uses
express.json()after validation to parse the body. - Logs the received report.
- Extracts key fields from the DLR payload.
- Contains a prominent
// PRODUCTION TODO:section outlining the necessary database/storage update logic (Find, Update, Idempotency, Logging, Retry considerations). This logic is essential for production but not implemented in this guide.
- Protected by
Frequently Asked Questions (FAQ)
How many SMS messages can Plivo send per API request?
Plivo supports sending bulk SMS to up to 1,000 recipients in a single API call. For larger recipient lists, implement batching logic to split your list into chunks of 1,000 or fewer recipients. The code in this guide automatically handles batch processing for any list size.
What phone number format does Plivo require for SMS?
Plivo requires all phone numbers in E.164 format. This international standard includes a plus sign (+), country code, and phone number without spaces or special characters. For example, format a US number as +14155552671, not (415) 555-2671.
How do I track SMS delivery status with Plivo?
Plivo sends delivery status updates to your webhook endpoint configured in the API call. Each delivery report includes the message UUID, recipient number, delivery status (delivered, failed, queued), error codes, and billing information. Store these updates in your database to track message delivery for each recipient.
How can I validate Plivo webhooks for security?
Plivo signs all webhooks using V3 signature validation with HMAC-SHA256. Your webhook endpoint should verify the X-Plivo-Signature-V3 header by comparing it against a signature you compute using the Plivo Auth Token, nonce, request URL, and raw body. The Plivo Node.js SDK provides the validateV3Signature method for this purpose.
What are the SMS character limits for Plivo messages?
SMS character limits depend on encoding. GSM-7 encoding allows 160 characters per message segment, while UCS-2 encoding (used for Unicode characters and emojis) allows 70 characters per segment. Longer messages are automatically split into multiple segments, with 153 characters (GSM-7) or 67 characters (UCS-2) per segment for concatenated messages.
How do I handle failed SMS deliveries with Plivo?
When a message fails, Plivo's delivery report webhook includes an ErrorCode field indicating the failure reason. Common causes include invalid phone numbers, carrier rejections, or blocked content. Implement retry logic for transient errors, remove invalid numbers from your list, and log all failures for analysis. Store error codes in your database to identify patterns.
Can I send SMS messages asynchronously with large recipient lists?
Yes, for lists with thousands or tens of thousands of recipients, implement a background job queue using BullMQ (Redis-based) or RabbitMQ. Your API endpoint accepts the request, adds a job to the queue with recipient data, and immediately returns a 202 Accepted status with a job ID. A separate worker process picks up jobs from the queue and processes them, preventing API timeouts.
What Node.js and Express versions work with Plivo SDK?
The Plivo Node.js SDK v4.x requires Node.js v18.x or later. Use Express.js v4.18.x or later for the web framework. These versions provide the necessary JavaScript features, security updates, and performance improvements for production SMS applications.
Conclusion
You've built a production-ready bulk SMS system using Node.js, Express, and the Plivo API. This implementation includes intelligent batch processing for unlimited recipient lists, secure webhook validation for delivery tracking, API key authentication, structured logging, and rate limiting.
Key takeaways:
- Batch processing handles lists exceeding Plivo's 1,000-recipient limit per API call
- Webhook validation with V3 signature verification ensures delivery reports are authentic
- Concurrent batch sending with Promise.allSettled maximizes throughput while handling failures gracefully
- Security layers including API key authentication, rate limiting, and environment variable management protect your SMS infrastructure
- Structured logging with Winston provides visibility into message processing and failures
Next steps for production:
- Integrate a database (PostgreSQL, MongoDB) to store message metadata and delivery status updates
- Implement a background job queue (BullMQ, RabbitMQ) for handling very large recipient lists asynchronously
- Add comprehensive phone number validation using libraries like libphonenumber-js to ensure E.164 compliance
- Set up monitoring and alerting for message delivery rates, API errors, and webhook failures
- Implement retry logic for transient delivery failures based on Plivo error codes
- Scale horizontally by deploying multiple instances behind a load balancer
The architecture you've built provides a solid foundation for reliable SMS communications at scale. Whether you send marketing campaigns, transactional notifications, or critical alerts, this system can grow with your needs while maintaining security and delivery tracking.
Related Resources:
- Plivo Messaging API Documentation
- Plivo Node.js SDK on npm
- E.164 Phone Number Format Standard
- Winston Logging Library
- Express.js Rate Limiting
- How to Send SMS in Node.js and Express
- Plivo Bulk Messaging Documentation
Start testing your bulk SMS system in Plivo's sandbox environment, then move to production once you've verified webhook handling, batch processing, and error management work correctly for your use case.