code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Article

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.

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 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

System Architecture:

plaintext
+-------------+       +-------------------+       +-----------------+       +-------------+
|   Client    |------>|  Node.js/Express  |<----->|  Infobip Service |------>| Infobip API |
| (Postman/UI)|       |      API Layer    |       | (Node.js SDK)   |       +-------------+
+-------------+       | (Routes, Ctrls)   |       +-----------------+
                      +-------------------+
                                |
                                V
                      +-------------------+
                      |  Database (PG)    |
                      | (Prisma ORM)      |
                      +-------------------+

Prerequisites:

  • Free or paid Infobip account (sign up here)
  • 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.

  1. Create Project Directory: Open your terminal and create a new project directory.

    bash
    mkdir infobip-bulk-sms
    cd infobip-bulk-sms
  2. Initialize Node.js Project: Initialize npm with default settings using the -y flag.

    bash
    npm init -y
  3. Install Dependencies: Install Express, the Infobip SDK, dotenv, Prisma, and testing tools. These versions are compatible as of January 2025.

    bash
    npm install express @infobip-api/sdk dotenv pg @prisma/client
    npm install --save-dev prisma nodemon jest supertest
    • express: Web framework
    • @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
  4. Set up Project Structure: Create this directory structure:

    plaintext
    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
  5. 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
    }
  6. 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.

  7. Configure Environment Variables: Create a .env file in the project root. Add this file to .gitignore immediately to prevent exposing credentials.

    plaintext
    # .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.
  8. Configure .gitignore: Create a .gitignore file in the root directory:

    text
    # .gitignore
    node_modules/
    .env
    dist/
    coverage/
    *.log

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.

  1. Create Infobip Service (src/services/infobipService.js):

    javascript
    // 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:

  1. Import and initialize: Load the Infobip SDK, validate credentials, and create the API client with API Key authentication.
  2. 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.
  3. Response handling: Returns structured data with bulkId (tracks the entire batch) and individual message statuses.
  4. 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.

  1. Create Broadcast Controller (src/controllers/broadcastController.js):

    javascript
    // 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,
    };
  2. Create Broadcast Routes (src/routes/broadcastRoutes.js):

    javascript
    // 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;
  3. Set up Express App (src/app.js):

    javascript
    // 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;
  4. Create Server Entry Point (server.js):

    javascript
    // 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/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).
  • cURL Example:

    bash
    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):

    json
    {
        "message": "Broadcast request accepted successfully.",
        "broadcastId": "clxyzabc1234567890def",
        "infobipBulkId": "2034072219640523072",
        "submittedMessages": 3
    }
  • Example Error Response (400 Bad Request):

    json
    {
        "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.

  1. Log in to Infobip: Access your Infobip account dashboard.

  2. Navigate to API Keys: Look for "API Keys," "Developer Tools," or similar in the navigation menu.

  3. Create/View API Key: Create a new API key or view an existing one. Ensure it has permissions for sending SMS messages.

  4. Copy API Key: Securely copy the generated API Key. This is your INFOBIP_API_KEY.

  5. 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.

  6. Update .env: Paste the copied values into your .env file.

    plaintext
    # .env
    # ... other variables
    INFOBIP_BASE_URL="YOUR_COPIED_BASE_URL"
    INFOBIP_API_KEY="YOUR_COPIED_API_KEY"
    # ...
  7. 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.

  1. 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).

    javascript
    // 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):

    javascript
    // 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;
  2. Centralized Error Handler (src/middleware/errorHandler.js): This middleware catches errors passed via next(error) and sends a structured response.

    javascript
    // 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;
    • Make sure this middleware is added last in src/app.js, after all routes.
  3. 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.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
    }
  4. 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.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 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.

  1. 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
    // }
  2. Run Migrations: Generate and apply the migration to create the database tables.

    bash
    npx prisma migrate dev --name init

    This command:

    • Creates a migration file in prisma/migrations/
    • Applies the migration to your database
    • Generates the Prisma Client
  3. Create Prisma Client Utility (src/utils/prismaClient.js):

    javascript
    // 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;
  4. Seed Database (Optional): Create a seed script to populate test data.

    javascript
    // 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:

    bash
    node prisma/seed.js

7. Security Best Practices for SMS Broadcasting APIs

Security Middleware

  1. Install Security Packages:

    bash
    npm install helmet express-rate-limit express-validator
  2. Add Helmet for Security Headers (src/app.js):

    javascript
    const helmet = require('helmet');
    app.use(helmet());
  3. Implement Rate Limiting (src/middleware/rateLimiter.js):

    javascript
    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:

    javascript
    const rateLimiter = require('./middleware/rateLimiter');
    app.use('/api/', rateLimiter);
  4. Add Input Validation (src/middleware/validators.js):

    javascript
    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:

    javascript
    const { validateBroadcastRequest } = require('../middleware/validators');
    router.post('/', validateBroadcastRequest, broadcastController.createBroadcast);

