code examples

Sent logo
Sent TeamMar 8, 2026 / code examples / Article

Sinch Bulk SMS with Node.js & Express: Complete Implementation Guide 2025

Build a production-ready bulk SMS broadcast system using Sinch Batches API, Node.js 22 LTS, and Express 5. Includes error handling, Winston logging, and E.164 validation.

Build a production-ready Node.js application using Express to send bulk SMS broadcast messages via the Sinch SMS API. This guide covers project setup, core functionality, error handling, security, and deployment best practices.

Learn how to use the Sinch Batches API endpoint to efficiently send the same message to multiple recipients in a single API request – ideal for notifications, marketing campaigns, or mass SMS distribution.

Project Overview and Goals

What You'll Build:

A Node.js Express API server with an endpoint that accepts recipient phone numbers and a message body, then broadcasts the message to all recipients via the Sinch SMS API.

Problem Solved:

Send bulk SMS messages programmatically at scale, overcoming one-by-one messaging limitations while integrating seamlessly into your application workflows.

Technologies Used:

  • Node.js: A JavaScript runtime built on Chrome's V8 engine, suitable for building scalable network applications. This guide uses Node.js 22.x LTS (Active until October 2025, Maintenance until April 2027). Note: Node.js 18+ includes native Fetch API support, reducing the need for node-fetch in many cases.
  • Express: A minimal and flexible Node.js web application framework (v5.1.0 current as of March 2025 with LTS timeline) providing a robust set of features for web and mobile applications. Express 5.x requires Node.js 18+.
  • Sinch SMS API (REST): A powerful API for sending and receiving SMS messages globally. We will use the RESTful Batches endpoint. Batches are stored in Sinch's system for 14 days after creation.
  • node-fetch: A light-weight module that brings the browser fetch API to Node.js (v3+ is ESM-only, requires Node.js 12.20+). Alternative: Use Node.js native fetch() available in Node.js 18+.
  • dotenv: A zero-dependency module that loads environment variables from a .env file into process.env.
  • winston: A versatile logging library for Node.js.

System Architecture:

mermaid
graph LR
    A[User/Client Application] -- HTTP POST Request --> B(Node.js/Express API Server);
    B -- Send Batch Request (recipients, message) --> C(Sinch SMS API);
    C -- Sends SMS --> D{Recipient Phones};
    C -- Returns Batch ID/Status --> B;
    B -- Returns API Response (success/failure) --> A;

