This guide provides a step-by-step walkthrough for building a production-ready Node.js and Express application capable of sending bulk SMS messages efficiently using Amazon Simple Notification Service (SNS). We'll cover everything from initial project setup and AWS configuration to implementing core functionality, error handling, security, and deployment considerations.
By the end of this guide, you will have a functional API endpoint that accepts a list of phone numbers and a message, then leverages AWS SNS to dispatch those messages reliably and at scale. This solves the common need for applications to send notifications, alerts, or marketing messages via SMS to multiple recipients simultaneously.
Project Overview and Goals
What We're Building:
- A simple REST API built with Node.js and Express.
- An endpoint (
POST /api/sms/bulk-send
) that accepts a JSON payload containing an array of phone numbers (in E.164 format) and a message string. - Integration with AWS SNS to handle the actual SMS dispatching.
- Secure handling of AWS credentials.
- Basic error handling, logging, and rate limiting.
Technologies Used:
- Node.js: A JavaScript runtime environment for server-side development.
- Express: A minimal and flexible Node.js web application framework.
- AWS SDK for JavaScript (v2): Enables Node.js applications to interact with AWS services, including SNS. (Note: AWS recommends migrating to v3, but v2 is still widely used).
- AWS SNS: A fully managed messaging service for both application-to-application (A2A) and application-to-person (A2P) communication, including SMS.
- dotenv: A module to load environment variables from a
.env
file. - express-validator: Middleware for request validation.
- express-rate-limit: Middleware for basic rate limiting.
Why these technologies?
- Node.js/Express: Provide a fast, efficient, and widely adopted platform for building APIs. The asynchronous nature of Node.js is well-suited for I/O-bound tasks like making API calls to AWS.
- AWS SNS: Offers a scalable, reliable, and cost-effective way to send SMS globally without managing complex carrier relationships. It handles retries, opt-out management, and provides delivery statistics.
- dotenv: Best practice for managing sensitive configuration like API keys outside of source code.
- express-validator/express-rate-limit: Essential tools for building secure and robust APIs.
System Architecture:
+-------------+ +---------------------+ +---------+ +-----------------+
| Client | ----> | Node.js/Express API | ----> | AWS SNS | ----> | SMS Recipients |
| (e.g., Web, | | (Our Application) | | Service | | (Mobile Phones) |
| Mobile App) | +---------------------+ +---------+ +-----------------+
| | | - Receives request | | |
| POST | | - Validates input | | |
| /api/sms/ | | - Iterates numbers | | |
| bulk-send | | - Calls SNS Publish | | |
+-------------+ +---------------------+ +---------+ +-----------------+
| | |
| +---- Logs & Metrics ----> CloudWatch
|
+------------<---------+
Response (Success/Error)
Prerequisites:
- Node.js and npm (or yarn): Installed on your development machine. (Download Node.js)
- AWS Account: You need an active AWS account. (Create AWS Account)
- AWS IAM User: An IAM (Identity and Access Management) user with programmatic access and permissions to publish messages via SNS.
- Basic Terminal/Command Line Knowledge: Familiarity with navigating directories and running commands.
- Text Editor or IDE: Like VS Code_ Sublime Text_ WebStorm_ etc.
Expected Outcome:
A running Express server with a single API endpoint that can successfully send SMS messages to a list of provided phone numbers via AWS SNS.
1. Setting Up the Project
Let's start by creating our project directory_ initializing Node.js_ and installing necessary dependencies.
1.1. Create Project Directory:
Open your terminal or command prompt and create a new directory for the project_ then navigate into it.
mkdir node-sns-bulk-sms
cd node-sns-bulk-sms
1.2. Initialize Node.js Project:
Initialize the project using npm. The -y
flag accepts default settings.
npm init -y
This creates a package.json
file.
1.3. Install Dependencies:
We need Express for the server_ the AWS SDK to interact with SNS_ dotenv for environment variables_ express-validator for input validation_ and express-rate-limit for security.
npm install express aws-sdk dotenv express-validator express-rate-limit
1.4. Project Structure:
Create a basic structure to organize our code:
mkdir src
mkdir src/config src/controllers src/routes src/services src/utils
touch src/server.js src/app.js .env .gitignore
touch src/config/aws.js
touch src/controllers/sms.controller.js
touch src/routes/sms.routes.js
touch src/services/sns.service.js
touch src/utils/logger.js
Your structure should look like this:
node-sns-bulk-sms/
├── node_modules/
├── src/
│ ├── config/
│ │ └── aws.js
│ ├── controllers/
│ │ └── sms.controller.js
│ ├── routes/
│ │ └── sms.routes.js
│ ├── services/
│ │ └── sns.service.js
│ ├── utils/
│ │ └── logger.js
│ ├── app.js # Express app configuration
│ └── server.js # Server startup logic
├── .env # Environment variables (AWS Keys_ etc.) - DO NOT COMMIT
├── .gitignore # Specifies intentionally untracked files that Git should ignore
├── package.json
└── package-lock.json
1.5. Configure .gitignore
:
It's crucial never to commit sensitive information like AWS keys or environment-specific files. Add the following to your .gitignore
file:
# Dependencies
node_modules/
# Environment variables
.env
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Optional IDE specific files
.idea/
.vscode/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# OS generated files
.DS_Store
Thumbs.db
1.6. Set up AWS IAM User and Credentials:
This is a critical security step.
- Navigate to IAM: Log in to your AWS Management Console and navigate to the IAM service.
- Create User: Go to "Users" and click "Add users".
- Enter a descriptive User name (e.g._
sns-bulk-sms-api-user
). - Select Access key - Programmatic access for the AWS credential type. Click "Next: Permissions".
- Enter a descriptive User name (e.g._
- Set Permissions:
- Choose "Attach existing policies directly".
- Search for
AmazonSNSFullAccess
. - Important Note on Permissions: For production environments_ following the principle of least privilege is highly recommended. Instead of using the broad
AmazonSNSFullAccess
policy_ create a custom IAM policy granting only the specific permissions required by this application. The minimal permissions typically needed are:sns:Publish
: To send SMS messages.sns:SetSMSAttributes
: To set attributes likeSMSType
orSenderID
.sns:CheckIfPhoneNumberIsOptedOut
: (Optional) If using the opt-out checking feature.sns:ListPhoneNumbersOptedOut
: (Optional) If you need to retrieve the full list of opted-out numbers.
- However_ for simplicity in this initial setup guide_ we will attach the pre-existing
AmazonSNSFullAccess
policy. SelectAmazonSNSFullAccess
. Click "Next: Tags".
- Tags (Optional): Add any tags if needed (e.g._
Project: bulk-sms-api
). Click "Next: Review". - Review and Create: Review the details and click "Create user".
- IMPORTANT - Save Credentials: You will be shown the Access key ID and Secret access key. This is the only time the Secret access key will be shown. Copy both values immediately and store them securely. We will put them in our
.env
file.
1.7. Configure Environment Variables (.env
):
Open the .env
file in your project root and add your AWS credentials and the AWS region you want to use for SNS. Choose a region that supports SMS messaging (e.g._ us-east-1
_ eu-west-1
_ ap-southeast-1
_ ap-southeast-2
). Refer to AWS documentation for the latest list.
Remember to replace the placeholder values below with your actual credentials.
# .env
# AWS Credentials - Replace with your actual keys!
AWS_ACCESS_KEY_ID=YOUR_ACCESS_KEY_ID_HERE # <-- Replace with your key
AWS_SECRET_ACCESS_KEY=YOUR_SECRET_ACCESS_KEY_HERE # <-- Replace with your secret
# AWS Region - Choose a region that supports SNS SMS
AWS_REGION=us-east-1 # Example: us-east-1
# Server Configuration
PORT=3000
# Optional: Default SMS Type for SNS
# Options: Promotional (cost-optimized) or Transactional (reliability-optimized)
# Transactional is often preferred for OTPs_ alerts etc. as it bypasses DND in some regions.
DEFAULT_SMS_TYPE=Transactional
2. Implementing Core Functionality (SNS Service)
Now_ let's write the service that interacts with AWS SNS.
2.1. Configure AWS SDK Client:
We'll centralize the AWS SDK configuration.
// src/config/aws.js
const AWS = require('aws-sdk');
require('dotenv').config(); // Load .env variables
// Configure the AWS SDK
AWS.config.update({
accessKeyId: process.env.AWS_ACCESS_KEY_ID_
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY_
region: process.env.AWS_REGION
});
// Create an SNS service object
const sns = new AWS.SNS({ apiVersion: '2010-03-31' });
module.exports = { sns };
- We import
aws-sdk
anddotenv
. dotenv.config()
loads the variables from.env
intoprocess.env
.AWS.config.update
configures the SDK globally with the credentials and region.- We create and export an SNS client instance using the specified API version.
2.2. Implement the SNS Sending Logic:
This service will contain the function to send SMS messages.
// src/services/sns.service.js
const { sns } = require('../config/aws');
const logger = require('../utils/logger');
/**
* Sends a single SMS message using AWS SNS.
* @param {string} phoneNumber - The recipient phone number in E.164 format (e.g._ +12223334444).
* @param {string} message - The text message content.
* @param {string} [messageType='Transactional'] - The type of SMS (Promotional or Transactional).
* @returns {Promise<object>} - Promise resolving with SNS publish response data.
* @throws {Error} - Throws error if SNS publish fails.
*/
async function sendSms(phoneNumber, message, messageType = process.env.DEFAULT_SMS_TYPE || 'Transactional') {
const params = {
Message: message,
PhoneNumber: phoneNumber,
MessageAttributes: {
'AWS.SNS.SMS.SMSType': {
DataType: 'String',
StringValue: messageType
}
// Optional: Add SenderID here if needed and supported
// 'AWS.SNS.SMS.SenderID': {
// DataType: 'String',
// StringValue: 'MySenderID' // Must be pre-approved by AWS in some regions
// }
}
};
try {
logger.info(`Attempting to send SMS to ${phoneNumber} (Type: ${messageType})`);
const publishResponse = await sns.publish(params).promise();
logger.info(`SMS sent successfully to ${phoneNumber}. MessageID: ${publishResponse.MessageId}`);
return publishResponse;
} catch (error) {
logger.error(`Failed to send SMS to ${phoneNumber}. Error: ${error.message}`, error);
// Rethrow the error to be handled by the controller
throw error;
}
}
/**
* Sends SMS messages to multiple phone numbers. Handles partial failures.
* @param {string[]} phoneNumbers - Array of phone numbers in E.164 format.
* @param {string} message - The message content.
* @param {string} [messageType='Transactional'] - SMS type.
* @returns {Promise<object>} - Object containing results for success and failure.
*/
async function sendBulkSms(phoneNumbers, message, messageType = process.env.DEFAULT_SMS_TYPE || 'Transactional') {
const results = {
successful: [],
failed: []
};
// Use Promise.allSettled to send all messages concurrently and capture all outcomes
const promises = phoneNumbers.map(number =>
sendSms(number, message, messageType)
.then(response => ({ status: 'fulfilled', value: { phoneNumber: number, messageId: response.MessageId } }))
.catch(error => ({ status: 'rejected', reason: { phoneNumber: number, error: error.message } }))
);
// Wait for all promises to settle
const outcomes = await Promise.allSettled(promises);
outcomes.forEach(outcome => {
if (outcome.status === 'fulfilled' && outcome.value.status === 'fulfilled') {
// Successfully sent SMS (inner promise resolved)
results.successful.push(outcome.value.value);
} else {
// Handle both promise rejections and SNS errors caught within sendSms
const reason = (outcome.status === 'rejected') ? outcome.reason : outcome.value.reason;
results.failed.push(reason);
logger.warn(`Failed delivery recorded for ${reason.phoneNumber}. Reason: ${reason.error}`);
}
});
logger.info(`Bulk SMS sending completed. Successful: ${results.successful.length}, Failed: ${results.failed.length}`);
return results;
}
/**
* Optional: Function to check if a phone number has opted out.
* @param {string} phoneNumber - Phone number in E.164 format.
* @returns {Promise<boolean>} - True if opted out, false otherwise.
*/
async function checkOptOut(phoneNumber) {
try {
const response = await sns.checkIfPhoneNumberIsOptedOut({ phoneNumber }).promise();
logger.info(`Checked opt-out status for ${phoneNumber}: ${response.isOptedOut}`);
return response.isOptedOut;
} catch (error) {
logger.error(`Failed to check opt-out status for ${phoneNumber}: ${error.message}`, error);
// Assume not opted out on error, or handle differently based on requirements
return false;
}
}
module.exports = {
sendSms,
sendBulkSms,
checkOptOut,
// Add other SNS functions here if needed (listOptedOut, setAttributes etc.)
};
sendSms
Function:- Takes phone number, message, and optional
messageType
. - Constructs the
params
object required bysns.publish
.PhoneNumber
: Must be in E.164 format (e.g.,+14155552671
).Message
: The content of the SMS.MessageAttributes
: Used to set specific SMS properties.AWS.SNS.SMS.SMSType
is crucial for differentiating betweenPromotional
andTransactional
messages. Transactional often has higher deliverability, especially to numbers that might have opted out of promotional content (e.g., on Do Not Disturb/DND lists). However, regulations and deliverability characteristics vary significantly by country, influencing which type is more appropriate or required. Check AWS SNS pricing and regional regulations.
- Uses
sns.publish(params).promise()
to send the message asynchronously. - Includes basic logging for success and failure.
- Rethrows errors so they can be handled upstream.
- Takes phone number, message, and optional
sendBulkSms
Function:- Takes an array of
phoneNumbers
and themessage
. - Uses
Promise.allSettled
to attempt sending to all numbers concurrently. This ensures all attempts complete, regardless of individual failures. - Iterates through outcomes, categorizing them into
successful
andfailed
. - Logs the overall outcome and returns detailed results.
- Takes an array of
checkOptOut
Function (Optional):- Demonstrates how to use
checkIfPhoneNumberIsOptedOut
.
- Demonstrates how to use
2.3. Create a Simple Logger Utility:
// src/utils/logger.js
// Basic logger using console. In production, consider a robust library like Winston or Pino.
const logger = {
info: (message) => {
console.log(`[INFO] ${new Date().toISOString()}: ${message}`);
},
warn: (message) => {
console.warn(`[WARN] ${new Date().toISOString()}: ${message}`);
},
error: (message, error = null) => {
// Include stack trace if error object is provided
const errorDetails = error instanceof Error ? `\n${error.stack}` : (error ? `\n${JSON.stringify(error)}` : '');
console.error(`[ERROR] ${new Date().toISOString()}: ${message}${errorDetails}`);
},
debug: (message) => {
// Optionally enable debug logging based on environment variable
if (process.env.NODE_ENV === 'development') {
console.debug(`[DEBUG] ${new Date().toISOString()}: ${message}`);
}
}
};
module.exports = logger;
3. Building the API Layer (Express)
Now let's set up the Express server and define the API endpoint.
3.1. Configure the Express App:
// src/app.js
const express = require('express');
const rateLimit = require('express-rate-limit');
const smsRoutes = require('./routes/sms.routes');
const logger = require('./utils/logger');
const app = express();
// --- Middleware ---
// 1. Enable JSON body parsing
app.use(express.json());
// 2. Basic Rate Limiting
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 after 15 minutes',
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
handler: (req, res, /*next, options*/) => {
logger.warn(`Rate limit exceeded for IP: ${req.ip}`);
res.status(429).json({
status: 'error',
message: 'Too many requests, please try again later.'
});
}
});
app.use('/api/', limiter); // Apply limiter specifically to API routes or globally if preferred
// --- Routes ---
app.get('/health', (req, res) => {
res.status(200).json({ status: 'UP' }); // Simple health check endpoint
});
app.use('/api/sms', smsRoutes); // Mount SMS routes
// --- Error Handling ---
// 404 Handler for undefined routes
app.use((req, res, next) => {
res.status(404).json({
status: 'error',
message: 'Resource not found'
});
});
// Global error handler
app.use((err, req, res, next) => {
logger.error(`Unhandled error: ${err.message}`, err); // Log the full error object including stack
// Avoid sending detailed errors to client in production
const statusCode = err.statusCode || 500;
const message = (process.env.NODE_ENV === 'production' && statusCode === 500)
? 'An internal server error occurred'
: err.message;
res.status(statusCode).json({
status: 'error',
message: message,
// Optionally include stack trace in development
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
});
});
module.exports = app;
- Sets up Express, JSON parsing, and rate limiting.
- Defines a health check endpoint and mounts the SMS routes.
- Includes 404 and global error handlers.
3.2. Define API Routes and Validation:
// src/routes/sms.routes.js
const express = require('express');
const { body, validationResult } = require('express-validator');
const smsController = require('../controllers/sms.controller');
const logger = require('../utils/logger');
const router = express.Router();
// Validation middleware for the bulk send endpoint
const validateBulkSend = [
body('phoneNumbers')
.isArray({ min: 1 }).withMessage('phoneNumbers must be an array with at least one number.')
.custom((numbers) => {
// Basic E.164 format check (starts with +, followed by digits)
const e164Regex = /^\+[1-9]\d{1,14}$/;
const invalidNumbers = numbers.filter(num => typeof num !== 'string' || !e164Regex.test(num));
if (invalidNumbers.length > 0) {
throw new Error(`Invalid E.164 phone number format found: ${invalidNumbers.join(', ')}`);
}
return true;
}),
body('message')
.isString().withMessage('Message must be a string.')
.notEmpty().withMessage('Message cannot be empty.')
.isLength({ max: 1600 }).withMessage('Message exceeds maximum length (check SNS limits).'), // Check current SNS limits
body('messageType')
.optional()
.isIn(['Promotional', 'Transactional']).withMessage('messageType must be either Promotional or Transactional.')
];
// Middleware to handle validation results
const handleValidationErrors = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
logger.warn(`Validation failed for /bulk-send: ${JSON.stringify(errors.array())}`);
return res.status(400).json({ errors: errors.array() });
}
next();
};
// --- Routes ---
// POST /api/sms/bulk-send
router.post(
'/bulk-send',
validateBulkSend, // Apply validation rules
handleValidationErrors, // Handle any validation errors
smsController.handleBulkSend // Pass to the controller if valid
);
// Optional: Add route for checking opt-out status
// router.get('/opt-out-status/:phoneNumber', smsController.handleCheckOptOut);
module.exports = router;
- Uses
express-validator
for robust input validation (array check, E.164 format regex, message length, messageType enum). - Includes middleware to return 400 Bad Request on validation failure.
- Defines the
POST /api/sms/bulk-send
route.
3.3. Implement the Controller:
// src/controllers/sms.controller.js
const snsService = require('../services/sns.service');
const logger = require('../utils/logger');
/**
* Handles the incoming request for sending bulk SMS.
*/
async function handleBulkSend(req, res, next) {
const { phoneNumbers, message, messageType } = req.body; // messageType is optional
try {
logger.info(`Received bulk SMS request. Count: ${phoneNumbers.length}`);
// Optional: Pre-check opt-out status logic could go here if needed
// const numbersToSend = [];
// for (const number of phoneNumbers) {
// const isOptedOut = await snsService.checkOptOut(number);
// if (!isOptedOut) {
// numbersToSend.push(number);
// } else {
// logger.warn(`Skipping opted-out number: ${number}`);
// // Potentially add to a 'skipped' list in the response
// }
// }
// if (numbersToSend.length === 0) { // Handle case where all numbers are opted out
// return res.status(200).json({ status: 'success', message: 'No messages sent; all provided numbers are opted out.', results: { successful: [], failed: [] }});
// }
// Pass the original list or filtered list depending on opt-out handling
const results = await snsService.sendBulkSms(phoneNumbers, message, messageType);
const responseStatus = results.failed.length === 0 ? 200 : 207; // 200 OK or 207 Multi-Status
res.status(responseStatus).json({
status: responseStatus === 200 ? 'success' : 'partial_success',
message: `Bulk SMS processing complete. Successful: ${results.successful.length}, Failed: ${results.failed.length}`,
results: results
});
} catch (error) {
// Log the specific error from the controller level
logger.error(`Error in handleBulkSend controller`, error);
// Pass the error to the global error handler middleware
// Set a specific status code if identifiable (e.g., from AWS SDK errors)
error.statusCode = error.statusCode || 500;
next(error);
}
}
/**
* Optional: Handles checking opt-out status for a single number.
*/
// async function handleCheckOptOut(req, res, next) {
// // Parameter validation (e.g., using express-validator param check) should be added here
// const phoneNumber = req.params.phoneNumber;
// // Ensure phoneNumber starts with '+' if needed for validation consistency
// const validatedNumber = phoneNumber.startsWith('+') ? phoneNumber : `+${phoneNumber}`;
// try {
// // E.164 format validation should be performed before calling the service
// const isOptedOut = await snsService.checkOptOut(validatedNumber);
// res.status(200).json({ phoneNumber: validatedNumber, isOptedOut });
// } catch (error) {
// logger.error(`Error checking opt-out status for ${validatedNumber}`, error);
// next(error);
// }
// }
module.exports = {
handleBulkSend,
// handleCheckOptOut // Keep commented out unless implementing the route
};
- Orchestrates the request flow: extracts data, calls the service, formats the response (200 OK or 207 Multi-Status).
- Includes
try...catch
to pass errors to the global handler. - The commented-out
handleCheckOptOut
function demonstrates structure but requires validation and route setup if used.
3.4. Create the Server Entry Point:
This file starts the Express server and includes graceful shutdown logic.
// src/server.js
require('dotenv').config(); // Ensure env vars are loaded first
const app = require('./app');
const logger = require('./utils/logger');
const PORT = process.env.PORT || 3000;
// Start the server and store the server instance
const server = app.listen(PORT, () => {
logger.info(`Server listening on port ${PORT}`);
logger.info(`Current Environment: ${process.env.NODE_ENV || 'development'}`);
logger.info(`AWS Region: ${process.env.AWS_REGION}`);
logger.info(`Default SMS Type: ${process.env.DEFAULT_SMS_TYPE || 'Transactional'}`);
// Verify essential environment variables
if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY || !process.env.AWS_REGION) {
logger.error('FATAL ERROR: AWS credentials or region not configured in .env file. Exiting.');
process.exit(1); // Exit if essential config is missing
}
});
// Graceful shutdown logic
const shutdown = (signal) => {
logger.info(`${signal} signal received: closing HTTP server`);
server.close(() => {
logger.info('HTTP server closed');
// Add cleanup logic here (e.g., close database connections)
process.exit(0);
});
// Force shutdown if server hasn't closed in time
setTimeout(() => {
logger.error('Could not close connections in time, forcefully shutting down');
process.exit(1);
}, 10000); // 10 seconds timeout
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT')); // Catches Ctrl+C
- Loads environment variables and the Express app.
- Starts the server using
app.listen
and assigns the return value toserver
. - Logs configuration details and checks for essential AWS variables.
- Includes signal handlers (
SIGTERM
,SIGINT
) that callserver.close()
for graceful shutdown.
3.5. Add Start Script to package.json
:
Add a script to easily start your server.
// package.json (add within the "scripts" object)
{
"name": "node-sns-bulk-sms",
"version": "1.0.0",
"description": "",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"aws-sdk": "^2.1000.0", // Example version, use actual installed version
"dotenv": "^10.0.0",
"express": "^4.17.1",
"express-rate-limit": "^5.5.1",
"express-validator": "^6.13.0"
},
"devDependencies": {
"nodemon": "^2.0.15" // Optional for development
}
}
- Provides
start
script and optionaldev
script usingnodemon
(install withnpm install --save-dev nodemon
). Ensure the JSON is valid.
4. Testing the API
Start your server:
npm start
# or if using nodemon:
# npm install --save-dev nodemon
# npm run dev
Use curl
or an API client like Postman to test the endpoint http://localhost:3000/api/sms/bulk-send
.
Using curl
:
curl -X POST http://localhost:3000/api/sms/bulk-send \
-H ""Content-Type: application/json"" \
-d '{
""phoneNumbers"": [""+1XXXXXXXXXX"", ""+1YYYYYYYYYY""],
""message"": ""Hello from Node.js SNS Bulk Sender! Test: '\''$(date)'\''"",
""messageType"": ""Transactional""
}'
Important: Replace +1XXXXXXXXXX
and +1YYYYYYYYYY
with valid phone numbers in E.164 format that you can check. Note the escaped single quotes ('\''
) within the JSON string in the curl
command to correctly embed the output of the date
command.
Expected Responses:
- 200 OK: All messages were accepted by SNS for delivery (check logs for details).
{ ""status"": ""success"", ""message"": ""Bulk SMS processing complete. Successful: 2, Failed: 0"", ""results"": { ""successful"": [ { ""phoneNumber"": ""+1XXXXXXXXXX"", ""messageId"": ""uuid-..."" }, { ""phoneNumber"": ""+1YYYYYYYYYY"", ""messageId"": ""uuid-..."" } ], ""failed"": [] } }
- 207 Multi-Status: Some messages were accepted, others failed (e.g., invalid number format passed validation but rejected by SNS, or an internal SNS error occurred for one number).
{ ""status"": ""partial_success"", ""message"": ""Bulk SMS processing complete. Successful: 1, Failed: 1"", ""results"": { ""successful"": [ { ""phoneNumber"": ""+1XXXXXXXXXX"", ""messageId"": ""uuid-..."" } ], ""failed"": [ { ""phoneNumber"": ""+1YYYYYYYYYY"", ""error"": ""Invalid parameter: PhoneNumber"" } ] } }
- 400 Bad Request: Input validation failed (check
errors
array in response).{ ""errors"": [ { ""value"": [""+123""], ""msg"": ""Invalid E.164 phone number format found: +123"", ""param"": ""phoneNumbers"", ""location"": ""body"" } ] }
- 429 Too Many Requests: Rate limit exceeded.
{ ""status"": ""error"", ""message"": ""Too many requests, please try again later."" }
- 500 Internal Server Error: An unexpected error occurred (check server logs).
Check your application logs for detailed information on each request and SNS interaction. Check the recipient phones for the actual SMS delivery (delivery is asynchronous and not guaranteed by a 200/207 response).
5. Error Handling, Logging, and Retries
- Error Handling: Implemented via
try...catch
blocks in async functions, the global Express error handler, andexpress-validator
for input validation. Consider adding specificcatch
blocks for known AWS SDK error codes (e.g.,error.code === 'ThrottlingException'
) in the service layer if fine-grained handling is needed. - Logging: Basic console logging is provided via the
logger
utility. In production, replace this with a more robust library like Winston or Pino configured to output structured logs (JSON) and ship them to a centralized logging system (e.g., AWS CloudWatch Logs, ELK stack, Datadog). - Retries:
- SNS Internal Retries: SNS automatically retries delivery to carrier networks for a period.
- AWS SDK Retries: The AWS SDK has built-in retry logic for transient network errors or throttled API calls when publishing to SNS. This is generally sufficient for the API call itself.
- Application-Level Retries: Implement custom retry logic in
sendBulkSms
only if you need to handle specific, non-transient failures differently (e.g., retrying failed numbers after a delay, perhaps with a different message type). Use libraries likeasync-retry
carefully to avoid infinite loops or overwhelming downstream systems. For most use cases, relying on SDK and SNS retries is preferred.
6. Database Schema and Data Layer (Considerations)
For a production system, simply sending messages isn't enough; you need to track their status and potentially store related information.
- Tracking: Use AWS SNS Delivery Status Logging. Configure SNS to send delivery status events (e.g.,
delivered
,failed
,opted_out
) to CloudWatch Logs, an SQS queue, or a Lambda function. Process these events to update the status of individual messages. - Database: Store information about each bulk send operation and individual messages. This allows querying status, auditing, and analytics.
Conceptual Prisma Schema Example:
// schema.prisma
datasource db {
provider = ""postgresql"" // or mysql, sqlite, etc.
url = env(""DATABASE_URL"")
}
generator client {
provider = ""prisma-client-js""
}
model BulkSendBatch {
id String @id @default(cuid())
createdAt DateTime @default(now())
message String
messageType String // ""Promotional"" or ""Transactional""
status String // e.g., ""PENDING"", ""PROCESSING"", ""COMPLETED"", ""PARTIAL_FAILURE""
totalCount Int
successCount Int @default(0)
failureCount Int @default(0)
messages SmsMessage[]
}
model SmsMessage {
id String @id @default(cuid())
batchId String
batch BulkSendBatch @relation(fields: [batchId], references: [id])
phoneNumber String @db.VarChar(20) // E.164 format
snsMessageId String? @unique // The MessageID returned by SNS publish
status String // e.g., ""ACCEPTED"", ""SENT"", ""DELIVERED"", ""FAILED"", ""OPTED_OUT""
statusReason String? // Reason for failure or details
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([batchId])
@@index([phoneNumber])
@@index([status])
}
- This requires adding a database and ORM (like Prisma or Sequelize) to your project.
- Your API controller would create a
BulkSendBatch
record, then createSmsMessage
records (initially withACCEPTED
status and thesnsMessageId
) as messages are sent. - A separate process (e.g., Lambda triggered by SNS status logs) would update the
SmsMessage
status based on delivery events.
7. Security Features
- Input Validation: Handled by
express-validator
. Keep validation rules strict. - Rate Limiting: Basic protection via
express-rate-limit
. Consider more sophisticated strategies (e.g., tiered limits, per-user limits if authenticated). - AWS Credentials Security: Using
.env
+.gitignore
is suitable for local development but insecure for production.- Production Best Practice: Use IAM Roles when deploying on AWS services (EC2, ECS, Fargate, Lambda). The SDK automatically retrieves temporary credentials from the instance metadata or execution environment, eliminating the need to store long-lived keys.
- If not on AWS, use AWS Secrets Manager or HashiCorp Vault to store and retrieve credentials securely at runtime.
- Authentication/Authorization: Crucial for production. This API endpoint should not be public. Implement protection:
- API Keys: Simple method for server-to-server communication. Generate keys, store them securely, and require clients to send them in a header (e.g.,
X-API-Key
). Validate the key on the server. - JWT (JSON Web Tokens): Standard for user authentication or service accounts.
- OAuth2: More complex, suitable for third-party integrations or user-delegated access.
- API Keys: Simple method for server-to-server communication. Generate keys, store them securely, and require clients to send them in a header (e.g.,
- HTTPS: Mandatory for production. Encrypts data in transit.
- On AWS: Use an Application Load Balancer (ALB) with AWS Certificate Manager (ACM) for free TLS certificates.
- Elsewhere: Use Nginx or Apache as a reverse proxy with Let's Encrypt certificates.
8. Handling Special Cases
- E.164 Format: Enforced via validation (
^\+[1-9]\d{1,14}$
). Ensure clients always provide numbers in this international format. - Opt-Outs: SNS automatically handles opt-outs based on standard keywords (STOP, UNSUBSCRIBE).
- Use
checkIfPhoneNumberIsOptedOut
before sending if you need to avoid attempting delivery to known opted-out numbers (saves cost, improves reporting). - Monitor SNS Delivery Status Logs for
opted_out
events. - Respect user preferences and comply with regulations like TCPA (US), GDPR (EU), etc.
- Use
- Transactional vs. Promotional:
- Cost: Pricing can differ.
- Deliverability: Transactional messages often have higher priority and may bypass DND lists in some countries (e.g., India), making them suitable for OTPs, alerts, etc. Promotional messages are for marketing and are more likely to be filtered.
- Regulations: Some countries have strict rules about message types and sending times, especially for promotional content.
- Sender ID: A custom name/number displayed as the sender (e.g., ""MyCompany"").
- Support varies by country. Some require pre-registration (alphanumeric Sender IDs). Some only allow numeric Sender IDs (like virtual numbers purchased through SNS or other providers).
- Set using the
AWS.SNS.SMS.SenderID
message attribute. Check AWS documentation for regional requirements.
- Character Limits & Encoding:
- Standard SMS messages use GSM-7 encoding (160 characters per segment).
- Using non-GSM characters (like emojis or certain accented letters) switches to UCS-2 encoding, reducing the limit to 70 characters per segment.
- Long messages are split into multiple segments, each billed separately. Be mindful of message length to control costs. SNS handles the segmentation.
9. Performance Optimizations
- Asynchronous Operations: The use of
async/await
andPromise.allSettled
ensures non-blocking I/O when calling the AWS SDK, maximizing throughput. - SNS Scaling: AWS SNS is highly scalable and handles the underlying SMS infrastructure. Your application's bottleneck is more likely to be CPU/memory limits of your server instance or Node.js event loop congestion under extreme load, rather than SNS itself.
- Connection Re-use: The AWS SDK for JavaScript handles underlying HTTP connection pooling and re-use automatically. Instantiating the
sns
client once (as done insrc/config/aws.js
) is the correct approach. - Payload Size: Keep API request payloads reasonable. While SNS can handle many numbers, sending extremely large arrays (
phoneNumbers
) in a single API call increases memory usage and request processing time. Consider batching large lists into multiple API calls if necessary. - Caching: If
checkOptOut
is used frequently for the same numbers, consider caching the results (with a reasonable TTL) to reduce redundant API calls to SNS. Use an in-memory cache (likenode-cache
) or an external cache (like Redis or Memcached).
10. Monitoring, Observability, and Analytics
- AWS CloudWatch Metrics: Monitor key SNS metrics in the AWS console:
NumberOfMessagesPublished
: How many publish requests your app made.NumberOfNotificationsDelivered
: Successful deliveries to handsets (requires Delivery Status Logging).NumberOfNotificationsFailed
: Failed deliveries (requires Delivery Status Logging).SMSMonthToDateSpentUSD
: Monitor costs.- Set CloudWatch Alarms on these metrics (e.g., high failure rate, exceeding budget).
- AWS CloudWatch Logs:
- Application Logs: Ship logs from your Node.js application (using Winston/Pino) to CloudWatch Logs for centralized analysis and troubleshooting.
- SNS Delivery Status Logging: Crucial for production. Configure SNS to log the status of every SMS message attempt to CloudWatch Logs. This provides detailed delivery receipts (success, failure reason, carrier info).
- Health Checks: Use the
/health
endpoint with monitoring services (like CloudWatch Synthetics, UptimeRobot) to ensure the API is responsive. - Distributed Tracing: For more complex microservice architectures, implement distributed tracing using AWS X-Ray or open standards like OpenTelemetry to track requests as they flow through different services.
11. Troubleshooting and Caveats
- Credentials/Authorization Errors (
CredentialsError
,AccessDenied
): Double-check AWS keys in.env
(or IAM Role permissions), ensure the correct AWS region is configured, and verify the IAM user/role hassns:Publish
permissions. - Invalid Parameter Errors (
InvalidParameterValue
): Most commonly due to incorrect phone number format (must be E.164:+
followed by country code and number). Also check message attributes likeSMSType
. - Throttling Exceptions (
ThrottlingException
): You've exceeded your SNS account's sending rate or quota limits. The SDK handles some retries, but persistent throttling requires requesting a limit increase via AWS Support. - Region Support: Ensure the AWS region specified in your configuration supports sending SMS messages. Check the AWS documentation for supported regions.
- Opted-Out Numbers: SNS will block messages to numbers that have opted out. This will result in a failed delivery status if Delivery Status Logging is enabled. Use
checkIfPhoneNumberIsOptedOut
proactively if needed. - Silent Failures (Publish Success != Delivery): A successful
sns.publish
call means SNS accepted the message, not that it reached the handset. Network issues, carrier filtering, or invalid numbers can cause delivery failure later. Use Delivery Status Logging for confirmation. - Cost: SMS messages are billed per segment. Monitor the
SMSMonthToDateSpentUSD
CloudWatch metric closely. UsePromotional
type where appropriate for potential cost savings (but be aware of deliverability differences). - Message Encoding/Length: Non-GSM characters drastically reduce characters per segment (160 -> 70), increasing costs for longer messages.
12. Deployment and CI/CD
- Environment Configuration:
- NEVER commit
.env
files or secrets to Git. - Use environment variables provided by the deployment platform (ECS Task Definitions, Lambda Environment Variables, Heroku Config Vars, etc.).
- For sensitive data like database passwords or external API keys, use a secrets management service (AWS Secrets Manager, AWS Systems Manager Parameter Store, HashiCorp Vault).
- Use IAM Roles for AWS credentials when deploying on AWS infrastructure (EC2, ECS, Lambda).
- NEVER commit
- Deployment Strategies:
- EC2: Deploy the Node.js app, use a process manager like PM2 to keep it running, and potentially use Nginx as a reverse proxy for HTTPS and load balancing.
- Containers (Docker): Package the application into a Docker image. Deploy using services like AWS ECS (with Fargate for serverless compute or EC2 instances), AWS EKS (Kubernetes), or other container orchestration platforms.
- Serverless: Refactor the core logic into an AWS Lambda function fronted by API Gateway. This offers auto-scaling and pay-per-use pricing but requires adapting the Express structure.
- CI/CD Pipeline:
- Use services like AWS CodePipeline, GitHub Actions, GitLab CI, or Jenkins.
- Automate steps: Linting -> Testing -> Building Docker Image (if applicable) -> Pushing Image to Registry (ECR) -> Deploying to Staging -> Deploying to Production.
- Manage environment variables and secrets securely within the pipeline.