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:
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).
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:
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:
Under Your API Credentials, you will find your Service plan ID and API 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
# .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.
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.mjsimport fetch from'node-fetch';
import logger from'../config/logger.mjs'; // We will create this logger soonconstSERVICE_PLAN_ID = process.env.SINCH_SERVICE_PLAN_ID;
constAPI_TOKEN = process.env.SINCH_API_TOKEN;
constSINCH_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.
*/exportconstsendBulkSms = async (recipients, messageBody, sender, feedbackEnabled = false) => {
const endpoint = `${SINCH_REGION_URL}/xms/v1/${SERVICE_PLAN_ID}/batches`;
// Validate essential configurationif (!SERVICE_PLAN_ID || !API_TOKEN || !SINCH_REGION_URL || !sender) {
logger.error('Sinch configuration missing in environment variables.');
thrownewError('Sinch service configuration is incomplete.');
}
if (!recipients || recipients.length === 0) {
logger.error('No recipients provided for bulk SMS.');
thrownewError('Recipient list cannot be empty.');
}
if (!messageBody) {
logger.error('No message body provided for bulk SMS.');
thrownewError('Message body cannot be empty.');
}
// Construct the payload for the Sinch Batches APIconst payload = {
to: recipients, // Array of phone numbersfrom: sender, // Your Sinch number or Sender IDbody: messageBody, // The message contentfeedback_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 numberstry {
const response = awaitfetch(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)}`);
thrownewError(`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 routethrow 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.mjsimport express from'express';
import { sendBulkSms } from'../services/sinchService.mjs';
import logger from'../config/logger.mjs';
const router = express.Router();
constSINCH_NUMBER = process.env.SINCH_NUMBER;
// POST /api/broadcast// Endpoint to send a bulk SMS broadcast
router.post('/broadcast', async (req, res) => {
// Basic Input Validationconst { 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 functionconst sinchResponse = awaitsendBulkSms(recipients, message, SINCH_NUMBER);
// Respond to the client
res.status(202).json({ // 202 Accepted: Request received, processing startedmessage: '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
});
}
});
exportdefault 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.
{"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.
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.
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).
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.
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 = awaitretry(
async (bail, attempt) => {
logger.info(`Attempt ${attempt} to call Sinch API...`);
const response = awaitfetch(endpoint, { /* ... fetch options ... */ });
const responseData = await response.json(); // Need to handle non-JSON responses tooif (!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(newError(`Non-retriable Sinch API error: ${response.status}`)); // Stop retryingreturn; // Important: exit async function after bail
}
// Throw error to trigger retry for 5xx or 429thrownewError(`Sinch API request failed with status ${response.status}`);
}
return responseData; // Success
},
{
retries: 3, // Number of retriesfactor: 2, // Exponential backoff factorminTimeout: 1000, // Initial delay 1 secondmaxTimeout: 5000, // Max delay 5 secondsonRetry: (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):
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
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 = awaitgetActiveSubscribersByTag(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 tooif (!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 = awaitsendBulkSms(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.mjsimport logger from'../config/logger.mjs';
constAPI_KEYS = process.env.API_KEYS ? process.env.API_KEYS.split(',') : [];
exportconstauthenticateApiKey = (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();
};
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.mjsimport rateLimit from'express-rate-limit';
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutesmax: 100, // Limit each IP to 100 requests per windowMsmessage: 'Too many requests from this IP, please try again later.',
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headerslegacyHeaders: 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.
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! 🚀
Frequently asked questions
Use the Sinch Batches API endpoint within a Node.js Express app. This allows you to send a single message to multiple recipients efficiently, perfect for notifications and marketing campaigns.
The Sinch Batches API endpoint is a RESTful endpoint designed for sending the same SMS message content to multiple recipients in a single API request, improving efficiency over individual messages.
Node-fetch brings the familiar browser fetch API to Node.js, providing a lightweight and standard way to make HTTP requests to REST APIs like Sinch.
Use the Batches API for scenarios like sending notifications, running marketing campaigns, or any situation needing mass SMS distribution to multiple recipients simultaneously.
Yes, you can use a provisioned phone number or an approved Alphanumeric Sender ID as the sender of your SMS messages with Sinch. Configure this in your Sinch dashboard.
Initialize a Node.js project with npm, enable ESM, install Express, dotenv, node-fetch, and winston, and create the necessary project file structure.
Dotenv loads environment variables from a .env file into process.env, allowing you to store sensitive credentials like API keys securely outside your source code.
Implement try/catch blocks in your service layer to handle API errors and retry mechanisms for transient errors, using Winston to log errors for debugging and monitoring.
Log in to your Sinch Dashboard, navigate to SMS > APIs, and find your Service plan ID and API token under 'Your API Credentials'. Ensure you select the correct region.
The correct SINCH_REGION_URL for the US region is https://us.sms.api.sinch.com. Ensure you use the correct URL based on your Sinch account region to avoid authentication errors.
Create separate directories for config, routes, and services to organize logging, API endpoints, and the core Sinch interaction logic respectively.
ESM (ECMAScript Modules) is the standard module system for JavaScript and is required by some modern libraries like node-fetch v3+. Use .mjs extensions or "type": "module" in package.json.
Implement robust input validation, sanitize user inputs, and use environment variables to manage sensitive credentials, ensuring the .env file is added to .gitignore.
You'll need Node.js and npm installed, a Sinch account with a Service Plan ID and API token, a provisioned phone number or Alphanumeric Sender ID, and basic knowledge of JavaScript, Node.js, and REST APIs.
Winston is a logging library used for structured logging, allowing you to categorize logs by levels (error, warn, info, etc.) and output to different transports like the console or files.
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-fetchin many cases.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:
Prerequisites:
Service Plan IDandAPI Tokenfrom the Sinch Dashboard (SMS → APIs).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 bynode-fetchv3+ (ESM-only since v3.0.0) and recommended by modern Node.js practices. Note: Node.js 18+ includes nativefetch()support, makingnode-fetchoptional 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
-yflag accepts the default settings.This creates a
package.jsonfile.Step 3: Enable ESM
Open the generated
package.jsonfile 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" }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,
dotenvfor environment variables,node-fetchfor API calls, andwinstonfor logging.Optionally, install
nodemonas a development dependency for automatic server restarts on file changes:Step 5: Create Project Structure
Create the following directories and files:
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
.gitignoreCreate a
.gitignorefile in the root directory and add the following lines to prevent committing sensitive information and node modules:Step 7: Configure Environment Variables (
.env)Create a
.envfile in the root directory. Add your Sinch credentials and other configuration.Service plan IDandAPI token. Click the "Show" button for the token if needed.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.mjsThis 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
curlor a tool like Postman.curlExample: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.
dotenvlibrary to load credentials and settings from a.envfile intoprocess.env. This keeps sensitive information out of the source code.Service plan IDandAPI token. You may need to click "Show" to reveal the full token. Ensure you copy the complete values accurately.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).SINCH_NUMBERvariable in your.envfile..envfile only for local development..envto your.gitignorefile to prevent committing secrets to version control..envfile itself.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.mjsandsmsRoutes.mjs. Ensure it's used consistently for logging events, warnings, and errors.Step 3: Error Handling Strategy
smsRoutes.mjs). Logged as warnings (logger.warn) and returned to the client with a 400 status code..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.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.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.
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 }toxiproxyto simulate network failures or invalid responses during testing. Temporarily changeSINCH_API_TOKENin.envto test 401 errors.Log Analysis:
grep,jq, or dedicated log management systems (ELK stack, Splunk, Datadog Logs) to search and analyze logs (especially if using JSON format).errororwarnlevel messages to identify recurring issues.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):
Entity Relationship Diagram (Conceptual):
Data 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:
npx prisma migrate devto create and apply SQL migrations based onschema.prisma.Performance/Scale:
phoneNumber,isActive,tags).select: { phoneNumber: true }).Replacing Request Body Recipients:
In
smsRoutes.mjs, instead of directly usingreq.body.recipients, you would modify the route to accept criteria (like a tag) and fetch the corresponding recipients from the database using a service likesubscriberService.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
smsRoutes.mjs(checking types, presence, basic phone format). For production, use a dedicated library likejoiorexpress-validatorfor more robust schema validation.+followed by country code and number, no spaces/dashes) is standard. Validate rigorously. Libraries likegoogle-libphonenumbercan help parse and validate international numbers accurately.Step 2: Authentication and Authorization
// 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(); };// routes/smsRoutes.mjs import { authenticateApiKey } from '../middleware/auth.mjs'; router.post('/broadcast', authenticateApiKey, async (req, res) => { // ... existing code ... });Step 3: Rate Limiting
Protect your API from abuse and DoS attacks by implementing rate limiting.
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);Step 4: Environment Variable Security
.envfiles to version control (.gitignore).Step 5: Dependency Security
SnykorDependabotfor automated vulnerability scanning and updates.Step 6: Logging Security
logger.info(`Using API token: ${API_TOKEN.substring(0, 4)}...${API_TOKEN.substring(API_TOKEN.length - 4)}`);Step 7: Error Response Security
// 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:
npm start # or for development with auto-reload npm run devcurl -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:
sendBulkSms) in isolation using mocks.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:
.envfiles to production.Recommended Platforms:
Deployment Checklist:
Scaling Considerations:
11. Monitoring and Observability
Application Monitoring:
Key Metrics to Monitor:
Alerting:
Set up alerts for:
12. Best Practices and Common Pitfalls
Best Practices:
Common Pitfalls:
13. Additional Resources
Official Documentation:
Useful Tools:
Related Topics:
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:
Remember to:
Good luck with your SMS broadcasting application! 🚀