Prerequisites:

  • Node.js 18+ and npm (or yarn) installed. Recommended: Node.js 22.x LTS (Active until October 2025, Maintenance until April 2027).
  • A Sinch account (https://dashboard.sinch.com/signup).
  • Your Sinch Service Plan ID and API Token from the Sinch Dashboard (SMS → APIs).
  • A provisioned phone number (or Alphanumeric Sender ID) from Sinch.
  • Basic familiarity with JavaScript, Node.js, REST APIs, and ESM (ECMAScript Modules).

Expected Outcome:

Build a functional Node.js Express application that accepts API requests to send bulk SMS messages using your Sinch account. Understand essential production-grade considerations – configuration, error handling, and security.


1. Set Up Your Project

Initialize your Node.js project and install dependencies. Use ECMAScript Modules (ESM) syntax (import/export) – required by node-fetch v3+ (ESM-only since v3.0.0) and recommended by modern Node.js practices. Note: Node.js 18+ includes native fetch() support, making node-fetch optional for basic HTTP requests.

Step 1: Create Project Directory

Open your terminal and create a new directory for the project, then navigate into it.

bash
mkdir sinch-bulk-sms-app
cd sinch-bulk-sms-app

Step 2: Initialize Node.js Project

Initialize the project using npm. The -y flag accepts the default settings.

bash
npm init -y

This creates a package.json file.

Step 3: Enable ESM

Open the generated package.json file and add the following line to enable ESM syntax:

json
{
  "name": "sinch-bulk-sms-app",
  "version": "1.0.0",
  "description": "",
  "main": "server.mjs",
  "type": "module",
  "scripts": {
    "start": "node server.mjs",
    "dev": "nodemon server.mjs"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}
  • Why ESM? It's the standard module system for JavaScript and required by libraries like node-fetch v3+. Using .mjs file extensions or "type": "module" ensures Node.js treats our files as ES Modules.

Step 4: Install Dependencies

Install Express for the web server, dotenv for environment variables, node-fetch for API calls, and winston for logging.

bash
npm install express dotenv node-fetch winston

Optionally, install nodemon as a development dependency for automatic server restarts on file changes:

bash
npm install --save-dev nodemon

Step 5: Create Project Structure

Create the following directories and files:

sinch-bulk-sms-app/ ├── config/ │ └── logger.mjs ├── routes/ │ └── smsRoutes.mjs ├── services/ │ └── sinchService.mjs ├── .env ├── .gitignore ├── package.json ├── package-lock.json (or yarn.lock) └── server.mjs
  • config/: For configuration files (like logging).
  • routes/: For Express route definitions.
  • services/: For business logic interacting with external APIs (like Sinch).
  • server.mjs: The main entry point for our Express application.
  • .env: To store sensitive credentials (API keys, etc.). Never commit this file to version control.
  • .gitignore: To specify intentionally untracked files that Git should ignore.

Step 6: Configure .gitignore

Create a .gitignore file in the root directory and add the following lines to prevent committing sensitive information and node modules:

text
# .gitignore

# Dependencies
node_modules/

# Environment variables
.env

# Logs
logs/
*.log

# OS generated files
.DS_Store
Thumbs.db

Step 7: Configure Environment Variables (.env)

Create a .env file in the root directory. Add your Sinch credentials and other configuration.

  • How to find Sinch Credentials:
    1. Log in to your Sinch Dashboard (https://dashboard.sinch.com/login).
    2. Navigate to SMS > APIs.
    3. Under Your API Credentials, you will find your Service plan ID and API token. Click the "Show" button for the token if needed.
    4. Select the appropriate Region (e.g., US, EU). The API endpoint URL depends on this.
    5. Navigate to Numbers > Your virtual numbers to find your provisioned Sinch number (or configure an Alphanumeric Sender ID under SMS > Sender IDs).
dotenv
# .env

# Sinch API Credentials
SINCH_SERVICE_PLAN_ID=YOUR_SERVICE_PLAN_ID
SINCH_API_TOKEN=YOUR_API_TOKEN
SINCH_NUMBER=+1XXXXXXXXXX # Your provisioned Sinch number or Alphanumeric Sender ID

# Sinch API Region Endpoint Base URL (Choose the correct one for your account)
# US: https://us.sms.api.sinch.com
# EU: https://eu.sms.api.sinch.com
# More regions available, see Sinch documentation
SINCH_REGION_URL=https://us.sms.api.sinch.com

# Application Port
PORT=3000

# Logging Level (error, warn, info, http, verbose, debug, silly)
LOG_LEVEL=info
  • SINCH_SERVICE_PLAN_ID: Your unique service plan identifier from Sinch.
  • SINCH_API_TOKEN: Your secret API token for authentication. Treat this like a password.
  • SINCH_NUMBER: The sender ID for your messages (your virtual number or approved Alphanumeric ID).
  • SINCH_REGION_URL: The base URL for the Sinch API corresponding to your account's region. Crucial for successful API calls.
  • PORT: The port your Express server will listen on.
  • LOG_LEVEL: Controls the verbosity of logs.

2. Implementing Core Functionality (Sinch Service)

Now, let's create the service responsible for interacting with the Sinch API.

File: services/sinchService.mjs

This file will contain the function to send the bulk SMS batch.

javascript
// services/sinchService.mjs
import fetch from 'node-fetch';
import logger from '../config/logger.mjs'; // We will create this logger soon

const SERVICE_PLAN_ID = process.env.SINCH_SERVICE_PLAN_ID;
const API_TOKEN = process.env.SINCH_API_TOKEN;
const SINCH_REGION_URL = process.env.SINCH_REGION_URL;

/**
 * Sends a bulk SMS message using the Sinch Batches API.
 * The Batches endpoint sends sets of SMS messages queued and delivered in first-in-first-out order.
 * Batches are stored in Sinch's system for 14 days after creation.
 *
 * @param {string[]} recipients - An array of recipient phone numbers in E.164 format (+ followed by country code and number, max 15 digits per ITU-T E.164).
 * @param {string} messageBody - The text content of the SMS message.
 * @param {string} sender - The sender ID (Sinch Number or Alphanumeric Sender ID).
 * @param {boolean} feedbackEnabled - Whether Sinch should attempt to provide delivery feedback via webhooks (requires webhook setup).
 * @returns {Promise<object>} - A promise that resolves with the Sinch API response containing batch_id and other metadata.
 * @throws {Error} - Throws an error if the API call fails.
 */
export const sendBulkSms = async (recipients, messageBody, sender, feedbackEnabled = false) => {
    const endpoint = `${SINCH_REGION_URL}/xms/v1/${SERVICE_PLAN_ID}/batches`;

    // Validate essential configuration
    if (!SERVICE_PLAN_ID || !API_TOKEN || !SINCH_REGION_URL || !sender) {
        logger.error('Sinch configuration missing in environment variables.');
        throw new Error('Sinch service configuration is incomplete.');
    }
    if (!recipients || recipients.length === 0) {
        logger.error('No recipients provided for bulk SMS.');
        throw new Error('Recipient list cannot be empty.');
    }
    if (!messageBody) {
        logger.error('No message body provided for bulk SMS.');
        throw new Error('Message body cannot be empty.');
    }

    // Construct the payload for the Sinch Batches API
    const payload = {
        to: recipients,             // Array of phone numbers
        from: sender,               // Your Sinch number or Sender ID
        body: messageBody,          // The message content
        feedback_enabled: feedbackEnabled // Optional: Set true if you plan to handle delivery feedback
        // You can add other parameters like 'delivery_report', 'expire_at', etc.
        // Refer to Sinch API documentation for more options:
        // https://developers.sinch.com/docs/sms/api-reference/sms/tag/Batches/#tag/Batches/operation/SendSMSBatchMessage
    };

    logger.info(`Sending bulk SMS to ${recipients.length} recipients via Sinch`);
    logger.debug(`Sinch API Endpoint: ${endpoint}`);
    // logger.debug(`Sinch API Payload: ${JSON.stringify(payload)}`); // Be careful logging payload with phone numbers

    try {
        const response = await fetch(endpoint, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'Authorization': `Bearer ${API_TOKEN}` // Use Bearer token authentication
            },
            body: JSON.stringify(payload)
        });

        const responseData = await response.json();

        if (!response.ok) {
            // Log detailed error information from Sinch
            logger.error(`Sinch API Error (${response.status}): ${JSON.stringify(responseData)}`);
            throw new Error(`Sinch API request failed with status ${response.status}: ${responseData.code || ''} ${responseData.text || ''}`);
        }

        logger.info(`Sinch batch submitted successfully. Batch ID: ${responseData.id}`);
        // logger.debug(`Sinch API Response: ${JSON.stringify(responseData)}`);
        return responseData; // Contains batch_id, etc.

    } catch (error) {
        logger.error(`Error calling Sinch API: ${error.message}`, { stack: error.stack });
        // Re-throw the error to be handled by the calling route
        throw error;
    }
};

// Why this approach?
// 1. Encapsulation: Keeps Sinch-specific logic separate from the API routes.
// 2. Async/Await: Handles the asynchronous nature of network requests cleanly.
// 3. node-fetch: Standard way to make HTTP requests in modern Node.js.
// 4. Bearer Token Auth: Standard and secure way to authenticate with the Sinch API.
// 5. Error Handling: Includes checks for configuration and handles API response errors, logging useful details.
// 6. Parameterization: Makes the function reusable by accepting recipients, message, and sender dynamically.
// 7. Feedback Enabled Flag: Includes the option to request delivery feedback, although handling the callback is not implemented in this guide.

3. Building the API Layer (Express Routes)

Now, let's create the Express route that will receive requests and use our sinchService.

File: routes/smsRoutes.mjs

javascript
// routes/smsRoutes.mjs
import express from 'express';
import { sendBulkSms } from '../services/sinchService.mjs';
import logger from '../config/logger.mjs';

const router = express.Router();
const SINCH_NUMBER = process.env.SINCH_NUMBER;

// POST /api/broadcast
// Endpoint to send a bulk SMS broadcast
router.post('/broadcast', async (req, res) => {
    // Basic Input Validation
    const { recipients, message } = req.body;

    if (!Array.isArray(recipients) || recipients.length === 0) {
        logger.warn('Broadcast request received with invalid or empty recipients list.');
        return res.status(400).json({ error: 'Invalid request: recipients must be a non-empty array of phone numbers.' });
    }
    if (typeof message !== 'string' || message.trim() === '') {
        logger.warn('Broadcast request received with invalid or empty message.');
        return res.status(400).json({ error: 'Invalid request: message must be a non-empty string.' });
    }
    if (!SINCH_NUMBER) {
        logger.error('Sinch sender number/ID is not configured in environment variables.');
        return res.status(500).json({ error: 'Server configuration error: Sender ID not set.' });
    }

    // Basic phone number format validation (E.164: + followed by 1-15 digits, per ITU-T E.164 specification)
    // E.164 format: + [country code 1-3 digits] [subscriber number, total max 15 digits]
    const invalidNumbers = recipients.filter(num => !/^\+[1-9]\d{1,14}$/.test(num));
    if (invalidNumbers.length > 0) {
        logger.warn(`Broadcast request received with invalid phone numbers: ${invalidNumbers.join(', ')}`);
        return res.status(400).json({
            error: 'Invalid request: One or more recipient phone numbers are not in valid E.164 format (+ followed by country code and subscriber number, max 15 digits total per ITU-T E.164).',
            invalid_numbers: invalidNumbers
         });
    }

    logger.info(`Received broadcast request for ${recipients.length} recipients.`);

    try {
        // Call the Sinch service function
        const sinchResponse = await sendBulkSms(recipients, message, SINCH_NUMBER);

        // Respond to the client
        res.status(202).json({ // 202 Accepted: Request received, processing started
            message: 'SMS batch submitted successfully to Sinch.',
            batch_id: sinchResponse.id,
            // Include other relevant info from sinchResponse if needed
        });

    } catch (error) {
        // Log the error (already logged in sinchService, but good to log context here too)
        logger.error(`Failed to process broadcast request: ${error.message}`);

        // Determine appropriate status code based on error if possible
        // For now, return a generic 500 for internal errors
        res.status(500).json({
            error: 'Failed to send SMS batch.',
            details: error.message // Provide some detail back, but be cautious in production
        });
    }
});

export default router;

// Design Decisions:
// 1. POST Method: Appropriate for actions that create a resource (in this case, an SMS batch submission).
// 2. Input Validation: Crucial first step to reject invalid requests early. Checks for presence, type, and basic format.
// 3. Separation of Concerns: Route handler focuses on request/response and validation, delegating the core SMS sending logic to `sinchService`.
// 4. Error Handling: Uses try/catch to handle errors from the service layer and respond appropriately to the client.
// 5. Status Codes: Uses meaningful HTTP status codes (202 Accepted for successful submission, 400 Bad Request for validation errors, 500 Internal Server Error for processing failures).
// 6. Configuration Use: Pulls the sender number directly from environment variables.

Testing the Endpoint (Example):

Once the server is running, you can test this endpoint using curl or a tool like Postman.

curl Example:

bash
curl -X POST http://localhost:3000/api/broadcast \
 -H "Content-Type: application/json" \
 -d '{
       "recipients": ["+15551234567", "+15559876543"],
       "message": "Hello from our Node.js broadcast app!"
     }'

