code examples
code examples
Build a Bulk SMS Broadcaster with Plivo, Fastify & Node.js: Complete Guide (2025)
Learn how to build a scalable bulk SMS broadcasting service using Plivo's API, Fastify web framework, and Node.js. Includes setup, error handling, retry logic, and deployment best practices.
Build a scalable bulk SMS broadcasting service using Fastify's performance and Plivo's efficient bulk messaging API. Send a single SMS message to thousands of recipients with a single API request. This guide walks you through project setup, implementation, error handling, retry logic, and deployment best practices.
You'll build a robust API endpoint that accepts phone numbers and messages, then uses Plivo to broadcast SMS efficiently. This approach dramatically outperforms sending individual messages in a loop – reducing latency and API call overhead.
Project Overview and Goals
-
Problem: Sending the same SMS message to numerous recipients individually is slow, inefficient, and easily hits API rate limits.
-
Solution: Build a Fastify API endpoint (
POST /broadcast) that accepts destination phone numbers and a message body. Your endpoint will use the Plivo Node.js SDK to send messages to all recipients via Plivo's bulk messaging feature (single API call). -
Technologies:
- Node.js: Your runtime environment.
- Fastify: A high-performance, low-overhead Node.js web framework. Choose it for speed, extensibility, and built-in validation. Note: Fastify v5 is the current version requiring Node.js v20+. Fastify v4 support ends June 30, 2025.
- Plivo Node.js SDK: Simplifies interaction with the Plivo REST API. Current version: v4.74.0 as of early 2025.
dotenv&fastify-env: Manage environment variables securely and reliably.
-
Architecture:
text+-----------------+ +---------------------+ +-------------+ +-----------------+ | Client (e.g. UI,| ---> | Fastify API Server | ---> | Plivo API | ---> | SMS Recipients | | Curl, Postman) | | (POST /broadcast) | | (Bulk Send) | | (Mobile Phones) | +-----------------+ +---------------------+ +-------------+ +-----------------+ | | Uses Plivo SDK | Reads Env Vars (.env) | Logs Events/Errors -
Outcome: A functional Fastify application with a single endpoint (
/broadcast) that accepts bulk SMS requests and dispatches them via Plivo. -
Prerequisites:
- Install Node.js v22 LTS or later (v20 LTS minimum). Note: Node.js v18 reaches end-of-life on April 30, 2025 and requires upgrading.
- Install npm or yarn package manager.
- Create a Plivo account (sign up at Plivo).
- Purchase a Plivo phone number capable of sending SMS (available in your Plivo console).
- Locate your Plivo Auth ID and Auth Token (found in the Plivo console).
1. Setting Up the Project
Initialize your Node.js project and install dependencies. These instructions work across all operating systems.
-
Create Project Directory:
bashmkdir fastify-plivo-broadcaster cd fastify-plivo-broadcaster -
Initialize npm Project:
bashnpm init -yThis creates a
package.jsonfile. -
Install Dependencies:
fastify: The core web framework.plivo: The official Plivo Node.js SDK.dotenv: Loads environment variables from a.envfile.fastify-env: Schema-based environment variable validation for Fastify.
bashnpm install fastify plivo dotenv fastify-env -
Install Development Dependencies (Optional but Recommended):
nodemon: Automatically restarts the server on file changes during development.pino-pretty: Formats Pino logs for readability during development.
bashnpm install --save-dev nodemon pino-pretty -
Configure
package.jsonScripts:Open
package.jsonand add/modify thescriptssection:json{ ""name"": ""fastify-plivo-broadcaster"", ""version"": ""1.0.0"", ""description"": """", ""main"": ""src/server.js"", ""scripts"": { ""start"": ""node src/server.js"", ""dev"": ""nodemon src/server.js | pino-pretty"", ""test"": ""echo \""Error: no test specified\"" && exit 1"" }, ""keywords"": [], ""author"": """", ""license"": ""ISC"", ""dependencies"": { ""dotenv"": ""^..."", ""fastify"": ""^..."", ""fastify-env"": ""^..."", ""plivo"": ""^..."" }, ""devDependencies"": { ""nodemon"": ""^..."", ""pino-pretty"": ""^..."" } }(Note: The
devscript pipes output topino-prettyfor readable logs during development). -
Create Project Structure:
Organize your code for clarity and maintainability.
bashmkdir src mkdir src/routes mkdir src/services touch src/server.js touch src/routes/broadcast.js touch src/services/plivoService.js touch .env touch .gitignoresrc/: Contains all source code.src/routes/: Holds route definitions.src/services/: Contains business logic interacting with external services (like Plivo).src/server.js: The main application entry point..env: Stores sensitive credentials and configuration (DO NOT commit this file)..gitignore: Specifies files/directories to be ignored by Git.
-
Configure
.gitignore:Add the following lines to your
.gitignorefile to prevent committing sensitive information and unnecessary files:text# .gitignore node_modules .env npm-debug.log *.log -
Set Up Environment Variables (
.env):Open the
.envfile and add your Plivo credentials and server configuration.PLIVO_AUTH_ID: Your Plivo Account Auth ID. Find this on the overview page of your Plivo Console.PLIVO_AUTH_TOKEN: Your Plivo Account Auth Token. Also on the overview page.PLIVO_SOURCE_NUMBER: A Plivo phone number you own (in E.164 format, e.g.,+14155552671) enabled for SMS. Find this under Phone Numbers -> Your Numbers in the console.HOST: The host address the server should listen on (e.g.,0.0.0.0to listen on all available network interfaces, or127.0.0.1for localhost only).PORT: The port number for the server (e.g.,3000).LOG_LEVEL: Controls logging verbosity (e.g.,info,debug,warn,error). Fastify uses Pino logger.
dotenv# .env PLIVO_AUTH_ID=YOUR_PLIVO_AUTH_ID PLIVO_AUTH_TOKEN=YOUR_PLIVO_AUTH_TOKEN PLIVO_SOURCE_NUMBER=+1XXXXXXXXXX HOST=0.0.0.0 PORT=3000 LOG_LEVEL=infoReplace the placeholder values with your actual Plivo credentials and desired settings.
-
Initialize Basic Fastify Server (
src/server.js):Set up the basic server structure and configure
fastify-envto load and validate our environment variables.javascript// src/server.js 'use strict'; // Load environment variables from .env file first require('dotenv').config(); const Fastify = require('fastify'); const fastifyEnv = require('fastify-env'); const broadcastRoutes = require('./routes/broadcast'); // Define schema for environment variables const envSchema = { type: 'object', required: [ 'PORT', 'HOST', 'PLIVO_AUTH_ID', 'PLIVO_AUTH_TOKEN', 'PLIVO_SOURCE_NUMBER', ], properties: { PORT: { type: 'string', default: 3000 }, HOST: { type: 'string', default: '0.0.0.0' }, PLIVO_AUTH_ID: { type: 'string' }, PLIVO_AUTH_TOKEN: { type: 'string' }, PLIVO_SOURCE_NUMBER: { type: 'string', pattern: '^\\+[1-9]\\d{1,14}$' // E.164 format regex }, LOG_LEVEL: { type: 'string', default: 'info' }, // Add other env vars here if needed }, }; // Determine logger options based on NODE_ENV // Use pino-pretty only in development const loggerConfig = process.env.NODE_ENV === 'production' ? { level: process.env.LOG_LEVEL || 'info' } // Production: JSON logs : { // Development: Pretty logs level: process.env.LOG_LEVEL || 'info', transport: { target: 'pino-pretty', options: { translateTime: 'HH:MM:ss Z', ignore: 'pid,hostname', }, }, }; // Initialize Fastify with logging options const fastify = Fastify({ logger: loggerConfig }); const startServer = async () => { try { // Register fastify-env plugin await fastify.register(fastifyEnv, { schema: envSchema, dotenv: true, // Load .env file using dotenv }); // Make Plivo credentials available across the app via decoration // This prevents needing to pass `fastify.config` everywhere fastify.decorate('plivoConfig', { authId: fastify.config.PLIVO_AUTH_ID, authToken: fastify.config.PLIVO_AUTH_TOKEN, sourceNumber: fastify.config.PLIVO_SOURCE_NUMBER, }); // Register application routes fastify.register(broadcastRoutes, { prefix: '/api/v1' }); // Add a version prefix // Basic health check route fastify.get('/health', async (request, reply) => { return { status: 'ok', timestamp: new Date().toISOString() }; }); // Start the server await fastify.listen({ port: parseInt(fastify.config.PORT, 10), host: fastify.config.HOST, }); fastify.log.info(`Server dependencies loaded successfully.`); } catch (err) { fastify.log.error(err); process.exit(1); } }; startServer();- We use
dotenvto load the.envfile before Fastify initializes fully. fastify-envvalidates the loaded environment variables againstenvSchema. This ensures critical configuration is present and correctly formatted (like the E.164 pattern for the phone number).- We decorate the
fastifyinstance withplivoConfigto make credentials easily accessible in services/routes without explicit passing. - A simple
/healthendpoint is added for monitoring. - Routes are registered with a
/api/v1prefix for better API versioning. - Logging is configured using Pino. Crucially,
pino-prettyis conditionally used only whenNODE_ENVis notproduction, ensuring readable logs in development and efficient JSON logs in production.
- We use
-
Run the Server (Development):
bashnpm run devYou should see pretty-printed output indicating the server is listening on the configured host and port (e.g.,
http://0.0.0.0:3000). Iffastify-envencounters missing or invalid variables, it will throw an error. To run in production mode (for JSON logs):NODE_ENV=production npm start.
2. Implementing Core Functionality (Plivo Service)
Now, let's create the service responsible for interacting with the Plivo API.
-
Create Plivo Service (
src/services/plivoService.js):This service initializes the Plivo client and exposes a function to send bulk messages.
javascript// src/services/plivoService.js 'use strict'; const plivo = require('plivo'); let plivoClient; // Singleton client instance /** * Initializes the Plivo client using configuration from the Fastify instance. * Should be called once during application startup or on first use. * @param {object} config - Plivo configuration object { authId, authToken, sourceNumber } * @param {object} logger - Fastify logger instance */ function initializePlivoClient(config, logger) { if (!plivoClient) { if (!config || !config.authId || !config.authToken) { logger.error('Plivo Auth ID or Auth Token is missing in config.'); throw new Error('Plivo credentials are required for initialization.'); } try { plivoClient = new plivo.Client(config.authId, config.authToken); logger.info('Plivo client initialized successfully.'); } catch (error) { logger.error({ err: error }, 'Failed to initialize Plivo client'); throw error; // Re-throw to prevent application start if client fails } } return plivoClient; } /** * Sends a single SMS message to multiple recipients using Plivo's bulk messaging. * @param {string[]} destinations - An array of destination phone numbers in E.164 format. * @param {string} message - The text message content. * @param {object} config - Plivo configuration object { authId, authToken, sourceNumber } * @param {object} logger - Fastify logger instance * @returns {Promise<object>} - The Plivo API response object. * @throws {Error} - If Plivo API call fails or input is invalid. */ async function sendBulkSms(destinations, message, config, logger) { // Ensure client is initialized (idempotent) initializePlivoClient(config, logger); if (!Array.isArray(destinations) || destinations.length === 0) { logger.warn('sendBulkSms called with invalid or empty destinations array.'); throw new Error('Destinations array cannot be empty.'); } if (!message || typeof message !== 'string' || message.trim() === '') { logger.warn('sendBulkSms called with invalid or empty message.'); throw new Error('Message content cannot be empty.'); } if (!config.sourceNumber) { logger.error('Plivo source number is missing from configuration.'); throw new Error('Plivo source number is required.'); } // Plivo expects destination numbers separated by '<' // Filter out potential duplicates before joining const uniqueDestinations = [...new Set(destinations)]; const plivoDestinationString = uniqueDestinations.join('<'); // Plivo's current limit is 1,000 unique destination numbers per API call const MAX_RECIPIENTS_PER_REQUEST = 1000; // Source: https://www.plivo.com/docs/messaging/api/message/bulk-messaging if (uniqueDestinations.length > MAX_RECIPIENTS_PER_REQUEST) { logger.warn(`Attempted to send to ${uniqueDestinations.length} recipients, exceeding Plivo's limit of ${MAX_RECIPIENTS_PER_REQUEST}. The current implementation does NOT automatically batch requests. Request may fail.`); // Depending on requirements, you might throw an error or implement batching logic here. // throw new Error(`Cannot send to more than ${MAX_RECIPIENTS_PER_REQUEST} recipients in a single request without batching.`); } logger.info( `Attempting to send bulk SMS via Plivo to ${uniqueDestinations.length} unique recipients.`, ); try { const response = await plivoClient.messages.create( config.sourceNumber, // src: Your Plivo number plivoDestinationString, // dst: Recipients separated by '<' message, // text: The message content // Optional parameters (e.g., method, url for status callbacks) // { // method: 'POST', // Method for status callbacks // url: 'https://your-app.com/plivo/status-callback' // Your callback URL // } ); logger.info( { message_uuid: response.messageUuid, api_id: response.apiId, recipient_count: uniqueDestinations.length, }, 'Plivo bulk message request successful.', ); // The response contains message UUIDs for tracking, but not individual delivery status here. // Use Webhooks for detailed status updates. return response; } catch (error) { logger.error( { err: error, plivoError: error.message, // Plivo SDK often puts useful info here statusCode: error.statusCode, // Plivo SDK might add statusCode }, 'Plivo API error during bulk message send.', ); // Re-throw a generic error or a custom application error throw new Error(`Failed to send bulk SMS via Plivo: ${error.message}`); } } module.exports = { initializePlivoClient, // Export initializer if needed elsewhere, though route will handle it sendBulkSms, };- We use a module-level variable
plivoClientto act as a singleton – the client is initialized only once. - The
initializePlivoClientfunction handles the creation, taking config and logger from Fastify. sendBulkSmsperforms input validation (non-empty destinations array, non-empty message).- It filters duplicate numbers using
new Set(). Crucially, it joins the unique destination numbers with the<delimiter as required by Plivo's bulk API. Plivo automatically verifies that each destination number is in the correct format and removes duplicates, but pre-filtering improves efficiency. - It includes a check against Plivo's documented recipient limit of 1,000 per request. Note: The warning message explicitly states that automatic batching is not implemented in this code.
- It calls
plivoClient.messages.createwith the source number, the<-separated destination string, and the message text. - Successful responses and errors from the Plivo API are logged appropriately.
- Errors are caught and re-thrown to be handled by the route.
- We comment out the optional
urlparameter for status callbacks – implementing webhooks is a vital next step for production monitoring but beyond this initial setup scope.
- We use a module-level variable
3. Building the API Layer (Broadcast Route)
Let's create the Fastify route that exposes our bulk sending functionality.
-
Define Route and Schema (
src/routes/broadcast.js):This file defines the
POST /broadcastendpoint, validates the incoming request body, and calls the Plivo service.javascript// src/routes/broadcast.js 'use strict'; const { sendBulkSms, initializePlivoClient } = require('../services/plivoService'); // Define the schema for the request body and response const broadcastSchema = { body: { type: 'object', required: ['destinations', 'message'], properties: { destinations: { type: 'array', minItems: 1, items: { type: 'string', // Basic E.164 format validation pattern: '^\\+[1-9]\\d{1,14}$', }, description: 'Array of recipient phone numbers in E.164 format (e.g., +14155552671)', }, message: { type: 'string', minLength: 1, maxLength: 1600, // Standard SMS limit consideration (Plivo handles segmentation) description: 'The text message content to send.', }, }, }, response: { 200: { // Successful response type: 'object', properties: { message: { type: 'string' }, message_uuid: { type: 'array', items: { type: 'string' } }, // Plivo returns UUIDs array api_id: { type: 'string' }, recipient_count: { type: 'integer' }, }, }, 400: { // Bad request (validation failed) type: 'object', properties: { statusCode: { type: 'integer' }, error: { type: 'string' }, message: { type: 'string' }, }, }, 500: { // Server error (e.g., Plivo API failed) type: 'object', properties: { statusCode: { type: 'integer' }, error: { type: 'string' }, message: { type: 'string' }, }, }, // Add other status codes like 429 for Rate Limiting if implemented }, }; async function broadcastRoutes(fastify, options) { // Ensure Plivo client is initialized when this route plugin is loaded // Access config decorated onto fastify instance in server.js initializePlivoClient(fastify.plivoConfig, fastify.log); fastify.post('/broadcast', { schema: broadcastSchema }, async (request, reply) => { const { destinations, message } = request.body; const requestId = request.id; // Fastify generates a unique ID for each request fastify.log.info( `[${requestId}] Received broadcast request for ${destinations.length} destinations.`, ); try { // Call the Plivo service function const plivoResponse = await sendBulkSms( destinations, message, fastify.plivoConfig, // Pass Plivo config from fastify instance request.log // Pass request-specific logger for better context ); // Send success response back to the client reply.code(200).send({ message: plivoResponse.message, // e.g., ""message(s) queued"" message_uuid: plivoResponse.messageUuid, api_id: plivoResponse.apiId, recipient_count: destinations.length, // Or use unique count if preferred }); fastify.log.info( `[${requestId}] Broadcast request processed successfully. Plivo API ID: ${plivoResponse.apiId}`, ); } catch (error) { fastify.log.error( `[${requestId}] Error processing broadcast request: ${error.message}`, { err: error } // Log the full error object ); // Determine appropriate status code (could refine based on error type) // If it's a known Plivo client-side error (e.g., validation) maybe 400? // Otherwise, assume 500 for server/Plivo issues. // For now, defaulting to 500 for simplicity. reply.code(500).send({ statusCode: 500, error: 'Internal Server Error', message: `Failed to send broadcast: ${error.message}`, // Provide some context }); } }); // You can add more routes here if needed } module.exports = broadcastRoutes;- We define a
broadcastSchemausing Fastify's schema validation capabilities. This automatically validates therequest.bodyagainst the defined structure, types, and constraints (likeminItems,minLength, and the E.164pattern). If validation fails, Fastify automatically sends a 400 Bad Request response. - The schema also defines expected response formats for different status codes (200, 400, 500).
- The route handler extracts
destinationsandmessagefrom the validatedrequest.body. - It calls the
sendBulkSmsservice function, passing the necessary data, the Plivo configuration (fastify.plivoConfig), and the request-specific logger (request.log) for contextual logging. - It handles success by sending a 200 response with relevant details from the Plivo API response.
- It handles errors caught from the service layer by logging them and sending a 500 Internal Server Error response.
- We define a
-
Register Route in Server:
Ensure the route is registered in
src/server.js(already done in Step 1.9). The linefastify.register(broadcastRoutes, { prefix: '/api/v1' });handles this. -
Test the Endpoint:
Restart your server (
npm run dev). You can now test the endpoint usingcurlor a tool like Postman.Using
curl:Replace placeholders with your actual server address, valid E.164 numbers (use your own test numbers!), and a message.
bashcurl -X POST http://localhost:3000/api/v1/broadcast \ -H ""Content-Type: application/json"" \ -d '{ ""destinations"": [""+14155551234"", ""+14155555678"", ""+14155551234""], ""message"": ""Hello from the Fastify Bulk Broadcaster! Test message."" }'Expected Success Response (Example):
json{ ""message"": ""message(s) queued"", ""message_uuid"": [ ""e8f3a0ee-a1d7-11eb-8f9c-0242ac110002"", ""e8f3a3a8-a1d7-11eb-8f9c-0242ac110002"" ], ""api_id"": ""f9b3aabc-a1d7-11eb-b3e0-0242ac110003"", ""recipient_count"": 3 }(Note: Plivo's actual response structure for
message_uuidmight vary slightly; consult their documentation. The service filters duplicates before sending, so only 2 unique numbers are sent here).Example Error Response (Validation Failure):
If you send invalid data (e.g., missing
messageor invalid phone number format):json{ ""statusCode"": 400, ""error"": ""Bad Request"", ""message"": ""body/destinations/0 must match pattern \""^\\\\+[1-9]\\\\d{1,14}$\"""" }Example Error Response (Server/Plivo Error):
If Plivo credentials are wrong or the API call fails:
json{ ""statusCode"": 500, ""error"": ""Internal Server Error"", ""message"": ""Failed to send broadcast: Error: Authentication credentials invalid. Please check your auth_id and auth_token."" }
4. Integrating with Plivo (Credentials & Setup)
We've already integrated the SDK, but let's explicitly cover obtaining and securing credentials.
-
Obtain Plivo Auth ID and Auth Token:
- Log in to your Plivo Console (https://console.plivo.com/).
- On the main Dashboard/Overview page, you will find your Auth ID and Auth Token.
- Copy these values carefully.
-
Obtain Plivo Source Number:
- Navigate to Messaging -> Phone Numbers in the Plivo Console sidebar.
- If you don't have a number, you'll need to buy one capable of sending SMS messages to your target regions.
- Copy the Number in E.164 format (e.g.,
+14155552671).
-
Secure Storage:
-
As done in Step 1.8, place these credentials only in your
.envfile:dotenv# .env PLIVO_AUTH_ID=YOUR_PLIVO_AUTH_ID PLIVO_AUTH_TOKEN=YOUR_PLIVO_AUTH_TOKEN PLIVO_SOURCE_NUMBER=+1XXXXXXXXXX # ... other vars -
Crucially: Ensure
.envis listed in your.gitignorefile to prevent accidentally committing secrets to version control. -
In production environments, use your hosting provider's mechanism for managing secrets (e.g., AWS Secrets Manager, Kubernetes Secrets, environment variables injected by the platform).
-
-
Fallback Mechanisms:
- The current implementation relies directly on Plivo. For high availability, consider:
- Retry Logic: Implement application-level retries with exponential backoff (see Section 5) for transient network errors before calling Plivo.
- Secondary Provider: Abstract the SMS sending logic further. If Plivo fails consistently (e.g., platform outage), you could potentially switch to a different SMS provider API. This adds significant complexity. For this guide, we focus on robust error handling with Plivo.
- The current implementation relies directly on Plivo. For high availability, consider:
5. Error Handling, Logging, and Retry Mechanisms
We've built basic error handling and logging. Let's enhance it.
-
Consistent Error Handling:
- Service Layer (
plivoService.js): Catches specific Plivo API errors, logs detailed information (including Plivo error messages/codes if available), and throws a standardizedErrorobject. - Route Layer (
broadcast.js): Catches errors propagated from the service layer. Logs the error with request context (request.id,request.log). Sends an appropriate HTTP status code (4xx for client errors it can detect, 5xx for server/dependency errors) and a generic error message to the client, avoiding leaking internal details. Fastify's schema validation automatically handles 400 errors for invalid input. - Global Error Handler (Optional): For unhandled exceptions, Fastify allows setting a global error handler using
fastify.setErrorHandler((error, request, reply) => { ... }). This can catch unexpected errors and ensure a consistent error response format.
- Service Layer (
-
Logging:
- Levels: Use
LOG_LEVELenv var (info,debug,warn,error,fatal) to control verbosity.infois good for production,debugfor development. - Context: Fastify automatically injects
reqIdinto logs. We passrequest.logto the service layer (sendBulkSms) so service-level logs also contain the request ID, making it easy to trace a single request's lifecycle through logs. - Format: As configured in Section 1,
pino-prettyis used for development readability. In production (NODE_ENV=production), output defaults to JSON logs for easier parsing by log aggregation systems (e.g., ELK stack, Datadog, Loki). - What to Log:
- Request received (
info) - Validation errors (
warnorinfo, Fastify handles response) - Calls to external services (Plivo) (
info/debug) - Successful external service responses (
info) - Errors from external services (
error) - Application logic errors (
error) - Key decision points or state changes (
debug)
- Request received (
- Levels: Use
-
Retry Mechanisms:
- Plivo Internal Retries: Plivo may have internal retries for certain transient issues, but don't rely solely on this.
- Application-Level Retries: For network errors or specific Plivo error codes indicating a temporary issue (e.g., rate limit exceeded, temporary gateway error), you can implement retries before calling
plivoClient.messages.create. Libraries likeasync-retryorp-retryare excellent for this.
Example using
async-retry(Conceptual):First, install the library:
bashnpm install async-retryThen, modify the service conceptually:
javascript// src/services/plivoService.js (Conceptual modification with async-retry) const retry = require('async-retry'); const plivo = require('plivo'); // Assuming plivo is already required let plivoClient; function initializePlivoClient(config, logger) { /* ... as before ... */ } async function sendBulkSms(destinations, message, config, logger) { initializePlivoClient(config, logger); // ... existing validation ... const uniqueDestinations = [...new Set(destinations)]; const plivoDestinationString = uniqueDestinations.join('<'); // ... MAX_RECIPIENTS_PER_REQUEST check ... logger.info( `Attempting to send bulk SMS via Plivo to ${uniqueDestinations.length} unique recipients (with retry logic).` ); try { // Wrap the Plivo call in retry logic const response = await retry( async (bail, attemptNumber) => { // bail is a function to stop retrying (e.g., for auth errors) // attemptNumber is the current retry attempt logger.debug(`Plivo API call attempt #${attemptNumber}`); try { const result = await plivoClient.messages.create( config.sourceNumber, plivoDestinationString, message // Optional params... ); logger.info(`Plivo API attempt #${attemptNumber} successful.`); return result; // Success, return result } catch (error) { logger.warn(`Plivo API attempt #${attemptNumber} failed: ${error.message}`); // Decide if the error is retryable // Example: Don't retry authentication errors (401) if (error.statusCode === 401 || (error.message && error.message.includes('Authentication credentials invalid'))) { logger.error('Authentication error. Not retrying.'); bail(new Error(`Plivo authentication failed: ${error.message}`)); // Stop retrying return; // Necessary after bail } // Example: Retry potentially transient errors (rate limit 429, server errors 5xx) if (error.statusCode === 429 || error.statusCode >= 500) { logger.warn(`Retryable error detected (status: ${error.statusCode}). Throwing to trigger retry.`); throw error; // Throw error to trigger retry by async-retry } // For other client-side errors (e.g., invalid number 400), don't retry logger.error(`Non-retryable Plivo error (status: ${error.statusCode || 'N/A'}). Not retrying.`); bail(new Error(`Non-retryable Plivo error: ${error.message}`)); return; // Necessary after bail } }, { retries: 3, // Number of retries (total attempts = retries + 1) factor: 2, // Exponential backoff factor minTimeout: 1000, // Initial delay ms maxTimeout: 5000, // Max delay ms onRetry: (error, attempt) => { logger.warn(`Retrying Plivo API call (attempt ${attempt}) due to: ${error.message}`); }, } ); logger.info( { message_uuid: response.messageUuid, api_id: response.apiId, recipient_count: uniqueDestinations.length, }, 'Plivo bulk message request successful after potential retries.', ); return response; } catch (error) { // This catches errors after all retries fail or if bail was called logger.error( { err: error, plivoError: error.message, statusCode: error.statusCode, }, 'Plivo API error during bulk message send after all retries.', ); throw new Error(`Failed to send bulk SMS via Plivo after retries: ${error.message}`); } } module.exports = { initializePlivoClient, sendBulkSms };- This adds complexity but improves resilience against temporary Plivo issues or network glitches. Carefully define which errors are retryable.
- Avoid retrying user errors (e.g., invalid phone numbers, authentication failures) – these won't succeed on retry and waste resources.
- For rate limiting (429), consider adding a longer delay or implementing a queue-based approach.
6. Deployment and Production Considerations
Before deploying your Fastify bulk SMS broadcaster to production, consider these important factors:
-
Environment Variables:
- Never hardcode credentials. Always use environment variables.
- Use your cloud provider's secret management service (AWS Secrets Manager, Azure Key Vault, Google Secret Manager, etc.).
- Set
NODE_ENV=productionto enable production logging (JSON format, nopino-pretty).
-
Security:
- Rate Limiting: Implement rate limiting on the
/broadcastendpoint to prevent abuse. Use plugins like@fastify/rate-limit. - Authentication: Add API key authentication or OAuth to protect your endpoint from unauthorized access.
- Input Sanitization: While schema validation helps, ensure message content is sanitized if storing or displaying it.
- HTTPS: Always use HTTPS in production to encrypt data in transit.
- Rate Limiting: Implement rate limiting on the
-
Monitoring and Observability:
- Health Checks: The
/healthendpoint should be monitored by your orchestration platform (Kubernetes, AWS ECS, etc.). - Metrics: Track API response times, error rates, and Plivo API success/failure rates.
- Alerting: Set up alerts for increased error rates or slow response times.
- Distributed Tracing: For complex systems, consider adding OpenTelemetry or similar tracing.
- Health Checks: The
-
Webhooks for Delivery Status:
- Implement a webhook endpoint to receive delivery status updates from Plivo.
- Store delivery status in a database for auditing and analytics.
- This requires exposing a publicly accessible endpoint (e.g.,
POST /api/v1/plivo/callback).
-
Scaling Considerations:
- Fastify is designed for high performance, but for very high volumes:
- Use a load balancer to distribute traffic across multiple instances.
- Consider using a message queue (RabbitMQ, AWS SQS, Redis) to decouple request handling from SMS sending.
- Implement database persistence for tracking message status and history.
- Fastify is designed for high performance, but for very high volumes:
-
Error Recovery:
- Consider implementing a dead-letter queue for failed messages.
- Implement automatic retry with exponential backoff for transient failures.
- Log all failures with sufficient context for debugging.
-
Testing:
- Write unit tests for your service layer (
plivoService.js). - Write integration tests for your routes.
- Use Plivo's test credentials or sandbox environment for testing.
- Write unit tests for your service layer (
-
Documentation:
- Document your API endpoints with examples.
- Provide clear error messages and status codes.
- Document rate limits and usage quotas.
-
Compliance:
- TCPA Compliance: Ensure you have proper consent before sending SMS messages to US numbers.
- 10DLC Registration: Register your use case with The Campaign Registry for A2P messaging in the US.
- Opt-out Handling: Implement automatic opt-out handling (STOP, UNSUBSCRIBE).
- GDPR/Privacy: Handle personal data (phone numbers) in compliance with applicable privacy laws.
Conclusion
You've successfully built a production-ready bulk SMS broadcasting service using Plivo, Fastify, and Node.js. This implementation provides:
- Efficient bulk messaging using Plivo's API (up to 1,000 recipients per request)
- Robust error handling with detailed logging
- Input validation using Fastify schemas
- Security best practices for credential management
- Production-ready logging with Pino
- Optional retry mechanisms for resilience
Next Steps
- Implement webhook handlers for delivery status tracking
- Add authentication and rate limiting
- Set up monitoring and alerting
- Implement batching logic for >1,000 recipients
- Add database persistence for message history
- Register for 10DLC if sending to US numbers
- Consider adding a message queue for high-volume scenarios
Additional Resources
Frequently Asked Questions
How to send bulk SMS with Fastify and Plivo?
Create a Fastify API endpoint that accepts an array of destination phone numbers and a message body. This endpoint uses the Plivo Node.js SDK to send a single API request to Plivo, broadcasting the SMS to all recipients simultaneously. This method improves efficiency compared to sending individual messages.
What is the benefit of using Plivo for bulk SMS?
Plivo's bulk messaging API allows sending a single message to thousands of recipients with one API call, reducing latency and overhead compared to looping through individual messages. This approach is crucial for efficient and scalable SMS broadcasting.
Why use Fastify for a bulk SMS broadcaster?
Fastify is a high-performance Node.js web framework known for its speed and extensibility. Its efficiency minimizes overhead, making it ideal for handling the demands of a high-throughput SMS broadcasting service.
When should I use bulk SMS broadcasting instead of individual messages?
Use bulk SMS when sending the same message to many recipients. This method is significantly more efficient and prevents hitting API rate limits, especially when dealing with thousands of numbers. Individual messages are better for personalized communications.
How to install necessary dependencies for this project?
Use `npm install fastify plivo dotenv fastify-env` to install the required packages. For development, add `npm install --save-dev nodemon pino-pretty` for automatic server restarts and readable logs. Remember to replace placeholders with your actual Plivo Auth ID, Token, and phone number.
What is the role of dotenv and fastify-env?
`dotenv` loads environment variables from a `.env` file, which stores sensitive credentials like your Plivo Auth ID and Token. `fastify-env` adds schema-based validation for these environment variables, ensuring required values are present and correctly formatted.
How to structure the project for a bulk SMS application?
Create directories for routes (`src/routes`), services (`src/services`), and a main server file (`src/server.js`). The routes define API endpoints, the services encapsulate interaction with Plivo, and the server file sets up the application. Add a `.env` file for credentials and a `.gitignore` for security best practices.
What are the prerequisites for building this broadcaster?
You need Node.js v18 or later, npm or yarn, a Plivo account, a Plivo phone number enabled for SMS, and your Plivo Auth ID and Auth Token. Remember to keep these credentials secure and never commit them to version control.
How can I improve error handling for the SMS broadcaster?
Implement detailed logging at both the service and route layers, including specific error messages from Plivo. For enhanced resilience, integrate retry mechanisms with exponential backoff for transient errors like network issues or rate limiting, using libraries like `async-retry`.
How to set up environment variables with Plivo credentials?
Create a `.env` file in your project root. Add your `PLIVO_AUTH_ID`, `PLIVO_AUTH_TOKEN`, and `PLIVO_SOURCE_NUMBER` (in E.164 format). Never commit this file to version control. Use environment variable management tools provided by your hosting platform for production environments.
How does the bulk SMS broadcaster handle duplicate phone numbers?
The `sendBulkSms` function uses a `Set` to automatically remove duplicate phone numbers from the provided list before sending the bulk message, ensuring that each recipient receives the SMS only once. This prevents sending unnecessary duplicates and is handled internally.
What is the `PLIVO_SOURCE_NUMBER` and where do I find it?
The `PLIVO_SOURCE_NUMBER` is your Plivo phone number that will be used to send the bulk SMS messages. You can find it in your Plivo console under Messaging -> Phone Numbers. Ensure this number is enabled for SMS and is in E.164 format (e.g. +14155552671).
Can I send bulk SMS to international numbers using Plivo?
Yes, Plivo supports sending SMS to international numbers, but ensure your Plivo phone number is set up to send to your target regions. When adding numbers to your list, make sure they are in international E.164 formatting. Consider checking Plivo's documentation for regional regulations and number formatting.
Why should I not commit my .env file to Git?
The .env file stores sensitive credentials like API keys and tokens. Committing it to Git poses a security risk, as it could expose those credentials to unauthorized users, including if your repository is public or shared. It is crucial for security to add .env to your .gitignore.