Build a Bulk SMS Broadcast System with Node.js, Express & Infobip API
Learn how to build a production-ready bulk SMS broadcast system using Node.js, Express, and Infobip API. Complete tutorial with code examples, security best practices, and deployment.
Learn how to build a production-ready bulk SMS broadcast system using Node.js, Express, and the Infobip API. This comprehensive guide covers project setup, core functionality, error handling, security, monitoring, and deployment best practices.
By the end of this tutorial, you'll have a functional Express application that sends SMS messages to multiple recipients using Infobip's bulk sending API – perfect for reliably broadcasting alerts, notifications, or marketing messages at scale.
Technologies Used:
Node.js: JavaScript runtime for server-side applications
Express: Minimal Node.js web framework
Infobip: Cloud communications platform with SMS API (via official Node.js SDK)
dotenv: Secure environment variable management
Prisma: Next-generation ORM for Node.js and TypeScript
PostgreSQL: Open-source relational database (MySQL and MariaDB are compatible alternatives with minor code changes)
Jest & Supertest: Unit and integration testing frameworks
Node.js LTS version (18.x or later recommended) and npm
PostgreSQL database (version 12 or later, local or cloud-based)
Basic knowledge of Node.js, Express, REST APIs, and relational databases
Registered phone number for your Infobip trial account (trial accounts have restrictions: typically 5–10 test numbers, limited sender IDs)
Time to complete: 60–90 minutes | Skill level: Intermediate
1. Setting Up Your Node.js Bulk SMS Project
Initialize your Node.js project, configure the directory structure, and install the required dependencies including the Infobip SDK, Express, and Prisma ORM.
Create Project Directory:
Open your terminal and create a new project directory.
Bash
mkdir infobip-bulk-sms
cd infobip-bulk-sms
Initialize Node.js Project:
Initialize npm with default settings using the -y flag.
Bash
npm init -y
Install Dependencies:
Install Express, the Infobip SDK, dotenv, Prisma, and testing tools. These versions are compatible as of January 2025.
@infobip-api/sdk: Official Infobip SDK for Node.js
dotenv: Loads environment variables from .env
pg: PostgreSQL client for Node.js (used by Prisma)
@prisma/client: Prisma's database client
prisma: Prisma CLI for migrations and studio
nodemon: Automatically restarts server during development
jest: Testing framework
supertest: HTTP assertion library for testing
Set up Project Structure:
Create this directory structure:
Text
infobip-bulk-sms/
├── prisma/
├── src/
│ ├── controllers/
│ ├── routes/
│ ├── services/
│ ├── utils/
│ ├── middleware/
│ └── app.js # Main Express application setup
├── tests/
│ ├── integration/
│ └── unit/
├── .env # Environment variables (DO NOT COMMIT)
├── .gitignore
├── package.json
└── server.js # Entry point to start the server
Configure nodemon:
Add development scripts to package.json.
JSON
// package.json{// ... other fields"scripts":{"start":"node server.js","dev":"nodemon server.js","test":"jest"// Add prisma commands later}// ... rest of the file}
Initialize Prisma:
Set up Prisma with PostgreSQL as the provider.
Bash
npx prisma init --datasource-provider postgresql
This creates the prisma/ directory (if not already present) and a schema.prisma file, and updates your .env file with a DATABASE_URL placeholder.
Configure Environment Variables:
Create a .env file in the project root. Add this file to .gitignore immediately to prevent exposing credentials.
Text
# .env
# Database
DATABASE_URL="postgresql://<user>:<password>@<host>:<port>/<database>?schema=public"
# Infobip Credentials
INFOBIP_BASE_URL="YOUR_INFOBIP_BASE_URL" # Find this in your Infobip account API section
INFOBIP_API_KEY="YOUR_INFOBIP_API_KEY" # Find this in your Infobip account API section
# Application Settings
PORT=3000
NODE_ENV=development # or production
Replace <...> placeholders in DATABASE_URL with your PostgreSQL credentials.
Find your INFOBIP_BASE_URL and INFOBIP_API_KEY in the Infobip portal: Account Settings → API Keys (format: xxxxx.api.infobip.com for base URL).
Production note: Use your hosting platform's environment variable system (AWS Secrets Manager, Heroku Config Vars, Kubernetes Secrets) instead of .env files in production for enhanced security.
Configure .gitignore:
Create a .gitignore file in the root directory:
2. Implementing Infobip SMS Service with Node.js SDK
Encapsulate all Infobip API interactions in a dedicated service module using the official Infobip Node.js SDK. This pattern separates business logic from API integration for better maintainability.
Create Infobip Service (src/services/infobipService.js):
JavaScript
// src/services/infobipService.jsconst { Infobip, AuthType } = require('@infobip-api/sdk');
const logger = require('../utils/logger'); // We'll create this logger later// Load environment variablesrequire('dotenv').config();
if (!process.env.INFOBIP_BASE_URL || !process.env.INFOBIP_API_KEY) {
logger.error('Infobip Base URL or API Key not configured in .env file.');
thrownewError('Infobip credentials missing.');
}
// Initialize Infobip client with API Key authenticationconst infobipClient = newInfobip({
baseUrl: process.env.INFOBIP_BASE_URL,
apiKey: process.env.INFOBIP_API_KEY,
authType: AuthType.ApiKey,
});
/**
* Sends the same SMS message to multiple recipients using Infobip's bulk send feature.
*
* @param {string[]} phoneNumbers - Array of recipient phone numbers in E.164 format (e.g., '+447123456789').
* @param {string} messageText - SMS message text (max 160 characters for standard GSM-7; 70 for Unicode/emojis; longer messages split into segments).
* @param {string} [sender='InfoSMS'] - Sender ID (alphanumeric, max 11 characters; numeric IDs have different limits). Registration required in some countries (US, India). Check [Infobip sender ID rules](https://www.infobip.com/docs/sms/get-started).
* @returns {Promise<object>} - The response object from the Infobip API, including the bulkId.
* @throws {Error} - Throws an error if the API call fails.
*/constsendBulkSms = async (phoneNumbers, messageText, sender = 'InfoSMS') => {
if (!phoneNumbers || phoneNumbers.length === 0) {
thrownewError('Recipient phone numbers array cannot be empty.');
}
if (!messageText) {
thrownewError('Message text cannot be empty.');
}
// Map phone numbers to Infobip destination formatconst destinations = phoneNumbers.map(number => ({ to: number }));
// Build message payloadconst payload = {
messages: [
{
from: sender,
destinations: destinations, // Array of recipient objectstext: messageText,
},
// Add more message objects here for different texts/senders if needed
],
// Optional: bulkId: 'YOUR_CUSTOM_BULK_ID'
};
try {
logger.info(`Attempting to send bulk SMS to ${phoneNumbers.length} recipients.`);
logger.debug('Infobip Payload:', payload); // Log payload only in debug modeconst infobipResponse = await infobipClient.channels.sms.send(payload);
const { data } = infobipResponse; // Contains bulkId and message array with individual messageIds/statuses
logger.info(`Infobip bulk SMS request successful. Bulk ID: ${data.bulkId}`);
logger.debug('Infobip Response:', data);
return {
bulkId: data.bulkId,
messages: data.messages.map(msg => ({
messageId: msg.messageId,
to: msg.to,
status: msg.status.name,
statusGroup: msg.status.groupName,
})),
};
} catch (error) {
logger.error('Infobip API Error:', error.response ? error.response.data : error.message);
thrownewError(`Failed to send bulk SMS via Infobip: ${error.message}`);
}
};
module.exports = {
sendBulkSms,
};
How it works:
Import and initialize: Load the Infobip SDK, validate credentials, and create the API client with API Key authentication.
sendBulkSms function: Accepts phone numbers, message text, and optional sender ID. Maps phone numbers to destination objects, constructs the API payload, and sends the request.
Response handling: Returns structured data with bulkId (tracks the entire batch) and individual message statuses.
Error handling: Catches API errors and throws descriptive exceptions.
3. Building the Express REST API for Bulk SMS
Create Express routes and controllers to expose the bulk SMS sending functionality via a RESTful API endpoint. This layer handles HTTP requests and orchestrates the SMS broadcast workflow.
Test the POST /api/v1/broadcasts endpoint using cURL or Postman.
Method:POST
URL:http://localhost:3000/api/v1/broadcasts
Headers:Content-Type: application/json
Body (JSON):
JSON
{"message":"Hello from our bulk broadcast system! Special offer inside.","recipients":["+15551112222","+447123456789","+4915123456789"],"sender":"MyAppAlerts"}
Omit recipients to send to all active recipients in the database (once database is configured).
{"error":"Invalid phone number format detected. Use E.164 format (e.g., +15551234567).","invalidNumbers":["12345"]}
4. Infobip API Integration: Credentials and Configuration
You already initialized the Infobip client in infobipService.js using environment variables. Here's how to obtain these credentials.
Log in to Infobip: Access your Infobip account dashboard.
Navigate to API Keys: Look for "API Keys," "Developer Tools," or similar in the navigation menu.
Create/View API Key: Create a new API key or view an existing one. Ensure it has permissions for sending SMS messages.
Copy API Key: Securely copy the generated API Key. This is your INFOBIP_API_KEY.
Find Base URL: Your account-specific Base URL is displayed near the API Key or in the API settings/documentation section. It looks like xxxxx.api.infobip.com. This is your INFOBIP_BASE_URL.
Update .env: Paste the copied values into your .env file.
Text
# .env
# ... other variables
INFOBIP_BASE_URL="YOUR_COPIED_BASE_URL"
INFOBIP_API_KEY="YOUR_COPIED_API_KEY"
# ...
Restart Application: Restart your application to load the new environment variables. nodemon usually handles this automatically.
Security Note: Never commit your .env file or hardcode API keys in source code. Use environment variables for all sensitive credentials. Rotate API keys regularly – generate new keys every 90 days and revoke old ones immediately.
API Rate Limits: Infobip enforces rate limits based on your account tier. Free tier typically allows 10–20 requests per second. Monitor your usage via the Infobip dashboard and upgrade your plan if needed.
5. Error Handling and Retry Logic for SMS Delivery
Robust error handling and logging are crucial for production systems.
Setup Logger (src/utils/logger.js):
This simple console-based logger is suitable for development. For production, use winston or pino for advanced features like log levels, file transport, structured logging, and external log aggregation (Datadog, Splunk).
Centralized Error Handler (src/middleware/errorHandler.js):
This middleware catches errors passed via next(error) and sends a structured response.
JavaScript
// src/middleware/errorHandler.jsconst logger = require('../utils/logger');
consterrorHandler = (err, req, res, next) => {
// Log the full error stack trace for debugging
logger.error(err.stack);
// Determine status codeconst statusCode = err.statusCode || res.statusCode || 500;
const finalStatusCode = statusCode < 400 ? 500 : statusCode;
// Customize error response based on NODE_ENVconst response = {
message: err.message || 'An unexpected error occurred.',
// Include stack trace only in development mode
...(process.env.NODE_ENV !== 'production' && { stack: err.stack }),
// Include custom error details if present
...(err.details && { details: err.details }),
};
res.status(finalStatusCode).json(response);
};
module.exports = errorHandler;
Make sure this middleware is added last in src/app.js, after all routes.
Using the Error Handler:
In controllers or services, wrap asynchronous operations in try...catch blocks. If an error occurs, call next(error) to pass it to the centralized handler.
JavaScript
// Example in src/controllers/broadcastController.jstry {
// ... async operations ...const result = await infobipService.sendBulkSms(/* ... */);
// ...
res.status(202).json(/* ... */);
} catch (error) {
logger.error(`Error in createBroadcast: ${error.message}`);
next(error); // Pass to centralized handler
}
Retry Mechanisms:
Network issues or temporary Infobip API glitches might occur. Implement retries to improve reliability. Use async-retry for exponential backoff and jitter.
Install:npm install async-retry
Example Usage (in infobipService.js):
JavaScript
// src/services/infobipService.jsconst retry = require('async-retry');
// ... other imports (Infobip, logger, etc.)constsendBulkSms = async (phoneNumbers, messageText, sender = 'InfoSMS') => {
// ... input validation ...// Construct payload outside the retry loopconst destinations = phoneNumbers.map(number => ({ to: number }));
const payload = { messages: [{ from: sender, destinations, text: messageText }] };
// Wrap the API call with retry logicreturnawaitretry(
async (bail, attemptNumber) => {
logger.info(`Attempt ${attemptNumber} to send bulk SMS via Infobip...`);
try {
const infobipResponse = await infobipClient.channels.sms.send(payload);
const { data } = infobipResponse;
logger.info(`Infobip bulk SMS request successful on attempt ${attemptNumber}. Bulk ID: ${data.bulkId}`);
logger.debug('Infobip Response:', data);
return {
bulkId: data.bulkId,
messages: data.messages.map(msg => ({
messageId: msg.messageId,
to: msg.to,
status: msg.status.name,
statusGroup: msg.status.groupName,
})),
};
} catch (error) {
logger.warn(`Attempt ${attemptNumber} failed: ${error.message}`);
if (error.response) {
const status = error.response.status;
// Bail on client errors (4xx) - retrying won't fix theseif (status === 401 || status === 400 || status === 403 || status === 404 || status === 422) {
logger.error(`Unrecoverable Infobip API error (${status}). Bailing out.`);
bail(newError(`Infobip API Error (${status}): ${error.message || 'Client Error'}`));
return;
}
// Retry on server errors (5xx) and network issues
}
throw error;
}
},
{
retries: 3, // Total attempts = retries + 1 = 4factor: 2, // Exponential backoff factorminTimeout: 1000, // Initial delay: 1 secondmaxTimeout: 5000, // Maximum delay: 5 secondsrandomize: true, // Add jitter to prevent thundering herdonRetry: (error, attempt) => {
logger.warn(`Retrying Infobip call (attempt ${attempt}) due to error: ${error.message}`);
},
}
);
};
module.exports = { sendBulkSms };
Idempotency Note: Infobip's API is idempotent when you provide a custom bulkId. If you retry with the same bulkId, Infobip won't send duplicate messages. To ensure idempotency, generate a unique bulkId (e.g., using UUID) before the retry loop and include it in the payload.
Circuit Breaker Pattern: For production systems, implement a circuit breaker (using libraries like opossum) to prevent cascading failures. A circuit breaker stops calling the Infobip API after repeated failures and retries after a cooldown period.
6. Database Schema with Prisma and PostgreSQL
Use Prisma to define your database schema and interact with the database.
Define Schema (prisma/schema.prisma):
Update the schema file to include models for Recipient and Broadcast.
Prisma
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Recipient {
id String @id @default(cuid())
phone String @unique // Ensure phone numbers are unique
name String? // Optional recipient name
isActive Boolean @default(true) // Handle opt-outs or inactive users
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Broadcast {
id String @id @default(cuid())
message String
status String // e.g., PENDING, SUBMITTED, PROCESSING, COMPLETED, FAILED
infobipBulkId String? @unique // Store the bulk ID from Infobip
recipientCount Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Optional: Add relation to specific recipients for detailed tracking
// recipients BroadcastRecipient[]
}
// Optional: Join table for many-to-many relationship if tracking per-recipient status
// model BroadcastRecipient {
// id String @id @default(cuid())
// broadcast Broadcast @relation(fields: [broadcastId], references: [id])
// broadcastId String
// recipient Recipient @relation(fields: [recipientId], references: [id])
// recipientId String
// messageId String? // Store individual message ID from Infobip
// status String? // Store individual message status
// submittedAt DateTime @default(now())
// updatedAt DateTime @updatedAt
//
// @@unique([broadcastId, recipientId]) // Ensure a recipient is only listed once per broadcast
// }
Run Migrations:
Generate and apply the migration to create the database tables.
Use the Infobip API with the Node.js SDK and Express to create a system that can handle sending a single SMS message to many recipients. This guide provides a comprehensive walkthrough, explaining each step in detail, from project setup and core functions to error handling and deployment techniques. You'll build an Express application that accepts requests to send SMS messages to a large list of recipients via Infobip's bulk sending feature.
Infobip is a cloud communications platform offering APIs for various channels like SMS, voice, and chat apps. Its robust SMS API, accessible via the official Node.js SDK, is ideal for sending large volumes of SMS messages reliably and quickly. This guide utilizes Infobip for broadcasting alerts, information, or marketing messages.
Start by creating a new directory, initializing a Node.js project with npm init -y, and installing necessary dependencies like express, @infobip-api/sdk, dotenv, pg, @prisma/client, and development dependencies including prisma, nodemon, jest, and supertest. Structure your project with directories for controllers, routes, services, utils, middleware, and tests.
The guide utilizes PostgreSQL as its relational database and Prisma as an ORM for database interactions. However, the core concepts can be adapted to other ORMs supported by Prisma (like TypeORM, Sequelize) or databases (e.g., MySQL, MariaDB) with necessary code modifications. Remember to configure your database credentials in the .env file.
The Infobip Node.js SDK simplifies interaction with the Infobip API. It handles authentication, request formatting, and response parsing, making it easier to integrate Infobip services into your Node.js application. The SDK is initialized with your Infobip Base URL and API key, which are stored as environment variables for security.
Store your INFOBIP_BASE_URL and INFOBIP_API_KEY in a .env file. This file should be added to your .gitignore to prevent it from being committed to version control. While suitable for development, for production environments, inject environment variables directly through your hosting platform or CI/CD pipeline for enhanced security.
Prisma acts as an Object-Relational Mapper (ORM), simplifying database interactions. It allows you to define your database schema using the Prisma Schema Language in schema.prisma and interact with the database using JavaScript. This guide uses Prisma with PostgreSQL but can be adapted for other databases and ORMs.
Organize your project with a clear directory structure. This typically includes separate folders for controllers, routes, services, utils (like logging), and middleware. The src/app.js file handles the main Express application setup, while server.js serves as the entry point for starting the server.
Dotenv is a crucial package for managing environment variables, which hold sensitive information like API keys and database credentials. By loading these variables from a .env file, you keep them separate from your codebase, improving security. Remember to never commit the .env file to your repository.
Create a centralized error handler middleware that catches errors passed through next(error) and returns a structured error response. This simplifies error management and helps provide user-friendly error messages. Use try-catch blocks around async operations and log errors using a logger for debugging and tracking.
The guide provides a simple logger implementation using console. However, for production, libraries like winston or pino are recommended, offering more advanced logging features such as log levels, log rotation, and structured logging.
The system follows a client-server architecture: the client (e.g., Postman, UI) sends requests to the Node.js/Express API layer. The API layer interacts with both the Infobip service (via the Node.js SDK) and the PostgreSQL database (using Prisma). The Infobip service then communicates with the Infobip API to send the SMS messages.
The sendBulkSms function in the infobipService.js accepts an optional sender parameter. Pass your desired alphanumeric sender ID to this parameter when calling the function. Be sure to consult Infobip's rules regarding sender IDs to ensure compliance.
Use a tool like Postman to send POST requests to the /api/v1/broadcasts endpoint. Include a JSON body with the message and an array of recipients. Alternatively, you can omit recipients to send to all active recipients in the database.
You need a free or paid Infobip account, Node.js and npm installed, access to a PostgreSQL database, and basic knowledge of Node.js, Express, APIs, and databases. For testing with a free trial, ensure you have a registered phone number linked to your Infobip account.
Learn how to build a production-ready bulk SMS broadcast system using Node.js, Express, and the Infobip API. This comprehensive guide covers project setup, core functionality, error handling, security, monitoring, and deployment best practices.
By the end of this tutorial, you'll have a functional Express application that sends SMS messages to multiple recipients using Infobip's bulk sending API – perfect for reliably broadcasting alerts, notifications, or marketing messages at scale.
Technologies Used:
System Architecture:
Prerequisites:
Time to complete: 60–90 minutes | Skill level: Intermediate
1. Setting Up Your Node.js Bulk SMS Project
Initialize your Node.js project, configure the directory structure, and install the required dependencies including the Infobip SDK, Express, and Prisma ORM.
Create Project Directory: Open your terminal and create a new project directory.
mkdir infobip-bulk-sms cd infobip-bulk-smsInitialize Node.js Project: Initialize npm with default settings using the
-yflag.Install Dependencies: Install Express, the Infobip SDK, dotenv, Prisma, and testing tools. These versions are compatible as of January 2025.
express: Web framework@infobip-api/sdk: Official Infobip SDK for Node.jsdotenv: Loads environment variables from.envpg: PostgreSQL client for Node.js (used by Prisma)@prisma/client: Prisma's database clientprisma: Prisma CLI for migrations and studionodemon: Automatically restarts server during developmentjest: Testing frameworksupertest: HTTP assertion library for testingSet up Project Structure: Create this directory structure:
Configure
nodemon: Add development scripts topackage.json.// package.json { // ... other fields "scripts": { "start": "node server.js", "dev": "nodemon server.js", "test": "jest" // Add prisma commands later } // ... rest of the file }Initialize Prisma: Set up Prisma with PostgreSQL as the provider.
This creates the
prisma/directory (if not already present) and aschema.prismafile, and updates your.envfile with aDATABASE_URLplaceholder.Configure Environment Variables: Create a
.envfile in the project root. Add this file to.gitignoreimmediately to prevent exposing credentials.<...>placeholders inDATABASE_URLwith your PostgreSQL credentials.INFOBIP_BASE_URLandINFOBIP_API_KEYin the Infobip portal: Account Settings → API Keys (format:xxxxx.api.infobip.comfor base URL)..envfiles in production for enhanced security.Configure
.gitignore: Create a.gitignorefile in the root directory:2. Implementing Infobip SMS Service with Node.js SDK
Encapsulate all Infobip API interactions in a dedicated service module using the official Infobip Node.js SDK. This pattern separates business logic from API integration for better maintainability.
Create Infobip Service (
src/services/infobipService.js):// src/services/infobipService.js const { Infobip, AuthType } = require('@infobip-api/sdk'); const logger = require('../utils/logger'); // We'll create this logger later // Load environment variables require('dotenv').config(); if (!process.env.INFOBIP_BASE_URL || !process.env.INFOBIP_API_KEY) { logger.error('Infobip Base URL or API Key not configured in .env file.'); throw new Error('Infobip credentials missing.'); } // Initialize Infobip client with API Key authentication const infobipClient = new Infobip({ baseUrl: process.env.INFOBIP_BASE_URL, apiKey: process.env.INFOBIP_API_KEY, authType: AuthType.ApiKey, }); /** * Sends the same SMS message to multiple recipients using Infobip's bulk send feature. * * @param {string[]} phoneNumbers - Array of recipient phone numbers in E.164 format (e.g., '+447123456789'). * @param {string} messageText - SMS message text (max 160 characters for standard GSM-7; 70 for Unicode/emojis; longer messages split into segments). * @param {string} [sender='InfoSMS'] - Sender ID (alphanumeric, max 11 characters; numeric IDs have different limits). Registration required in some countries (US, India). Check [Infobip sender ID rules](https://www.infobip.com/docs/sms/get-started). * @returns {Promise<object>} - The response object from the Infobip API, including the bulkId. * @throws {Error} - Throws an error if the API call fails. */ const sendBulkSms = async (phoneNumbers, messageText, sender = 'InfoSMS') => { if (!phoneNumbers || phoneNumbers.length === 0) { throw new Error('Recipient phone numbers array cannot be empty.'); } if (!messageText) { throw new Error('Message text cannot be empty.'); } // Map phone numbers to Infobip destination format const destinations = phoneNumbers.map(number => ({ to: number })); // Build message payload const payload = { messages: [ { from: sender, destinations: destinations, // Array of recipient objects text: messageText, }, // Add more message objects here for different texts/senders if needed ], // Optional: bulkId: 'YOUR_CUSTOM_BULK_ID' }; try { logger.info(`Attempting to send bulk SMS to ${phoneNumbers.length} recipients.`); logger.debug('Infobip Payload:', payload); // Log payload only in debug mode const infobipResponse = await infobipClient.channels.sms.send(payload); const { data } = infobipResponse; // Contains bulkId and message array with individual messageIds/statuses logger.info(`Infobip bulk SMS request successful. Bulk ID: ${data.bulkId}`); logger.debug('Infobip Response:', data); return { bulkId: data.bulkId, messages: data.messages.map(msg => ({ messageId: msg.messageId, to: msg.to, status: msg.status.name, statusGroup: msg.status.groupName, })), }; } catch (error) { logger.error('Infobip API Error:', error.response ? error.response.data : error.message); throw new Error(`Failed to send bulk SMS via Infobip: ${error.message}`); } }; module.exports = { sendBulkSms, };How it works:
sendBulkSmsfunction: Accepts phone numbers, message text, and optional sender ID. Maps phone numbers to destination objects, constructs the API payload, and sends the request.bulkId(tracks the entire batch) and individual message statuses.3. Building the Express REST API for Bulk SMS
Create Express routes and controllers to expose the bulk SMS sending functionality via a RESTful API endpoint. This layer handles HTTP requests and orchestrates the SMS broadcast workflow.
Create Broadcast Controller (
src/controllers/broadcastController.js):// src/controllers/broadcastController.js const infobipService = require('../services/infobipService'); const prisma = require('../utils/prismaClient'); // We'll create this later const logger = require('../utils/logger'); /** * Handle bulk SMS broadcast requests. * Expects { message: string, recipients: string[] } in request body. * Fetches recipients from database if `recipients` is omitted. */ const createBroadcast = async (req, res, next) => { const { message, recipients, sender } = req.body; // sender is optional // Input validation here is basic. Use express-validator middleware for production (see Section 7). // This controller orchestrates: request handling → database queries → Infobip service → database logging. if (!message) { return res.status(400).json({ error: 'Message content is required.' }); } let targetRecipients = recipients; try { // Fetch active recipients from database if not provided in request if (!targetRecipients || !Array.isArray(targetRecipients)) { logger.info('Recipient list not provided in request. Fetching active recipients from DB.'); const activeRecipients = await prisma.recipient.findMany({ where: { isActive: true }, select: { phone: true }, // Select only the phone number }); if (!activeRecipients || activeRecipients.length === 0) { return res.status(400).json({ error: 'No recipients provided and no active recipients found in the database.' }); } targetRecipients = activeRecipients.map(r => r.phone); logger.info(`Found ${targetRecipients.length} active recipients in DB.`); } if (targetRecipients.length === 0) { return res.status(400).json({ error: 'Recipient list cannot be empty.' }); } // Validate E.164 phone number format (basic regex; use google-libphonenumber for production) // Production example: const phoneUtil = require('google-libphonenumber').PhoneNumberUtil.getInstance(); // phoneUtil.isValidNumber(phoneUtil.parse(num, 'ZZ')) const invalidNumbers = targetRecipients.filter(num => !/^\+?[1-9]\d{1,14}$/.test(num)); if (invalidNumbers.length > 0) { logger.warn(`Request contains invalid phone numbers: ${invalidNumbers.join(', ')}`); return res.status(400).json({ error: 'Invalid phone number format detected. Use E.164 format (e.g., +15551234567).', invalidNumbers: invalidNumbers, }); } // Send bulk message via Infobip service const result = await infobipService.sendBulkSms(targetRecipients, message, sender); // Save broadcast record to database const broadcastRecord = await prisma.broadcast.create({ data: { message: message, status: 'SUBMITTED', // Initial status infobipBulkId: result.bulkId, recipientCount: targetRecipients.length, // Link recipients explicitly for detailed tracking in production } }); logger.info(`Broadcast record created with ID: ${broadcastRecord.id} and Bulk ID: ${result.bulkId}`); // Return 202 Accepted (async operation) res.status(202).json({ message: 'Broadcast request accepted successfully.', broadcastId: broadcastRecord.id, infobipBulkId: result.bulkId, submittedMessages: result.messages.length, // Individual message statuses available in result.messages }); } catch (error) { logger.error(`Failed to create broadcast: ${error.message}`); next(error); // Pass to centralized error handler } }; // TODO: Add getBroadcastStatus controller for status retrieval module.exports = { createBroadcast, };Create Broadcast Routes (
src/routes/broadcastRoutes.js):// src/routes/broadcastRoutes.js const express = require('express'); const broadcastController = require('../controllers/broadcastController'); // TODO: Import validation middleware (See Section 7) // const { validateBroadcastRequest } = require('../middleware/validators'); const router = express.Router(); // POST /api/v1/broadcasts - Create new broadcast router.post('/', /* validateBroadcastRequest, */ broadcastController.createBroadcast); // TODO: GET /api/v1/broadcasts/:id - Get broadcast status // router.get('/:id', broadcastController.getBroadcastStatus); module.exports = router;Set up Express App (
src/app.js):// src/app.js const express = require('express'); const dotenv = require('dotenv'); const broadcastRoutes = require('./routes/broadcastRoutes'); const logger = require('./utils/logger'); const errorHandler = require('./middleware/errorHandler'); // TODO: Import security middleware (See Section 7) // const rateLimiter = require('./middleware/rateLimiter'); // const helmet = require('helmet'); dotenv.config(); const app = express(); // --- Middleware --- // app.use(helmet()); // TODO: Add security headers (See Section 7) app.use(express.json()); app.use(express.urlencoded({ extended: true })); // Request logging app.use((req, res, next) => { logger.info(`${req.method} ${req.url}`); next(); }); // app.use(rateLimiter); // TODO: Add rate limiting (See Section 7) // --- Routes --- app.get('/health', (req, res) => res.status(200).json({ status: 'UP' })); app.use('/api/v1/broadcasts', broadcastRoutes); // --- Error Handling --- app.use((req, res, next) => { const error = new Error(`Not Found - ${req.originalUrl}`); res.status(404); next(error); }); // Centralized error handler (must be last) app.use(errorHandler); module.exports = app;Create Server Entry Point (
server.js):// server.js const app = require('./src/app'); const logger = require('./src/utils/logger'); const PORT = process.env.PORT || 3000; const server = app.listen(PORT, () => { logger.info(`Server running in ${process.env.NODE_ENV} mode on port ${PORT}`); }); // Graceful Shutdown Handling process.on('SIGTERM', () => { logger.info('SIGTERM signal received: closing HTTP server'); server.close(() => { logger.info('HTTP server closed'); process.exit(0); }); }); process.on('SIGINT', () => { logger.info('SIGINT signal received: closing HTTP server'); server.close(() => { logger.info('HTTP server closed'); process.exit(0); }); }); // Handle unhandled promise rejections process.on('unhandledRejection', (err, promise) => { logger.error('Unhandled Rejection at:', promise, 'reason:', err); server.close(() => process.exit(1)); });API Endpoint Example:
Test the
POST /api/v1/broadcastsendpoint using cURL or Postman.Method:
POSTURL:
http://localhost:3000/api/v1/broadcastsHeaders:
Content-Type: application/jsonBody (JSON):
{ "message": "Hello from our bulk broadcast system! Special offer inside.", "recipients": ["+15551112222", "+447123456789", "+4915123456789"], "sender": "MyAppAlerts" }recipientsto send to all active recipients in the database (once database is configured).cURL Example:
curl -X POST http://localhost:3000/api/v1/broadcasts \ -H "Content-Type: application/json" \ -d '{ "message": "Hello from our bulk broadcast system!", "recipients": ["+15551112222", "+447123456789"], "sender": "MyAppAlerts" }'Example Success Response (202 Accepted):
{ "message": "Broadcast request accepted successfully.", "broadcastId": "clxyzabc1234567890def", "infobipBulkId": "2034072219640523072", "submittedMessages": 3 }Example Error Response (400 Bad Request):
{ "error": "Invalid phone number format detected. Use E.164 format (e.g., +15551234567).", "invalidNumbers": [ "12345" ] }4. Infobip API Integration: Credentials and Configuration
You already initialized the Infobip client in
infobipService.jsusing environment variables. Here's how to obtain these credentials.Log in to Infobip: Access your Infobip account dashboard.
Navigate to API Keys: Look for "API Keys," "Developer Tools," or similar in the navigation menu.
Create/View API Key: Create a new API key or view an existing one. Ensure it has permissions for sending SMS messages.
Copy API Key: Securely copy the generated API Key. This is your
INFOBIP_API_KEY.Find Base URL: Your account-specific Base URL is displayed near the API Key or in the API settings/documentation section. It looks like
xxxxx.api.infobip.com. This is yourINFOBIP_BASE_URL.Update
.env: Paste the copied values into your.envfile.Restart Application: Restart your application to load the new environment variables.
nodemonusually handles this automatically.Security Note: Never commit your
.envfile or hardcode API keys in source code. Use environment variables for all sensitive credentials. Rotate API keys regularly – generate new keys every 90 days and revoke old ones immediately.API Rate Limits: Infobip enforces rate limits based on your account tier. Free tier typically allows 10–20 requests per second. Monitor your usage via the Infobip dashboard and upgrade your plan if needed.
5. Error Handling and Retry Logic for SMS Delivery
Robust error handling and logging are crucial for production systems.
Setup Logger (
src/utils/logger.js): This simple console-based logger is suitable for development. For production, usewinstonorpinofor advanced features like log levels, file transport, structured logging, and external log aggregation (Datadog, Splunk).// src/utils/logger.js const logger = { info: (...args) => { console.log(`[INFO] ${new Date().toISOString()}:`, ...args); }, warn: (...args) => { console.warn(`[WARN] ${new Date().toISOString()}:`, ...args); }, error: (...args) => { console.error(`[ERROR] ${new Date().toISOString()}:`, ...args); }, debug: (...args) => { // Only log debug messages if NODE_ENV is 'development' if (process.env.NODE_ENV === 'development') { console.debug(`[DEBUG] ${new Date().toISOString()}:`, ...args); } }, }; module.exports = logger;Production Logger Example (winston):
// Install: npm install winston const winston = require('winston'); const logger = winston.createLogger({ level: process.env.LOG_LEVEL || 'info', format: winston.format.combine( winston.format.timestamp(), winston.format.errors({ stack: true }), winston.format.json() ), transports: [ new winston.transports.File({ filename: 'error.log', level: 'error' }), new winston.transports.File({ filename: 'combined.log' }), new winston.transports.Console({ format: winston.format.combine( winston.format.colorize(), winston.format.simple() ), }), ], }); module.exports = logger;Centralized Error Handler (
src/middleware/errorHandler.js): This middleware catches errors passed vianext(error)and sends a structured response.// src/middleware/errorHandler.js const logger = require('../utils/logger'); const errorHandler = (err, req, res, next) => { // Log the full error stack trace for debugging logger.error(err.stack); // Determine status code const statusCode = err.statusCode || res.statusCode || 500; const finalStatusCode = statusCode < 400 ? 500 : statusCode; // Customize error response based on NODE_ENV const response = { message: err.message || 'An unexpected error occurred.', // Include stack trace only in development mode ...(process.env.NODE_ENV !== 'production' && { stack: err.stack }), // Include custom error details if present ...(err.details && { details: err.details }), }; res.status(finalStatusCode).json(response); }; module.exports = errorHandler;src/app.js, after all routes.Using the Error Handler: In controllers or services, wrap asynchronous operations in
try...catchblocks. If an error occurs, callnext(error)to pass it to the centralized handler.// Example in src/controllers/broadcastController.js try { // ... async operations ... const result = await infobipService.sendBulkSms(/* ... */); // ... res.status(202).json(/* ... */); } catch (error) { logger.error(`Error in createBroadcast: ${error.message}`); next(error); // Pass to centralized handler }Retry Mechanisms: Network issues or temporary Infobip API glitches might occur. Implement retries to improve reliability. Use
async-retryfor exponential backoff and jitter.Install:
npm install async-retryExample Usage (in
infobipService.js):// src/services/infobipService.js const retry = require('async-retry'); // ... other imports (Infobip, logger, etc.) const sendBulkSms = async (phoneNumbers, messageText, sender = 'InfoSMS') => { // ... input validation ... // Construct payload outside the retry loop const destinations = phoneNumbers.map(number => ({ to: number })); const payload = { messages: [{ from: sender, destinations, text: messageText }] }; // Wrap the API call with retry logic return await retry( async (bail, attemptNumber) => { logger.info(`Attempt ${attemptNumber} to send bulk SMS via Infobip...`); try { const infobipResponse = await infobipClient.channels.sms.send(payload); const { data } = infobipResponse; logger.info(`Infobip bulk SMS request successful on attempt ${attemptNumber}. Bulk ID: ${data.bulkId}`); logger.debug('Infobip Response:', data); return { bulkId: data.bulkId, messages: data.messages.map(msg => ({ messageId: msg.messageId, to: msg.to, status: msg.status.name, statusGroup: msg.status.groupName, })), }; } catch (error) { logger.warn(`Attempt ${attemptNumber} failed: ${error.message}`); if (error.response) { const status = error.response.status; // Bail on client errors (4xx) - retrying won't fix these if (status === 401 || status === 400 || status === 403 || status === 404 || status === 422) { logger.error(`Unrecoverable Infobip API error (${status}). Bailing out.`); bail(new Error(`Infobip API Error (${status}): ${error.message || 'Client Error'}`)); return; } // Retry on server errors (5xx) and network issues } throw error; } }, { retries: 3, // Total attempts = retries + 1 = 4 factor: 2, // Exponential backoff factor minTimeout: 1000, // Initial delay: 1 second maxTimeout: 5000, // Maximum delay: 5 seconds randomize: true, // Add jitter to prevent thundering herd onRetry: (error, attempt) => { logger.warn(`Retrying Infobip call (attempt ${attempt}) due to error: ${error.message}`); }, } ); }; module.exports = { sendBulkSms };Idempotency Note: Infobip's API is idempotent when you provide a custom
bulkId. If you retry with the samebulkId, Infobip won't send duplicate messages. To ensure idempotency, generate a uniquebulkId(e.g., using UUID) before the retry loop and include it in the payload.Circuit Breaker Pattern: For production systems, implement a circuit breaker (using libraries like
opossum) to prevent cascading failures. A circuit breaker stops calling the Infobip API after repeated failures and retries after a cooldown period.6. Database Schema with Prisma and PostgreSQL
Use Prisma to define your database schema and interact with the database.
Define Schema (
prisma/schema.prisma): Update the schema file to include models forRecipientandBroadcast.Run Migrations: Generate and apply the migration to create the database tables.
This command:
prisma/migrations/Create Prisma Client Utility (
src/utils/prismaClient.js):// src/utils/prismaClient.js const { PrismaClient } = require('@prisma/client'); const prisma = new PrismaClient({ log: process.env.NODE_ENV === 'development' ? ['query', 'info', 'warn', 'error'] : ['error'], }); module.exports = prisma;Seed Database (Optional): Create a seed script to populate test data.
// prisma/seed.js const { PrismaClient } = require('@prisma/client'); const prisma = new PrismaClient(); async function main() { await prisma.recipient.createMany({ data: [ { phone: '+15551112222', name: 'Alice', isActive: true }, { phone: '+447123456789', name: 'Bob', isActive: true }, { phone: '+4915123456789', name: 'Charlie', isActive: false }, ], }); console.log('Database seeded successfully'); } main() .catch((e) => { console.error(e); process.exit(1); }) .finally(async () => { await prisma.$disconnect(); });Run the seed script:
7. Security Best Practices for SMS Broadcasting APIs
Security Middleware
Install Security Packages:
Add Helmet for Security Headers (
src/app.js):const helmet = require('helmet'); app.use(helmet());Implement Rate Limiting (
src/middleware/rateLimiter.js):const rateLimit = require('express-rate-limit'); const limiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // Limit each IP to 100 requests per windowMs message: 'Too many requests from this IP, please try again later.', }); module.exports = limiter;Apply in
src/app.js:const rateLimiter = require('./middleware/rateLimiter'); app.use('/api/', rateLimiter);Add Input Validation (
src/middleware/validators.js):const { body, validationResult } = require('express-validator'); const validateBroadcastRequest = [ body('message') .trim() .notEmpty().withMessage('Message is required') .isLength({ max: 1600 }).withMessage('Message too long (max 1600 characters)'), body('recipients') .optional() .isArray().withMessage('Recipients must be an array') .custom((value) => { if (value && value.length > 1000) { throw new Error('Maximum 1000 recipients per request'); } return true; }), body('sender') .optional() .trim() .isLength({ max: 11 }).withMessage('Sender ID max 11 characters'), (req, res, next) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ errors: errors.array() }); } next(); }, ]; module.exports = { validateBroadcastRequest };Apply in
src/routes/broadcastRoutes.js:const { validateBroadcastRequest } = require('../middleware/validators'); router.post('/', validateBroadcastRequest, broadcastController.createBroadcast);Additional Best Practices
.envfiles for development, staging, and production8. Testing Your Bulk SMS Application
Create unit and integration tests for your application.
Configure Jest (
jest.config.js):module.exports = { testEnvironment: 'node', coverageDirectory: 'coverage', collectCoverageFrom: ['src/**/*.js'], testMatch: ['**/tests/**/*.test.js'], };Unit Test Example (
tests/unit/infobipService.test.js):const infobipService = require('../../src/services/infobipService'); describe('Infobip Service', () => { test('sendBulkSms throws error when phone numbers array is empty', async () => { await expect( infobipService.sendBulkSms([], 'Test message') ).rejects.toThrow('Recipient phone numbers array cannot be empty'); }); test('sendBulkSms throws error when message text is empty', async () => { await expect( infobipService.sendBulkSms(['+15551112222'], '') ).rejects.toThrow('Message text cannot be empty'); }); });Integration Test Example (
tests/integration/broadcast.test.js):const request = require('supertest'); const app = require('../../src/app'); describe('POST /api/v1/broadcasts', () => { test('returns 400 when message is missing', async () => { const response = await request(app) .post('/api/v1/broadcasts') .send({ recipients: ['+15551112222'] }); expect(response.status).toBe(400); expect(response.body).toHaveProperty('error'); }); test('returns 400 when recipients array is empty', async () => { const response = await request(app) .post('/api/v1/broadcasts') .send({ message: 'Test', recipients: [] }); expect(response.status).toBe(400); }); });Run Tests:
npm test9. Deploying Your SMS Broadcast System
Deploy your application to a cloud platform.
Heroku Deployment
Install Heroku CLI and log in:
Create Heroku app:
Add PostgreSQL addon:
Set environment variables:
heroku config:set INFOBIP_BASE_URL=your_base_url heroku config:set INFOBIP_API_KEY=your_api_key heroku config:set NODE_ENV=productionAdd Procfile:
Deploy:
Docker Deployment
Create Dockerfile:
Create docker-compose.yml:
version: '3.8' services: app: build: . ports: - "3000:3000" environment: - NODE_ENV=production - DATABASE_URL=postgresql://user:password@db:5432/infobip - INFOBIP_BASE_URL=${INFOBIP_BASE_URL} - INFOBIP_API_KEY=${INFOBIP_API_KEY} depends_on: - db db: image: postgres:14-alpine environment: - POSTGRES_USER=user - POSTGRES_PASSWORD=password - POSTGRES_DB=infobip volumes: - postgres_data:/var/lib/postgresql/data volumes: postgres_data:Build and run:
Conclusion
You now have a production-ready bulk SMS broadcast system with:
Next Steps
Troubleshooting Common Issues
Infobip client is not initialized.envfile for correctINFOBIP_BASE_URLandINFOBIP_API_KEYvaluesInvalid phone number format+15551234567)Rate limit exceededDatabase connection errorDATABASE_URLin.envand ensure PostgreSQL is runningPrisma client not foundnpx prisma generateto regenerate the client429 Too Many Requests401 UnauthorizedCost Estimation
Infobip Pricing (approximate as of 2025):
Infrastructure Costs:
Additional Resources