Expected Response (Success - 202 Accepted):

json
{
  "message": "SMS batch submitted successfully to Sinch.",
  "batch_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV"
}

Expected Response (Validation Error - 400 Bad Request):

json
{
  "error": "Invalid request: message must be a non-empty string."
}

Expected Response (Server Error - 500 Internal Server Error):

json
{
  "error": "Failed to send SMS batch.",
  "details": "Sinch API request failed with status 401: 40100 Invalid credentials ..."
}

4. Locating and Understanding Sinch Credentials & Configuration

This section focuses specifically on obtaining and understanding the necessary Sinch credentials configured via environment variables. Proper configuration is essential for successful integration.

  • Configuration Method: The application uses the dotenv library to load credentials and settings from a .env file into process.env. This keeps sensitive information out of the source code.
  • Obtaining Credentials from Sinch Dashboard:
    1. Log in to the Sinch Dashboard: https://dashboard.sinch.com/login
    2. Service Plan ID & API Token: Navigate to SMS -> APIs. In the "Your API Credentials" section, copy your Service plan ID and API token. You may need to click "Show" to reveal the full token. Ensure you copy the complete values accurately.
    3. Region URL: On the same SMS -> APIs page, note your account's Region (e.g., US, EU). Use the corresponding base URL for the SINCH_REGION_URL variable in your .env file (e.g., https://us.sms.api.sinch.com, https://eu.sms.api.sinch.com). Using an incorrect region URL is a common cause of authentication failures (401/404 errors).
    4. Sender Number/ID: Navigate to Numbers -> Your virtual numbers to find your provisioned phone number, or go to SMS -> Sender IDs if you have configured an Alphanumeric Sender ID. This value should be used for the SINCH_NUMBER variable in your .env file.
  • Secure Handling:
    • Use the .env file only for local development.
    • Crucially, add .env to your .gitignore file to prevent committing secrets to version control.
    • For production deployments (e.g., Heroku, AWS, Docker), utilize the platform's secure environment variable management system (e.g., Config Vars, Secrets Manager, Kubernetes Secrets). Do not deploy the .env file itself.
  • Environment Variables Explained:
    • SINCH_SERVICE_PLAN_ID: (String) Your unique identifier for the Sinch SMS service plan. Typically an alphanumeric string (e.g., abcdef1234567890abcdef12345678). Found on the Sinch Dashboard (SMS -> APIs).
    • SINCH_API_TOKEN: (String) Your secret API key used for authenticating requests. Usually a UUID-like string (e.g., xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx). Found on the Sinch Dashboard (SMS -> APIs). Treat this like a password and keep it confidential.
    • SINCH_NUMBER: (String) The identifier your recipients will see as the sender. Must be either a phone number purchased/verified in your Sinch account (in E.164 format, e.g., +12125551212) or an approved Alphanumeric Sender ID (e.g., MyCompany, max 11 characters, subject to country restrictions and potential registration requirements). Found/Configured in the Sinch Dashboard (Numbers or SMS -> Sender IDs).
    • SINCH_REGION_URL: (String) The base URL for the Sinch SMS API specific to your account's region. Format: https://<region_code>.sms.api.sinch.com (e.g., https://us.sms.api.sinch.com). Determined by your region selection shown on the Sinch Dashboard (SMS -> APIs).

