This guide provides a complete walkthrough for building a system to send SMS marketing campaigns using Node.js, Express, and the Plivo communication platform, covering aspects essential for production readiness. We will cover everything from initial project setup to deployment, monitoring, and handling real-world complexities.
By the end of this tutorial, you will have a functional application capable of sending targeted SMS messages, handling replies, and managing campaign data, establishing a foundation built with considerations for scalability, security, and reliability.
Project Overview and Goals
What We're Building:
A Node.js application using the Express framework that enables users to define and execute SMS marketing campaigns via Plivo's API. The system will:
- Accept campaign details (message content, recipient list).
- Send SMS messages reliably via Plivo.
- Optionally handle incoming replies using Plivo webhooks.
- Store basic campaign and recipient status data (initially in memory, with guidance for database integration, which is crucial for production).
- Provide a basic API structure for potential frontend integration.
Problem Solved:
This system automates the process of sending bulk SMS messages for marketing or notifications, providing a more scalable and manageable solution than manual sending. It also establishes a foundation for tracking campaign effectiveness and handling customer responses.
Technologies Used:
- Node.js: A JavaScript runtime environment chosen for its asynchronous, event-driven nature, well-suited for I/O-heavy tasks like API interactions and handling webhooks.
- Express.js: A minimal and flexible Node.js web application framework providing robust features for web and mobile applications, simplifying API and webhook creation.
- Plivo Node.js SDK: Simplifies interaction with the Plivo REST API for sending SMS and handling responses.
- Plivo: A cloud communications platform providing SMS API services, phone numbers, and webhook capabilities.
- dotenv: To manage environment variables securely.
- ngrok (for development): To expose the local development server to the internet for testing Plivo webhooks.
System Architecture:
The basic flow of the system is as follows:
- A User or Client sends an API request containing campaign details to the Node.js/Express API.
- The Node.js/Express API sends an SMS request to the Plivo API.
- The Plivo API sends the SMS to the recipient's phone.
- If the recipient replies via SMS, the reply goes to Plivo.
- Plivo sends an incoming SMS webhook notification to a dedicated Node.js/Express webhook endpoint.
- Both the main API and the webhook endpoint interact with a data store (initially in-memory, ideally a database) to store and retrieve campaign and message status information.
Prerequisites:
- Node.js and npm (or yarn) installed.
- A Plivo account (Sign up for free).
- A Plivo phone number with SMS capability (can be purchased via the Plivo console). Trial accounts have limitations (e.g., sending only to verified numbers).
- Basic understanding of JavaScript, Node.js, and REST APIs.
ngrok
installed for local webhook testing (Download ngrok).
1. Setting up the Project
Let's initialize our Node.js project and install the necessary dependencies.
-
Create Project Directory: Open your terminal and create a new directory for the project, then navigate into it.
mkdir plivo-marketing-campaign cd plivo-marketing-campaign
-
Initialize Node.js Project: This creates a
package.json
file to manage dependencies and project metadata.npm init -y
-
Install Dependencies: We need Express for the web server, the Plivo SDK,
dotenv
for environment variables, andbody-parser
(though included in modern Express, explicitly adding it is fine) for parsing request bodies.nodemon
is useful for development as it automatically restarts the server on file changes.npm install express plivo dotenv body-parser npm install --save-dev nodemon
-
Configure
nodemon
(Optional but Recommended): Add a script to yourpackage.json
for easy development server startup. Openpackage.json
and add/modify thescripts
section:// package.json { // ... other properties ""scripts"": { ""start"": ""node index.js"", // For production ""dev"": ""nodemon index.js"" // For development }, // ... other properties }
You can now run
npm run dev
to start the server with automatic restarts. -
Set up Environment Variables: Create a file named
.env
in the project root. This file will store sensitive credentials and configurations. Never commit this file to version control.# .env # Plivo Credentials (Get from Plivo Console > API > Keys & Tokens) PLIVO_AUTH_ID=YOUR_PLIVO_AUTH_ID PLIVO_AUTH_TOKEN=YOUR_PLIVO_AUTH_TOKEN # Plivo Number (Must be SMS enabled and in E.164 format, e.g., +14155551212) PLIVO_SENDER_ID=YOUR_PLIVO_PHONE_NUMBER # Server Configuration PORT=3000 # Base URL for Webhooks (Use ngrok URL during development) BASE_URL=YOUR_NGROK_OR_DEPLOYMENT_URL
PLIVO_AUTH_ID
/PLIVO_AUTH_TOKEN
: Find these on your Plivo Console dashboard under ""API -> Keys & Tokens"".PLIVO_SENDER_ID
: The Plivo phone number you purchased or will use for sending messages, in E.164 format (e.g.,+12025551234
). For countries outside the US/Canada, you might use an Alphanumeric Sender ID if registered and approved by Plivo.PORT
: The port your Express server will listen on.BASE_URL
: The publicly accessible URL where your application will be hosted. During development, this will be yourngrok
forwarding URL.
-
Create Basic Project Structure: Organize your code for better maintainability.
plivo-marketing-campaign/ ├── .env ├── .gitignore ├── index.js ├── package.json ├── config/ │ └── plivo.js ├── routes/ │ ├── campaign.js │ └── webhooks.js └── services/ └── campaignService.js
-
Configure
.gitignore
: Create a.gitignore
file in the root directory to prevent sensitive files and unnecessary directories from being committed to Git.# .gitignore # Dependencies node_modules/ # Environment variables .env # Logs logs/ *.log npm-debug.log* yarn-debug.log* yarn-error.log* # OS generated files .DS_Store Thumbs.db
-
Initialize Plivo Client: Create a file to encapsulate the Plivo client initialization using the environment variables.
// config/plivo.js require('dotenv').config(); const plivo = require('plivo'); const client = new plivo.Client( process.env.PLIVO_AUTH_ID, process.env.PLIVO_AUTH_TOKEN ); module.exports = client;
> Why: Centralizing the client creation makes it easy to manage and reuse throughout the application. Using
dotenv.config()
loads the variables from.env
. -
Create Main Application File (
index.js
): Set up the basic Express server.// index.js require('dotenv').config(); // Load environment variables first const express = require('express'); const bodyParser = require('body-parser'); const campaignRoutes = require('./routes/campaign'); const webhookRoutes = require('./routes/webhooks'); const app = express(); const PORT = process.env.PORT || 3000; // Middleware app.use(bodyParser.json()); // Parse JSON bodies app.use(bodyParser.urlencoded({ extended: true })); // Parse URL-encoded bodies // Basic Health Check Route app.get('/health', (req, res) => { res.status(200).send('OK'); }); // Mount Routers app.use('/api/campaigns', campaignRoutes); // Routes for campaigns app.use('/webhooks', webhookRoutes); // Routes for incoming webhooks // Global Error Handler (Basic) - More robust handling in Section 5 app.use((err, req, res, next) => { console.error(err.stack); res.status(500).send('Something broke!'); }); // Start Server app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); console.log(`Make sure BASE_URL in .env is set correctly for webhooks.`); if (!process.env.BASE_URL && process.env.NODE_ENV !== 'production') { console.warn('WARN: BASE_URL environment variable is not set. Webhooks may not function correctly.'); } else { console.log(`Webhook base URL: ${process.env.BASE_URL}`); } }); // Export app for potential testing module.exports = app;
> Why: This sets up the core Express application, includes necessary middleware for parsing request bodies, defines a basic health check, mounts routers for different functionalities, includes a simple error handler, and starts the server listening on the configured port.
You now have a structured project ready for implementing the core campaign logic.
2. Implementing Core Functionality
We'll focus on the service layer responsible for sending the campaign messages.
-
Define Campaign Service Logic: Create the
services/campaignService.js
file. This service will take campaign details and send messages via Plivo. For now, we'll use an in-memory store for simplicity, but structure it for potential database integration later.> Note: The in-memory store used here is not suitable for production; Section 6 outlines database integration.
// services/campaignService.js const plivoClient = require('../config/plivo'); // In-memory store for campaign status (Replace with DB in production - see Section 6) const campaignStore = {}; // { campaignId: { status: 'processing' | 'completed' | 'failed', results: [] } } /** * Sends SMS messages for a campaign. * @param {string} campaignId - A unique identifier for the campaign. * @param {string[]} recipients - Array of phone numbers in E.164 format. * @param {string} message - The text message content. * @returns {Promise<object>} - Promise resolving to campaign results. */ async function sendCampaign(campaignId, recipients, message) { const senderId = process.env.PLIVO_SENDER_ID; if (!senderId) { throw new Error('PLIVO_SENDER_ID environment variable is not set.'); } console.log(`Starting campaign ${campaignId} to ${recipients.length} recipients.`); campaignStore[campaignId] = { status: 'processing', results: [] }; // --- Production Consideration: Rate Limiting & Queuing --- // In a real application, avoid sending thousands of messages in a tight loop. // Implement rate limiting (e.g., 1 message per second) or use a message queue (e.g., RabbitMQ, Redis Queue - see Section 9). // This basic example sends sequentially with minimal delay for demonstration. // ----------------------------------------------------------- > **Production Consideration: Rate Limiting & Queuing** > In a real application, avoid sending thousands of messages in a tight loop. Implement rate limiting (e.g., 1 message per second) or use a message queue (e.g., RabbitMQ, Redis Queue - see Section 9). This basic example sends sequentially with minimal delay for demonstration. for (const recipient of recipients) { try { // Basic validation (More robust validation needed) if (!/^\+\d{1,15}$/.test(recipient)) { console.warn(`Skipping invalid recipient format: ${recipient}`); campaignStore[campaignId].results.push({ recipient, status: 'invalid_format' }); continue; } console.log(`Sending message to ${recipient}...`); const response = await plivoClient.messages.create( senderId, recipient, message, { // Optional: Configure webhook URL for delivery status reports // url: `${process.env.BASE_URL}/webhooks/status`, // method: 'POST' } ); console.log(`Message sent to ${recipient}, UUID: ${response.messageUuid[0]}`); campaignStore[campaignId].results.push({ recipient, status: 'sent', messageUuid: response.messageUuid[0], }); // Add a small delay to avoid hitting API rate limits too quickly (adjust as needed) await new Promise(resolve => setTimeout(resolve, 200)); // 200ms delay } catch (error) { console.error(`Failed to send message to ${recipient}:`, error.message); campaignStore[campaignId].results.push({ recipient, status: 'failed', error: error.message, }); // Decide if one failure should stop the whole campaign or just log and continue } } campaignStore[campaignId].status = 'completed'; // Or 'partial_failure' if some failed console.log(`Campaign ${campaignId} finished processing.`); return campaignStore[campaignId]; } /** * Retrieves the status of a campaign. * @param {string} campaignId - The ID of the campaign. * @returns {object | null} - Campaign status or null if not found. */ function getCampaignStatus(campaignId) { return campaignStore[campaignId] || null; } module.exports = { sendCampaign, getCampaignStatus, };
> Why: This isolates the logic for interacting with Plivo and managing campaign state. It uses the configured Plivo client, iterates through recipients (with basic validation and delay), calls
plivoClient.messages.create
, and stores results in memory. It includes crucial comments about rate limiting, queuing, and the need for a database for production readiness. ThegetCampaignStatus
function provides a way to check progress.
3. Building a Complete API Layer
Now, let's create the Express routes to expose our campaign functionality.
-
Create Campaign Routes File: Define the endpoints for creating and checking campaigns in
routes/campaign.js
.// routes/campaign.js const express = require('express'); const { sendCampaign, getCampaignStatus } = require('../services/campaignService'); const { v4: uuidv4 } = require('uuid'); // For generating unique campaign IDs const router = express.Router(); // POST /api/campaigns - Start a new campaign router.post('/', async (req, res, next) => { const { recipients, message } = req.body; // --- Input Validation (Basic) --- // Add more robust validation (e.g., using express-validator - see Section 7) in production > **Input Validation (Basic):** > Add more robust validation (e.g., using `express-validator` - see Section 7) in production. if (!Array.isArray(recipients) || recipients.length === 0) { return res.status(400).json({ error: 'Recipients must be a non-empty array.' }); } if (typeof message !== 'string' || message.trim() === '') { return res.status(400).json({ error: 'Message must be a non-empty string.' }); } // --------------------------------- const campaignId = uuidv4(); // Generate a unique ID for this campaign try { // Start sending asynchronously, don't wait for completion here sendCampaign(campaignId, recipients, message) .then(results => console.log(`Campaign ${campaignId} processing complete.`)) .catch(error => console.error(`Error processing campaign ${campaignId}:`, error)); // Immediately respond to the client res.status(202).json({ message: 'Campaign accepted for processing.', campaignId: campaignId, statusUrl: `/api/campaigns/${campaignId}/status`, // URL to check status }); } catch (error) { next(error); // Pass error to global error handler } }); // GET /api/campaigns/:campaignId/status - Check campaign status router.get('/:campaignId/status', (req, res) => { const { campaignId } = req.params; const status = getCampaignStatus(campaignId); if (status) { res.status(200).json(status); } else { res.status(404).json({ error: 'Campaign not found.' }); } }); module.exports = router;
> Why: This sets up two endpoints: > *
POST /api/campaigns
: Takes a list of recipients and a message. It performs basic validation, generates a uniquecampaignId
(usinguuid
), initiates thesendCampaign
service asynchronously (so the API responds quickly), and returns a202 Accepted
status with thecampaignId
and a URL to check the status. > *GET /api/campaigns/:campaignId/status
: Allows checking the progress/results using thecampaignId
.Install
uuid
: This code uses theuuid
package to generate unique IDs. Install it:npm install uuid
-
Testing with
curl
: Start your server:npm run dev
-
Send a Campaign: Replace the placeholder numbers
+1SANDBOXNUMBER1
and+1SANDBOXNUMBER2
with actual phone numbers verified in your Plivo Sandbox account. Remember trial accounts can only send to verified numbers.curl -X POST http://localhost:3000/api/campaigns \ -H 'Content-Type: application/json' \ -d '{ ""recipients"": [""+1SANDBOXNUMBER1"", ""+1SANDBOXNUMBER2""], ""message"": ""Hello from our Node.js campaign!"" }'
You should receive a response like:
{ ""message"": ""Campaign accepted for processing."", ""campaignId"": ""some-unique-uuid-string"", ""statusUrl"": ""/api/campaigns/some-unique-uuid-string/status"" }
-
Check Status: Use the
campaignId
from the previous response.curl http://localhost:3000/api/campaigns/some-unique-uuid-string/status
Initially, it might show
{""status"":""processing"",""results"":[]}
. After a short while, it should show{""status"":""completed"", ""results"": [...]}
with details for each recipient.
-
4. Integrating with Third-Party Services (Plivo Webhooks)
To handle replies or delivery reports, we need to configure Plivo to send HTTP requests (webhooks) to our application.
-
Start
ngrok
: If your server is running locally on port 3000, expose it to the internet:ngrok http 3000
ngrok
will display a forwarding URL (e.g.,https://<unique-subdomain>.ngrok.io
). Copy thehttps
version. -
Update
.env
: Set theBASE_URL
in your.env
file to yourngrok
forwarding URL. Restart your Node.js server after changing.env
.# .env # ... other variables BASE_URL=https://<your-unique-subdomain>.ngrok.io
-
Configure Plivo Application:
- Go to your Plivo Console: https://console.plivo.com/
- Navigate to Messaging -> Applications -> XML.
- Click Add New Application.
- Give it a name (e.g., ""Node Marketing Campaign App"").
- Message URL: Enter your webhook endpoint URL:
${BASE_URL}/webhooks/inbound-sms
. For example:https://<your-unique-subdomain>.ngrok.io/webhooks/inbound-sms
. - Method: Set to
POST
. - Optional Delivery Report URL: You can set a similar URL (e.g.,
${BASE_URL}/webhooks/status
) if you want delivery status updates. Set method toPOST
. - Click Create Application.
- Alternatively, if you only plan to use the Message URL and Delivery Report URL and won't be sending back dynamic XML responses from your webhook, Plivo's generic ""Messaging Application"" type might also suffice, depending on current Plivo UI options.
-
Assign Application to Plivo Number:
- Navigate to Phone Numbers -> Your Numbers.
- Click on the Plivo number you are using as
PLIVO_SENDER_ID
. - In the ""Application Type"" section, select
XML Application
(orMessaging Application
if you chose that type). - From the ""Plivo Application"" dropdown, choose the application you just created (""Node Marketing Campaign App"").
- Click Update Number.
-
Create Webhook Routes File: Define the endpoint to handle incoming SMS messages in
routes/webhooks.js
.// routes/webhooks.js const express = require('express'); const plivo = require('plivo'); const plivoClient = require('../config/plivo'); // Only if you need to send a reply via API const router = express.Router(); // POST /webhooks/inbound-sms - Handles incoming SMS from Plivo router.post('/inbound-sms', (req, res) => { const fromNumber = req.body.From; const toNumber = req.body.To; // Your Plivo number const text = req.body.Text; const messageUuid = req.body.MessageUUID; console.log(`\n--- Incoming SMS ---`); console.log(`From: ${fromNumber}`); console.log(`To: ${toNumber}`); console.log(`Text: ${text}`); console.log(`UUID: ${messageUuid}`); console.log(`--------------------\n`); // --- Business Logic for Replies --- > **Business Logic for Replies:** > 1. Check for keywords like STOP, UNSUBSCRIBE, HELP (See Section 6 & 8). > 2. Store the reply associated with the original campaign/recipient (requires DB - see Section 6). > 3. Trigger alerts or forward messages if needed. // --- Option 1: Acknowledge Receipt (No Reply Sent Back) --- > **Option 1: Acknowledge Receipt (No Reply Sent Back)** > Plivo expects a 200 OK response to acknowledge receipt. If you don't send specific XML, Plivo does nothing further. res.status(200).send('Webhook received.'); /* --- Option 2: Send an Auto-Reply using Plivo XML --- > **Option 2: Send an Auto-Reply using Plivo XML** > If you want to send an immediate reply, Plivo expects specific XML. Uncomment the code below to enable auto-reply. const response = plivo.Response(); const replyText = 'Thanks for your message! We received it.'; const params = { src: toNumber, // Reply FROM your Plivo number dst: fromNumber // Reply TO the sender }; response.addMessage(replyText, params); res.set('Content-Type', 'text/xml'); res.send(response.toXML()); console.log('Sent auto-reply XML.'); */ /* --- Option 3: Send a Reply Later using the API (Requires More Logic) --- > **Option 3: Send a Reply Later using the API (Requires More Logic)** > You might process the message and decide to send a reply later using `plivoClient.messages.create(...)` similar to the outbound campaign. In this case, just send the 200 OK now (Option 1). */ }); // Optional: Endpoint for Delivery Status Reports router.post('/status', (req, res) => { const status = req.body.Status; const messageUuid = req.body.MessageUUID; const recipient = req.body.To; // The end recipient number console.log(`\n--- Delivery Status ---`); console.log(`UUID: ${messageUuid}`); console.log(`To: ${recipient}`); console.log(`Status: ${status}`); // e.g., 'sent', 'delivered', 'failed', 'undelivered' console.log(`---------------------\n`); // --- Business Logic for Status --- > **Business Logic for Status:** > Update the status of the specific message in your database using the `messageUuid` (See Section 6). res.status(200).send('Status webhook received.'); }); module.exports = router;
> Why: This file defines the
/webhooks/inbound-sms
endpoint. When Plivo receives an SMS to your configured number, it POSTs data (like sender number, message text) to this URL. The code logs the incoming message details. It shows different options for responding: simply acknowledging receipt (required), sending an immediate XML-based auto-reply, or handling it for a later API-based reply. An optional/status
endpoint demonstrates handling delivery reports. -
Testing Webhooks:
- Ensure
ngrok
is running andBASE_URL
is set correctly in.env
. - Restart your Node server (
npm run dev
). - Send an SMS message from a mobile phone to your Plivo number.
- Check your Node.js console logs. You should see the ""Incoming SMS"" details printed. If you uncommented the auto-reply XML, your mobile phone should receive the reply.
- Ensure
5. Implementing Proper Error Handling, Logging, and Retry Mechanisms
Production systems need robust error handling and logging.
-
Consistent Error Handling Strategy:
- Use
try...catch
blocks for asynchronous operations (API calls, DB interactions). - In route handlers, pass errors to the
next
function (next(error);
). - Create a more informative global error handler in
index.js
.
// index.js (replace the basic error handler) // Global Error Handler (Improved) app.use((err, req, res, next) => { // Use the logger if configured (see below), otherwise console.error const logger = require('./config/logger'); // Assuming Winston setup from step 2 logger ? logger.error('Unhandled Error:', { error: err.message, stack: err.stack }) : console.error('Unhandled Error:', err); // Respond with a generic error message in production // In development, you might want to send more details const statusCode = err.statusCode || 500; // Use specific status code if available const message = process.env.NODE_ENV === 'production' ? 'Internal Server Error' : err.message; // Log specific Plivo API errors if possible if (err.message && err.message.includes('Plivo API error')) { // Log potentially useful Plivo details logger ? logger.error('Plivo Specific Error Details:', { error: err }) : console.error('Plivo Specific Error Details:', err); } res.status(statusCode).json({ status: 'error', message: message, // Optionally include stack trace in development ...(process.env.NODE_ENV !== 'production' && { stack: err.stack }) }); });
- Use
-
Logging:
- For simple applications,
console.log
,console.warn
,console.error
might suffice initially. - For production, use a dedicated logging library like
winston
orpino
for structured logging (JSON format), log levels (info, warn, error, debug), and outputting to files or log management services.
Install
winston
:npm install winston
Example using
winston
:// config/logger.js (New file) const winston = require('winston'); const logger = winston.createLogger({ level: process.env.LOG_LEVEL || 'info', // Control log level via env var format: winston.format.combine( winston.format.timestamp(), winston.format.errors({ stack: true }), // Log stack traces winston.format.json() // Log in JSON format ), defaultMeta: { service: 'plivo-campaign-service' }, // Add service name to logs transports: [ // Log to console (adjust format for development readability) new winston.transports.Console({ format: winston.format.combine( winston.format.colorize(), winston.format.simple() ) }), // Optionally log to a file in production // new winston.transports.File({ filename: 'error.log', level: 'error' }), // new winston.transports.File({ filename: 'combined.log' }) ], }); // If we're not in production then log to the `console` with the format: // `${info.level}: ${info.message} JSON.stringify({ ...rest }) ` // Note: The default Console transport above already handles development logging. // This block might be redundant or need adjustment based on specific needs. // if (process.env.NODE_ENV !== 'production') { // logger.add(new winston.transports.Console({ // format: winston.format.simple(), // })); // } module.exports = logger; // Usage in other files (replace console.log/error): // const logger = require('./config/logger'); // logger.info('Server started'); // logger.error('Failed to send message', { error: err.message, recipient });
- For simple applications,
-
Retry Mechanisms: Network issues or temporary Plivo API glitches can occur. Implement retries for critical operations like sending messages. Use libraries like
async-retry
or implement manual exponential backoff.Install
async-retry
:npm install async-retry
Example using
async-retry
:// services/campaignService.js (Modify the sending loop) const retry = require('async-retry'); const plivoClient = require('../config/plivo'); const logger = require('../config/logger'); // Assuming Winston setup // ... (inside sendCampaign function) ... for (const recipient of recipients) { try { // Basic validation... (as before) if (!/^\+\d{1,15}$/.test(recipient)) { logger.warn(`Skipping invalid recipient format: ${recipient}`, { recipient, campaignId }); campaignStore[campaignId].results.push({ recipient, status: 'invalid_format' }); continue; } await retry(async (bail, attempt) => { // bail(new Error('Dont retry')) is used to stop retrying for certain errors logger.debug(`Attempt ${attempt}: Sending message to ${recipient}...`, { recipient, campaignId }); const response = await plivoClient.messages.create( senderId, recipient, message // ... options like delivery report URL ); logger.info(`Message sent to ${recipient}, UUID: ${response.messageUuid[0]}`, { recipient, campaignId, messageUuid: response.messageUuid[0] }); // Update campaignStore or DB here upon successful send within retry block campaignStore[campaignId].results.push({ recipient, status: 'sent', messageUuid: response.messageUuid[0] }); }, { retries: 3, // Number of retries factor: 2, // Exponential backoff factor minTimeout: 1000, // Initial delay 1 second maxTimeout: 5000, // Max delay 5 seconds onRetry: (error, attempt) => { logger.warn(`Retrying (${attempt}) sending to ${recipient} due to error: ${error.message}`, { recipient, campaignId, attempt, error: error.message }); // Potentially check for non-retryable Plivo errors here and call bail(error) // Example: Plivo often uses statusCode for errors if (error.statusCode === 400 || error.statusCode === 404) { // Bad Request or Not Found (e.g., invalid number) logger.error(`Non-retryable error sending to ${recipient}. Bailing.`, { recipient, campaignId, statusCode: error.statusCode }); bail(new Error(`Non-retryable Plivo error (Status ${error.statusCode}): ${error.message}`)); return; // Explicitly return after bailing } // Add other non-retryable status codes as needed } }); // No need for manual delay here if using async-retry timeouts } catch (error) { logger.error(`Failed to send message to ${recipient} after retries: ${error.message}`, { recipient, campaignId, error: error.message, stack: error.stack }); // Update campaignStore or DB for failure state AFTER retries exhausted if (!error.message.startsWith('Non-retryable')) { // Avoid double-logging if bailed campaignStore[campaignId].results.push({ recipient, status: 'failed', error: error.message }); } else { // Handle the bailed error specifically if needed (e.g., mark as invalid_number) // Ensure the error object passed to bail is used or logged appropriately here. const originalErrorMsg = error.message.replace('Non-retryable Plivo error ', ''); // Clean up message campaignStore[campaignId].results.push({ recipient, status: 'failed_non_retryable', error: originalErrorMsg }); } } } // ... (rest of sendCampaign function) ...