Production-Ready Bulk SMS with Node.js, Express, and Plivo
Sending SMS messages to a large list of recipients -> often called bulk messaging or broadcast messaging -> is a common requirement for notifications, marketing campaigns, alerts, and more. Doing this efficiently, reliably, and in a way that handles errors and tracks delivery is crucial for production applications.
This guide provides a comprehensive walkthrough for building a robust bulk SMS sending system using Node.js, the Express framework, and the Plivo Communications Platform. We'll cover everything from project setup and core sending logic to API design, security, error handling, delivery tracking, and deployment.
Project Goals:
- Build a Node.js/Express application capable of accepting a list of phone numbers and a message text via an API endpoint.
- Utilize Plivo's bulk messaging feature to send the message efficiently to all recipients.
- Handle lists larger than Plivo's single API call limit by implementing intelligent batching.
- Securely manage API credentials and provide basic API authentication.
- Implement webhook handling to receive and process SMS delivery status updates from Plivo.
- Include robust error handling, logging, and provide guidance on retry mechanisms (conceptual).
- Provide guidance on testing, deployment, and monitoring.
- Note: This guide focuses on the core sending and webhook handling logic. Features like database persistence for tracking, advanced retry logic, and background job processing for very large lists are discussed conceptually but are not fully implemented in the provided code snippets and require further development for a production system.
Technologies Used:
- Node.js: A JavaScript runtime environment for server-side development.
- Express.js: A minimal and flexible Node.js web application framework.
- Plivo Node.js SDK: Simplifies interaction with the Plivo REST API.
- Plivo Communications Platform: Provides the SMS API infrastructure.
- dotenv: Loads environment variables from a
.env
file intoprocess.env
. - body-parser: Node.js body parsing middleware (included for clarity, though Express >= 4.16 has built-in
express.json()
/express.urlencoded()
). - express-rate-limit: Basic rate limiting middleware for Express.
- winston: A versatile logging library.
- (Conceptual / Not Implemented) Database: Such as PostgreSQL or MongoDB for persistent storage of message statuses.
- (Conceptual / Not Implemented) Background Job Queue: Such as 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): Installed on your development machine. Download Node.js
- Plivo Account: Sign up for a free trial or use an existing account. Sign up for Plivo
- Plivo Auth ID and Auth Token: Found on your Plivo Console dashboard.
- A Plivo Phone Number: Capable of sending SMS messages. You can rent one via the Plivo Console (Phone Numbers -> Buy Numbers) or the API. Note that SMS capabilities vary by country and number type (Long Code, Short Code, Toll-Free).
- (Optional) ngrok: Useful for testing webhooks locally. Download ngrok
Final Outcome:
By the end of this guide, you will have a functional Express API endpoint that accepts bulk SMS requests, sends messages via Plivo using appropriate batching, handles delivery reports, incorporates basic security and logging, and is structured for further enhancement (like database integration and background jobs) and deployment.
1. Setting Up the Project
Let's initialize our 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-sms
2. Initialize npm
This creates a package.json
file to manage dependencies and project metadata.
npm init -y
3. Install Dependencies
npm install express plivo dotenv body-parser express-rate-limit winston
express
: The web framework.plivo
: The official Plivo Node.js SDK.dotenv
: To load environment variables from a.env
file.body-parser
: Middleware to parse incoming request bodies (JSON, URL-encoded). While Express 4.16+ includesexpress.json()
andexpress.urlencoded()
,body-parser
is explicitly included here for clarity and historical context. The setup inapp.js
usesbody-parser
.express-rate-limit
: To prevent abuse of the API.winston
: For structured logging.
4. Create Project Structure
A simple structure to start:
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.json
.env
)
5. Configure Environment Variables (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
# 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. You only need to set this
# if you configure a *different* specific webhook secret in your Plivo Application settings.
# For simplicity, you can 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_KEY
is mandatory for the API security middleware. PLIVO_SENDER_ID
: This is the 'From' number or Alphanumeric Sender ID used 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
).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
: As noted, this is optional. The validation code will default to usingPLIVO_AUTH_TOKEN
if this specific variable is not set, which matches Plivo's default behavior.
.gitignore
6. Create 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.db
src/config/logger.js
)
7. Configure Logger (Set up a basic 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 we're not in production then log to the `console` with the format:
// `${info.level}: ${info.message} JSON.stringify({ ...rest }) `
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 also creates thelogs/
directory if it doesn't exist.
2. Implementing Core Functionality (Plivo Service)
This service will contain 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, you might want to 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
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 (max PLIVO_DST_LIMIT).
* @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}`);
}
const destinationNumbers = recipientsBatch.join('<'); // Plivo's delimiter for bulk SMS
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
destinationNumbers_ // dst
message_ // text
{
url: deliveryReportUrl_ // URL for delivery reports
method: 'POST'_ // 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.
// The primary way to track the status of *each individual recipient* in the bulk send
// is via the Delivery Report webhooks_ which will 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, consider triggering 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).
* @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.
*/
function validateWebhookSignature(signature, nonce, url, body) {
// Use the specific webhook secret if provided, otherwise default to the Plivo Auth Token.
const webhookSecret = process.env.PLIVO_WEBHOOK_SECRET || PLIVO_AUTH_TOKEN;
if (!webhookSecret) {
// This should ideally 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.
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, loads env vars, initializes Plivo client. Includes critical checks for essential credentials and warns if
API_BASE_URL
is missing. PLIVO_DST_LIMIT
: Defines the max recipients per API call.sendBulkSmsBatch
: Sends a single batch, joins recipients with<
_ constructs the webhook URL_ callsclient.messages.create
_ logs success/failure_ and clarifies the role ofmessageUuid
vs. DLRs for tracking.sendBulkSms
: Takes the full recipient list_ iterates creating batches_ pushessendBulkSmsBatch
promises_ usesPromise.allSettled
for concurrent execution and robust results_ logs outcomes.validateWebhookSignature
: Retrieves necessary components (signature_ nonce_ URL_ raw body)_ determines the correct secret (PLIVO_WEBHOOK_SECRET
or fallback toPLIVO_AUTH_TOKEN
)_ uses the SDK'splivo.validateV3Signature
_ logs errors/failures_ returns boolean.
3. Building the API Layer (Express Routes)
Now_ let's 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 a production scenario_ you might want to 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: 'Unauthorized: API key missing in x-api-key header.' });
}
if (providedApiKey !== API_KEY) {
logger.warn('API access attempt DENIED: Invalid API key provided.');
return res.status(403).json({ error: 'Forbidden: Invalid API key.' });
}
// API key is valid
logger.debug('API key validated successfully.'); // Use debug level for successful auth
next();
};
module.exports = apiKeyAuth;
- Security Enhancement: This middleware now requires the
API_KEY
environment variable to be set. If it's missing, it logs a critical error and returns a 500 status to prevent accidental unsecured deployment. It checks for thex-api-key
header 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
// This MUST be used *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.raw
to capture the raw body before JSON parsing. Added logging and basic error handling during capture. Changed type to*/*
to be more robust.validatePlivoWebhook
: UsesplivoService.validateWebhookSignature
. Reconstructs the URL, retrieves headers and the raw body. Returns appropriate error codes (400
for missing headers,403
for invalid signature,500
if 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: 'Invalid input: "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: 'Invalid input: "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 chars, less for UCS-2).
// - Sanitize message content if necessary (though 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: 'Invalid input: 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., 10k+), processing `sendBulkSms`
// directly in the request handler can cause timeouts.
// Consider implementing a background job queue (e.g., BullMQ, RabbitMQ):
// 1. API receives request, validates input.
// 2. API adds a job to the queue with recipients & 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: `Request processed, but ${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;
// Basic 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, creates router instance.
/api/bulk-sms
(POST):- Protected by
apiKeyAuth
. - Parses
recipients
andmessage
. - 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.allSettled
results 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...catch
for unexpected errors.
- Protected by
/api/webhooks/plivo/delivery-report
(POST):- Protected by
validatePlivoWebhook
(which relies oncaptureRawBody
running 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