(No specific dashboard screenshots are included as the UI might change over time, but the navigation paths provided should help locate the necessary information.)


5. Implementing Proper Error Handling, Logging, and Retry Mechanisms

Robust error handling and logging are essential for production applications.

Step 1: Setup Logging (Winston)

Configure a reusable logger.

File: config/logger.mjs

javascript
// config/logger.mjs
import winston from 'winston';

const { combine, timestamp, printf, colorize, align, json } = winston.format;

// Determine log level from environment variable, default to 'info'
const logLevel = process.env.LOG_LEVEL || 'info';

// Custom log format
const logFormat = printf(({ level, message, timestamp, stack, ...metadata }) => {
  let msg = `${timestamp} [${level}]: ${message}`;
  // Add stack trace for errors
  if (stack) {
    msg += `\n${stack}`;
  }
  // Add any additional metadata, ensuring space is only added if metadata exists
  const metadataString = Object.keys(metadata).length ? ` ${JSON.stringify(metadata)}` : '';
  msg += metadataString;
  return msg;
});

const logger = winston.createLogger({
  level: logLevel,
  format: combine(
    timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
    json() // Log as JSON potentially for log aggregators
  ),
  transports: [
    // Default console transport with colorization and readable format
    new winston.transports.Console({
      format: combine(
        colorize(),
        align(),
        timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
        logFormat
      ),
    }),
    // Optionally, add file transports for persistence
    // new winston.transports.File({
    //   filename: 'logs/error.log',
    //   level: 'error',
    //   format: combine(timestamp(), json()), // Keep file logs structured
    // }),
    // new winston.transports.File({
    //   filename: 'logs/combined.log',
    //    format: combine(timestamp(), json()),
    // }),
  ],
  exceptionHandlers: [
    // Catch unhandled exceptions
    new winston.transports.Console({
        format: combine(colorize(), logFormat)
    }),
    // new winston.transports.File({ filename: 'logs/exceptions.log' })
  ],
  rejectionHandlers: [
     // Catch unhandled promise rejections
     new winston.transports.Console({
         format: combine(colorize(), logFormat)
     }),
     // new winston.transports.File({ filename: 'logs/rejections.log' })
  ]
});

