code examples
code examples
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-fetchin 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 browserfetchAPI to Node.js (v3+ is ESM-only, requires Node.js 12.20+). Alternative: Use Node.js nativefetch()available in Node.js 18+.dotenv: A zero-dependency module that loads environment variables from a.envfile intoprocess.env.winston: A versatile logging library for Node.js.
System Architecture:
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 IDandAPI Tokenfrom 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.
mkdir sinch-bulk-sms-app
cd sinch-bulk-sms-appStep 2: Initialize Node.js Project
Initialize the project using npm. The -y flag accepts the default settings.
npm init -yThis creates a package.json file.
Step 3: Enable ESM
Open the generated package.json file and add the following line to enable ESM syntax:
{
"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-fetchv3+. Using.mjsfile 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.
npm install express dotenv node-fetch winstonOptionally, install nodemon as a development dependency for automatic server restarts on file changes:
npm install --save-dev nodemonStep 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:
# .gitignore
# Dependencies
node_modules/
# Environment variables
.env
# Logs
logs/
*.log
# OS generated files
.DS_Store
Thumbs.dbStep 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:
- Log in to your Sinch Dashboard (https://dashboard.sinch.com/login).
- Navigate to SMS > APIs.
- Under Your API Credentials, you will find your
Service plan IDandAPI token. Click the "Show" button for the token if needed. - Select the appropriate Region (e.g., US, EU). The API endpoint URL depends on this.
- Navigate to Numbers > Your virtual numbers to find your provisioned Sinch number (or configure an Alphanumeric Sender ID under SMS > Sender IDs).
# .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=infoSINCH_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.
// 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
// 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:
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):
{
"message": "SMS batch submitted successfully to Sinch.",
"batch_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV"
}Expected Response (Validation Error - 400 Bad Request):
{
"error": "Invalid request: message must be a non-empty string."
}Expected Response (Server Error - 500 Internal Server Error):
{
"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
dotenvlibrary to load credentials and settings from a.envfile intoprocess.env. This keeps sensitive information out of the source code. - Obtaining Credentials from Sinch Dashboard:
- Log in to the Sinch Dashboard: https://dashboard.sinch.com/login
- Service Plan ID & API Token: Navigate to SMS -> APIs. In the "Your API Credentials" section, copy your
Service plan IDandAPI token. You may need to click "Show" to reveal the full token. Ensure you copy the complete values accurately. - 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_URLvariable in your.envfile (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). - 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_NUMBERvariable in your.envfile.
- Secure Handling:
- Use the
.envfile only for local development. - Crucially, add
.envto your.gitignorefile 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
.envfile itself.
- Use the
- 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
// 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
.envvariables insinchService.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...catchinsinchService.mjsor the unhandled exception/rejection handlers inlogger.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-retrylibrary (Install:npm install async-retry):
// 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
toxiproxyto simulate network failures or invalid responses during testing. Temporarily changeSINCH_API_TOKENin.envto 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
errororwarnlevel 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):
// 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 implementedEntity Relationship Diagram (Conceptual):
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 broadcastData Access Layer (Conceptual using Prisma):
// 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 subscribersMigrations:
- Prisma: Use
npx prisma migrate devto create and apply SQL migrations based onschema.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.
// 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 likejoiorexpress-validatorfor more robust schema validation. - Phone Numbers: E.164 format (
+followed by country code and number, no spaces/dashes) is standard. Validate rigorously. Libraries likegoogle-libphonenumbercan 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:
// 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:
// 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:
npm install express-rate-limit// 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
.envfiles 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:
npm audit
npm audit fix- Use tools like
SnykorDependabotfor 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.
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.
// 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
// 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:
- Start your server:
npm start
# or for development with auto-reload
npm run dev- Test the health endpoint:
curl http://localhost:3000/health- Test the broadcast endpoint (replace with valid phone numbers):
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):
// 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
.envfiles 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:
- Always validate E.164 format for phone numbers before sending to Sinch API.
- Implement exponential backoff for retries on transient failures.
- Use structured logging (JSON format) for better log analysis.
- Keep secrets out of code – use environment variables and secret managers.
- Implement rate limiting at multiple levels (IP, user, API key).
- Monitor Sinch API quotas and implement appropriate limits in your application.
- Handle opt-outs properly – maintain a database of opted-out users.
- Respect quiet hours – don't send marketing SMS at inappropriate times.
- Include opt-out instructions in marketing messages (e.g., "Reply STOP to unsubscribe").
- Test thoroughly with small batches before sending to large recipient lists.
Common Pitfalls:
- Incorrect region URL – causes authentication failures.
- Invalid phone number formats – non-E.164 numbers will be rejected.
- Missing error handling – leads to silent failures.
- Logging sensitive data – exposes PII and credentials.
- No rate limiting – vulnerable to abuse and DoS.
- Hardcoded credentials – security risk if code is exposed.
- Ignoring message length limits – unexpected costs from multi-part messages.
- Not handling opt-outs – legal compliance issues.
- Sending to inactive numbers – wasted costs and poor metrics.
- No monitoring – inability to detect and respond to issues quickly.
13. Additional Resources
Official Documentation:
- Sinch SMS API Documentation
- Sinch Batches API Reference
- Express.js Documentation
- Node.js Documentation
- Winston Logging Library
Useful Tools:
- E.164 Phone Number Validator
- Postman – API testing tool
- Insomnia – Alternative API testing tool
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:
- Implement webhook handling for delivery reports
- Add database integration for subscriber management
- Build a web interface for campaign management
- Implement advanced features like message scheduling
- 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! 🚀