Additional Best Practices

  • Environment-specific configs: Use different .env files for development, staging, and production
  • HTTPS only: Enforce HTTPS in production
  • CORS configuration: Configure CORS appropriately for your client applications
  • Authentication: Add JWT or OAuth for production API authentication
  • Monitoring: Integrate with services like Datadog, New Relic, or Prometheus
  • Logging: Use structured logging and external log aggregation

8. Testing Your Bulk SMS Application

Create unit and integration tests for your application.

  1. Configure Jest (jest.config.js):

    javascript
    module.exports = {
      testEnvironment: 'node',
      coverageDirectory: 'coverage',
      collectCoverageFrom: ['src/**/*.js'],
      testMatch: ['**/tests/**/*.test.js'],
    };
  2. Unit Test Example (tests/unit/infobipService.test.js):

    javascript
    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');
      });
    });
  3. Integration Test Example (tests/integration/broadcast.test.js):

    javascript
    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);
      });
    });
  4. Run Tests:

    bash
    npm test

9. Deploying Your SMS Broadcast System

Deploy your application to a cloud platform.

Heroku Deployment

  1. Install Heroku CLI and log in:

    bash
    heroku login
  2. Create Heroku app:

    bash
    heroku create your-app-name
  3. Add PostgreSQL addon:

    bash
    heroku addons:create heroku-postgresql:mini
  4. Set environment variables:

    bash
    heroku config:set INFOBIP_BASE_URL=your_base_url
    heroku config:set INFOBIP_API_KEY=your_api_key
    heroku config:set NODE_ENV=production
  5. Add Procfile:

    web: node server.js release: npx prisma migrate deploy
  6. Deploy:

    bash
    git push heroku main

Docker Deployment

  1. Create Dockerfile:

    dockerfile
    FROM node:18-alpine
    WORKDIR /app
    COPY package*.json ./
    RUN npm ci --only=production
    COPY . .
    RUN npx prisma generate
    EXPOSE 3000
    CMD ["node", "server.js"]
  2. Create docker-compose.yml:

    yaml
    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:
  3. Build and run:

    bash
    docker-compose up -d

Conclusion

You now have a production-ready bulk SMS broadcast system with:

  • ✅ Express API with proper routing and controllers
  • ✅ Infobip SDK integration for bulk SMS sending
  • ✅ PostgreSQL database with Prisma ORM
  • ✅ Error handling and retry mechanisms
  • ✅ Security middleware (Helmet, rate limiting, validation)
  • ✅ Structured logging
  • ✅ Testing framework
  • ✅ Deployment configurations

Next Steps

  • Implement webhook handlers for Infobip delivery reports (track message delivery status)
  • Add recipient management endpoints (create, update, delete recipients)
  • Implement broadcast status retrieval endpoint
  • Add job queuing for scheduled broadcasts (using Bull or Agenda)
  • Set up monitoring and alerting (using Datadog, New Relic, or Prometheus)
  • Implement advanced features (message templates, A/B testing, analytics)

Troubleshooting Common Issues

IssueSolution
Infobip client is not initializedCheck .env file for correct INFOBIP_BASE_URL and INFOBIP_API_KEY values
Invalid phone number formatEnsure phone numbers use E.164 format (e.g., +15551234567)
Rate limit exceededCheck your Infobip account tier and upgrade if needed, or implement request throttling
Database connection errorVerify DATABASE_URL in .env and ensure PostgreSQL is running
Prisma client not foundRun npx prisma generate to regenerate the client
429 Too Many RequestsReduce request frequency or upgrade Infobip plan
401 UnauthorizedVerify API key is correct and has SMS permissions

Cost Estimation

Infobip Pricing (approximate as of 2025):

  • SMS costs vary by destination country: $0.01–$0.10 per message
  • Free tier: 10–20 test messages
  • Pay-as-you-go or monthly plans available
  • Monitor usage via Infobip dashboard

Infrastructure Costs:

  • Heroku: $7/month (Eco Dyno) + $5/month (Mini PostgreSQL) = $12/month
  • AWS: ~$15–30/month (t3.micro EC2 + RDS PostgreSQL)
  • DigitalOcean: ~$12/month (Basic Droplet + Managed PostgreSQL)

Additional Resources

Frequently Asked Questions

How to send bulk SMS messages with Node.js and Express?

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.

How to test the bulk SMS API endpoint?

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.

What is Infobip and why use it for bulk SMS?

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.

How to set up a Node.js project for bulk SMS messaging?

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.

What database is used in this Infobip tutorial?

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.

What is the purpose of the Infobip Node.js SDK?

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.

How to handle Infobip API credentials securely?

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.

What is the role of Prisma in the bulk SMS project?

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.

How to structure a Node.js Express application for bulk SMS?

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.

Why use dotenv in a Node.js application?

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.

How to implement error handling in Node.js for Infobip API calls?

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.

How to implement logging in Node.js for Infobip integration?

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.

What is the system architecture for the bulk SMS application?

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.

How to send a bulk SMS message with a custom sender ID?

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.

What are the prerequisites for this bulk SMS guide?

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.