logger.info(`Logger initialized with level: ${logLevel}`);

export default logger;

// Why Winston?
// 1. Flexibility: Supports multiple transports (console, file, HTTP, etc.).
// 2. Formatting: Customizable log formats (text, JSON).
// 3. Levels: Standard logging levels (error, warn, info, debug, etc.).
// 4. Unhandled Exceptions/Rejections: Can catch and log critical application crashes.

Make sure to create the logs/ directory if you enable file transports: mkdir logs.

Step 2: Integrate Logger

We already imported and used the logger in sinchService.mjs and smsRoutes.mjs. Ensure it's used consistently for logging events, warnings, and errors.

Step 3: Error Handling Strategy

  • Validation Errors (4xx): Handled in the route (smsRoutes.mjs). Logged as warnings (logger.warn) and returned to the client with a 400 status code.
  • Configuration Errors (5xx): Checked early (e.g., missing .env variables in sinchService.mjs). Logged as errors (logger.error) and result in a 500 status being returned to the client via the route's error handler.
  • Sinch API Errors (4xx/5xx): Caught in sinchService.mjs. Logged as errors (logger.error) with details from the Sinch response. Re-thrown and caught by the route handler, which returns a 500 status to the client. Refinement: You could map specific Sinch error codes (e.g., 401, 403) to specific client responses if needed, but a generic 500 is often sufficient for external API failures.
  • Network/Unexpected Errors: Caught by the try...catch in sinchService.mjs or the unhandled exception/rejection handlers in logger.mjs. Logged as errors. Return a 500 status.

Step 4: Retry Mechanisms (Conceptual)

For transient errors (network issues, temporary Sinch 5xx errors), implementing retries can improve reliability.

  • Strategy: Exponential Backoff (wait longer between each retry).
  • Example using async-retry library (Install: npm install async-retry):
javascript
// Example within sinchService.mjs (conceptual)
import retry from 'async-retry';

// ... inside sendBulkSms, replace the direct fetch call ...

try {
    const sinchResponse = await retry(
        async (bail, attempt) => {
            logger.info(`Attempt ${attempt} to call Sinch API...`);
            const response = await fetch(endpoint, { /* ... fetch options ... */ });
            const responseData = await response.json(); // Need to handle non-JSON responses too

            if (!response.ok) {
                 logger.error(`Sinch API Error on attempt ${attempt} (${response.status}): ${JSON.stringify(responseData)}`);
                 // Don't retry on client errors (4xx) except maybe rate limiting (429)
                 if (response.status >= 400 && response.status < 500 && response.status !== 429) {
                     bail(new Error(`Non-retriable Sinch API error: ${response.status}`)); // Stop retrying
                     return; // Important: exit async function after bail
                 }
                 // Throw error to trigger retry for 5xx or 429
                 throw new Error(`Sinch API request failed with status ${response.status}`);
            }
            return responseData; // Success
        },
        {
            retries: 3, // Number of retries
            factor: 2, // Exponential backoff factor
            minTimeout: 1000, // Initial delay 1 second
            maxTimeout: 5000, // Max delay 5 seconds
            onRetry: (error, attempt) => {
                logger.warn(`Retrying Sinch API call (attempt ${attempt}) due to error: ${error.message}`);
            },
        }
    );

     logger.info(`Sinch batch submitted successfully after retries. Batch ID: ${sinchResponse.id}`);
     return sinchResponse;

} catch (error) {
     logger.error(`Failed to call Sinch API after multiple retries: ${error.message}`, { stack: error.stack });
     throw error; // Re-throw final error
}
  • Testing Errors: Manually modify API tokens, URLs, or use tools like toxiproxy to simulate network failures or invalid responses during testing. Temporarily change SINCH_API_TOKEN in .env to test 401 errors.

Log Analysis:

  • Use grep, jq, or dedicated log management systems (ELK stack, Splunk, Datadog Logs) to search and analyze logs (especially if using JSON format).
  • Look for patterns in error or warn level messages to identify recurring issues.
  • Correlate timestamps to trace request flows.

6. Creating a Database Schema and Data Layer (Conceptual)

While this guide takes recipients directly from the request body (req.body.recipients) for simplicity, a production application managing recipient lists, tracking opt-outs, and segmenting users would require a database. Here's a conceptual overview of how you might structure it.

Technology Choice: PostgreSQL with Prisma (ORM) is a robust choice. Alternatives include MongoDB, MySQL, or Sequelize ORM.

Conceptual Schema (using Prisma syntax):

prisma
// schema.prisma

datasource db {
  provider = "postgresql" // or "mysql", "sqlite", "mongodb"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model Subscriber {
  id          String    @id @default(cuid())
  phoneNumber String    @unique // E.164 format recommended
  firstName   String?
  lastName    String?
  isActive    Boolean   @default(true) // For opt-out management
  subscribedAt DateTime  @default(now())
  updatedAt   DateTime  @updatedAt
  tags        String[]  // For segmenting users
  // Add other relevant fields
}

model Broadcast {
  id          String    @id @default(cuid())
  message     String
  status      String    // e.g., PENDING, SENT, FAILED
  sinchBatchId String?   @unique // Link to Sinch batch
  sentAt      DateTime?
  createdAt   DateTime  @default(now())
  // Maybe link to subscribers targeted if needed (e.g., via tags)
}

// Add models for tracking message delivery status via webhooks if implemented

Entity Relationship Diagram (Conceptual):

mermaid
erDiagram
    SUBSCRIBER ||--o{ TAGS : "has" // Assuming tags are strings for simplicity
    BROADCAST {
        String id PK
        String message
        String status
        String sinchBatchId UK
        DateTime sentAt
        DateTime createdAt
    }
    SUBSCRIBER {
        String id PK
        String phoneNumber UK
        String firstName
        String lastName
        Boolean isActive
        DateTime subscribedAt
        DateTime updatedAt
    }
    // Relationship depends on how targeting is done
    // Option 1: Log targeted tags in Broadcast
    // Option 2: Many-to-many if tracking individual sends per broadcast

Data Access Layer (Conceptual using Prisma):

javascript
// services/subscriberService.mjs (Conceptual Example)
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();

export const getActiveSubscribersByTag = async (tag) => {
  return prisma.subscriber.findMany({
    where: {
      isActive: true,
      tags: {
        has: tag, // Prisma specific array query
      },
    },
    select: {
      phoneNumber: true, // Only select the needed field
    },
  });
};

export const optOutSubscriber = async (phoneNumber) => {
 return prisma.subscriber.update({
    where: { phoneNumber: phoneNumber },
    data: { isActive: false },
 });
};

// Add functions to create, update, delete subscribers

Migrations:

  • Prisma: Use npx prisma migrate dev to create and apply SQL migrations based on schema.prisma.
  • Sequelize: Use Sequelize CLI for migrations.

Performance/Scale:

  • Index frequently queried columns (phoneNumber, isActive, tags).
  • Select only necessary fields (select: { phoneNumber: true }).
  • Implement pagination for fetching large lists of subscribers.
  • Consider database read replicas for heavy read loads.

Replacing Request Body Recipients:

In smsRoutes.mjs, instead of directly using req.body.recipients, you would modify the route to accept criteria (like a tag) and fetch the corresponding recipients from the database using a service like subscriberService.mjs.

javascript
// smsRoutes.mjs (Conceptual Change)
import { getActiveSubscribersByTag } from '../services/subscriberService.mjs'; // Conceptual

router.post('/broadcast/tag/:tagName', async (req, res) => {
    const { tagName } = req.params;
    const { message } = req.body;
    // ... validation for message ...

    try {
        const subscribers = await getActiveSubscribersByTag(tagName);
        const recipients = subscribers.map(sub => sub.phoneNumber);

        if (recipients.length === 0) {
            return res.status(404).json({ error: `No active subscribers found for tag: ${tagName}` });
        }

        // Ensure SINCH_NUMBER is checked/available here too
        if (!SINCH_NUMBER) {
             logger.error('Sinch sender number/ID is not configured.');
             return res.status(500).json({ error: 'Server configuration error: Sender ID not set.' });
        }

        const sinchResponse = await sendBulkSms(recipients, message, SINCH_NUMBER);
        // ... response handling ...
    } catch (error) {
        // ... error handling ...
        logger.error(`Failed to process broadcast request for tag ${tagName}: ${error.message}`);
        res.status(500).json({
            error: 'Failed to send SMS batch.',
            details: error.message
        });
    }
});

7. Adding Security Features

Securing your API is critical.

Step 1: Input Validation and Sanitization

  • Validation: We added basic validation in smsRoutes.mjs (checking types, presence, basic phone format). For production, use a dedicated library like joi or express-validator for more robust schema validation.
  • Phone Numbers: E.164 format (+ followed by country code and number, no spaces/dashes) is standard. Validate rigorously. Libraries like google-libphonenumber can help parse and validate international numbers accurately.
  • Message Content: Validate message length. SMS messages are typically limited to 160 characters for single-part messages (GSM-7 encoding) or 70 characters (UCS-2/UTF-16 for Unicode). Messages exceeding these limits are split into multiple parts, increasing costs.
  • Sanitization: Be cautious of injection attacks. While SMS content is typically plain text, ensure proper escaping if integrating with other systems (databases, logs).

Step 2: Authentication and Authorization

  • API Keys: For production, implement API key authentication for your Express endpoints. Generate unique keys for each client/application accessing your service.
  • Example using custom middleware:
javascript
// middleware/auth.mjs
import logger from '../config/logger.mjs';

const API_KEYS = process.env.API_KEYS ? process.env.API_KEYS.split(',') : [];

export const authenticateApiKey = (req, res, next) => {
    const apiKey = req.headers['x-api-key'];

    if (!apiKey) {
        logger.warn('Request received without API key');
        return res.status(401).json({ error: 'API key required' });
    }

    if (!API_KEYS.includes(apiKey)) {
        logger.warn(`Invalid API key attempted: ${apiKey.substring(0, 8)}...`);
        return res.status(403).json({ error: 'Invalid API key' });
    }

    next();
};
  • Usage in routes:
javascript
// routes/smsRoutes.mjs
import { authenticateApiKey } from '../middleware/auth.mjs';

router.post('/broadcast', authenticateApiKey, async (req, res) => {
    // ... existing code ...
});
  • Alternative: Consider OAuth 2.0 or JWT tokens for more sophisticated authentication/authorization needs.
  • HTTPS: Always use HTTPS in production to encrypt data in transit. Use services like Let's Encrypt for free SSL certificates.

Step 3: Rate Limiting

Protect your API from abuse and DoS attacks by implementing rate limiting.

  • Example using express-rate-limit:
bash
npm install express-rate-limit
javascript
// server.mjs
import rateLimit from '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.',
    standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
    legacyHeaders: false, // Disable the `X-RateLimit-*` headers
});

// Apply to all routes
app.use('/api/', limiter);
  • Adjust limits based on your expected traffic and Sinch API quotas.
  • Per-user limits: For authenticated APIs, implement per-user/per-API-key rate limiting instead of just IP-based.

Step 4: Environment Variable Security

  • Never commit .env files to version control (.gitignore).
  • Use platform-specific secret management in production (AWS Secrets Manager, Azure Key Vault, Kubernetes Secrets, Heroku Config Vars).
  • Rotate API tokens regularly.
  • Use principle of least privilege when granting access to secrets.

Step 5: Dependency Security

  • Regularly audit dependencies for known vulnerabilities:
bash
npm audit
npm audit fix
  • Use tools like Snyk or Dependabot for automated vulnerability scanning and updates.
  • Keep dependencies updated to latest stable versions.

Step 6: Logging Security

  • Be careful what you log. Never log sensitive information like:
    • Full API tokens/passwords
    • Complete credit card numbers
    • Personally identifiable information (PII) unnecessarily
  • Sanitize or redact sensitive data in logs.
  • Example: Log only first/last few characters of tokens for debugging.
javascript
logger.info(`Using API token: ${API_TOKEN.substring(0, 4)}...${API_TOKEN.substring(API_TOKEN.length - 4)}`);

Step 7: Error Response Security

  • In production, avoid exposing detailed error messages to clients that could reveal system internals.
  • Log detailed errors server-side, but return generic error messages to clients.
javascript
// Production error handling
if (process.env.NODE_ENV === 'production') {
    res.status(500).json({ error: 'An error occurred processing your request.' });
} else {
    res.status(500).json({ error: 'Failed to send SMS batch.', details: error.message });
}

8. Server Entry Point

Create the main server file that ties everything together.

File: server.mjs

javascript
// server.mjs
import 'dotenv/config'; // Load environment variables at the very start
import express from 'express';
import logger from './config/logger.mjs';
import smsRoutes from './routes/smsRoutes.mjs';

const app = express();
const PORT = process.env.PORT || 3000;

// Middleware
app.use(express.json()); // Parse JSON request bodies
app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies

// Routes
app.use('/api', smsRoutes);

// Health check endpoint
app.get('/health', (req, res) => {
    res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() });
});

// 404 handler
app.use((req, res) => {
    res.status(404).json({ error: 'Route not found' });
});

// Global error handler
app.use((err, req, res, next) => {
    logger.error(`Unhandled error: ${err.message}`, { stack: err.stack });
    res.status(500).json({ error: 'Internal server error' });
});

// Start server
app.listen(PORT, () => {
    logger.info(`Server started successfully on port ${PORT}`);
    logger.info(`Environment: ${process.env.NODE_ENV || 'development'}`);
    logger.info(`Health check available at http://localhost:${PORT}/health`);
});

// Graceful shutdown
process.on('SIGTERM', () => {
    logger.info('SIGTERM signal received: closing HTTP server');
    server.close(() => {
        logger.info('HTTP server closed');
    });
});

9. Testing Your Application

Manual Testing:

  1. Start your server:
bash
npm start
# or for development with auto-reload
npm run dev
  1. Test the health endpoint:
bash
curl http://localhost:3000/health
  1. Test the broadcast endpoint (replace with valid phone numbers):
bash
curl -X POST http://localhost:3000/api/broadcast \
  -H "Content-Type: application/json" \
  -d '{
    "recipients": ["+15551234567", "+15559876543"],
    "message": "Test message from Sinch bulk SMS app"
  }'

Automated Testing:

  • Unit Tests: Test individual functions (e.g., sendBulkSms) in isolation using mocks.
  • Integration Tests: Test the complete flow from API endpoint to Sinch API.
  • Recommended Libraries: Jest, Mocha, Chai, Supertest

Example Unit Test (conceptual):

javascript
// tests/sinchService.test.mjs
import { jest } from '@jest/globals';
import { sendBulkSms } from '../services/sinchService.mjs';

describe('sinchService', () => {
    it('should send bulk SMS successfully', async () => {
        // Mock fetch
        global.fetch = jest.fn(() =>
            Promise.resolve({
                ok: true,
                json: () => Promise.resolve({ id: 'test-batch-id' }),
            })
        );

        const result = await sendBulkSms(['+15551234567'], 'Test', '+15559999999');
        expect(result.id).toBe('test-batch-id');
        expect(fetch).toHaveBeenCalledTimes(1);
    });
});

10. Deployment Considerations

Environment Setup:

  • Set all environment variables in your deployment platform's configuration.
  • Never deploy .env files to production.
  • Use secure secret management services.

Recommended Platforms:

  • Heroku: Easy deployment with Config Vars for secrets.
  • AWS (EC2/ECS/Lambda): Flexible, scalable, use AWS Secrets Manager.
  • Google Cloud Platform: App Engine or Cloud Run with Secret Manager.
  • DigitalOcean: App Platform or Droplets with encrypted environment variables.
  • Docker/Kubernetes: Container-based deployment with ConfigMaps and Secrets.

Deployment Checklist:

  • All environment variables configured securely
  • HTTPS/SSL certificates installed
  • Rate limiting configured
  • Authentication/authorization implemented
  • Logging configured with proper levels
  • Error monitoring set up (e.g., Sentry, Rollbar)
  • Health check endpoint functional
  • Database migrations applied (if using database)
  • Backup and disaster recovery plan in place
  • Performance monitoring configured (e.g., New Relic, DataDog)

Scaling Considerations:

  • Horizontal Scaling: Deploy multiple instances behind a load balancer.
  • Queue-based Architecture: For high-volume broadcasts, use message queues (RabbitMQ, AWS SQS) to decouple request receipt from SMS sending.
  • Caching: Use Redis or Memcached for frequently accessed data.
  • Database Optimization: Connection pooling, read replicas, proper indexing.
  • Monitoring: Set up alerts for error rates, response times, and resource utilization.

11. Monitoring and Observability

Application Monitoring:

  • APM Tools: New Relic, Datadog, Dynatrace for performance monitoring.
  • Error Tracking: Sentry, Rollbar, Bugsnag for real-time error tracking.
  • Log Aggregation: ELK Stack (Elasticsearch, Logstash, Kibana), Splunk, CloudWatch Logs.

Key Metrics to Monitor:

  • Request rate and response times
  • Error rates (4xx, 5xx)
  • Sinch API success/failure rates
  • Batch submission latency
  • Database query performance
  • System resources (CPU, memory, disk)

Alerting:

Set up alerts for:

  • High error rates
  • API failures
  • Slow response times
  • Resource exhaustion

12. Best Practices and Common Pitfalls

Best Practices:

  1. Always validate E.164 format for phone numbers before sending to Sinch API.
  2. Implement exponential backoff for retries on transient failures.
  3. Use structured logging (JSON format) for better log analysis.
  4. Keep secrets out of code – use environment variables and secret managers.
  5. Implement rate limiting at multiple levels (IP, user, API key).
  6. Monitor Sinch API quotas and implement appropriate limits in your application.
  7. Handle opt-outs properly – maintain a database of opted-out users.
  8. Respect quiet hours – don't send marketing SMS at inappropriate times.
  9. Include opt-out instructions in marketing messages (e.g., "Reply STOP to unsubscribe").
  10. Test thoroughly with small batches before sending to large recipient lists.

Common Pitfalls:

  1. Incorrect region URL – causes authentication failures.
  2. Invalid phone number formats – non-E.164 numbers will be rejected.
  3. Missing error handling – leads to silent failures.
  4. Logging sensitive data – exposes PII and credentials.
  5. No rate limiting – vulnerable to abuse and DoS.
  6. Hardcoded credentials – security risk if code is exposed.
  7. Ignoring message length limits – unexpected costs from multi-part messages.
  8. Not handling opt-outs – legal compliance issues.
  9. Sending to inactive numbers – wasted costs and poor metrics.
  10. No monitoring – inability to detect and respond to issues quickly.

13. Additional Resources

Official Documentation:

Useful Tools:

Related Topics:

  • Webhook integration for delivery reports
  • Two-way SMS messaging
  • SMS analytics and reporting
  • Multi-channel messaging (SMS + Email + Push)
  • Compliance and regulations (TCPA, GDPR, CTIA guidelines)

Conclusion

You've now built a production-ready bulk SMS broadcast system using Sinch, Node.js, and Express. This implementation includes:

✅ Robust error handling and logging ✅ Input validation and security features ✅ Proper configuration management ✅ Retry mechanisms for reliability ✅ Scalable architecture patterns ✅ Testing and deployment guidance

Next Steps:

  1. Implement webhook handling for delivery reports
  2. Add database integration for subscriber management
  3. Build a web interface for campaign management
  4. Implement advanced features like message scheduling
  5. Add analytics and reporting capabilities

Remember to:

  • Test thoroughly before production deployment
  • Monitor your application continuously
  • Keep dependencies updated
  • Follow SMS compliance regulations
  • Implement proper opt-out handling
  • Document your API for consumers

Good luck with your SMS broadcasting application! 🚀