code examples

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

Developer Guide: Building a Node.js Express API for Bulk SMS with AWS SNS

A step-by-step guide to creating a Node.js and Express API for sending bulk SMS messages using AWS Simple Notification Service (SNS), covering setup, implementation, security, and deployment.

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:

text
+-------------+       +---------------------+       +---------+       +-----------------+
|   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.

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

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

bash
npm install express aws-sdk dotenv express-validator express-rate-limit

1.4. Project Structure:

Create a basic structure to organize our code:

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

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

text
# 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.

  1. Navigate to IAM: Log in to your AWS Management Console and navigate to the IAM service.
  2. 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".
  3. 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 like SMSType or SenderID.
      • 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. Select AmazonSNSFullAccess. Click "Next: Tags".
  4. Tags (Optional): Add any tags if needed (e.g., Project: bulk-sms-api). Click "Next: Review".
  5. Review and Create: Review the details and click "Create user".
  6. 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.

dotenv
# .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.

javascript
// 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 and dotenv.
  • dotenv.config() loads the variables from .env into process.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.

javascript
// 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 by sns.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 between Promotional and Transactional 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.
  • sendBulkSms Function:
    • Takes an array of phoneNumbers and the message.
    • 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 and failed.
    • Logs the overall outcome and returns detailed results.
  • checkOptOut Function (Optional):
    • Demonstrates how to use checkIfPhoneNumberIsOptedOut.

2.3. Create a Simple Logger Utility:

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

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

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

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

javascript
// 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 to server.
  • Logs configuration details and checks for essential AWS variables.
  • Includes signal handlers (SIGTERM, SIGINT) that call server.close() for graceful shutdown.

3.5. Add Start Script to package.json:

Add a script to easily start your server.

json
// 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 optional dev script using nodemon (install with npm install --save-dev nodemon). Ensure the JSON is valid.

4. Testing the API

Start your server:

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

bash
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).
    json
    {
        ""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).
    json
    {
        ""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).
    json
    {
        ""errors"": [
            {
                ""value"": [""+123""],
                ""msg"": ""Invalid E.164 phone number format found: +123"",
                ""param"": ""phoneNumbers"",
                ""location"": ""body""
            }
        ]
    }
  • 429 Too Many Requests: Rate limit exceeded.
    json
    {
        ""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, and express-validator for input validation. Consider adding specific catch 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 like async-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:

prisma
// 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 create SmsMessage records (initially with ACCEPTED status and the snsMessageId) 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.
  • 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.
  • 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 and Promise.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 in src/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 (like node-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 has sns: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 like SMSType.
  • 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. Use Promotional 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).
  • 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.

Frequently Asked Questions

How to send bulk SMS messages using Node.js?

Use Node.js with Express and the AWS SDK to build an API that interacts with Amazon SNS. Create an endpoint that accepts phone numbers and a message, then leverages SNS to send the messages. This setup allows for efficient bulk SMS sending through a scalable and reliable service.

What is AWS SNS used for in bulk SMS?

AWS SNS (Simple Notification Service) is a managed messaging service used to send SMS messages reliably at scale. It handles complexities like carrier relationships, retries, and opt-out management, simplifying the process for your application.

Why use Node.js for a bulk SMS API?

Node.js, with its asynchronous nature, is well-suited for I/O-bound operations such as sending bulk SMS. Its non-blocking model efficiently handles API calls to services like AWS SNS while Express.js simplifies building a robust API.

When to use Transactional vs Promotional SMS with SNS?

Use Transactional SMS for critical messages like OTPs and alerts, as they generally bypass DND lists, although pricing may differ. Promotional SMS is for marketing, where deliverability may be lower, offering potential cost savings. Check regional regulations for specific requirements.

Can I use a custom Sender ID with AWS SNS for SMS?

Yes, but support varies by country. Some regions require pre-registration for alphanumeric Sender IDs, while others only allow numeric Sender IDs (virtual numbers). Configure it in the 'AWS.SNS.SMS.SenderID' message attribute when publishing via the SNS API, but always adhere to regional regulations.

How to handle AWS credentials securely in Node.js?

For local development, use a .env file, but never commit it. In production, leverage IAM roles for applications running on AWS infrastructure (EC2, ECS, Lambda). If running elsewhere, utilize secure secrets management services such as AWS Secrets Manager or HashiCorp Vault.

What is the E.164 format for phone numbers?

E.164 is an international standard phone number format. It starts with a plus sign (+) followed by the country code and the subscriber number. For example, +12223334444. Ensure all numbers sent to your API conform to this format.

How to handle SMS opt-outs with Amazon SNS?

SNS automatically handles opt-outs based on keywords like STOP or UNSUBSCRIBE. You can proactively check opt-out status using the `checkIfPhoneNumberIsOptedOut` function before sending. Always monitor SNS delivery status logs for opted-out events and respect user preferences.

How to handle errors when sending bulk SMS with SNS?

Implement try...catch blocks, global Express error handlers, and input validation with express-validator. For known AWS errors (ThrottlingException, InvalidParameterValue), consider dedicated error handling within your Node.js services.

How to track SMS delivery status with AWS SNS?

Configure SNS Delivery Status Logging to send delivery events (delivered, failed, opted_out) to CloudWatch Logs, SQS, or a Lambda function. Use this data to update message status in your database and gain insights into deliverability.

What database schema is recommended for tracking bulk SMS?

Create tables for 'BulkSendBatch' (overall information about each batch) and 'SmsMessage' (individual message status). Include fields like message ID, phone number, status, and timestamps. Use an ORM like Prisma or Sequelize to manage database interactions.

How to improve bulk SMS API performance?

Use asynchronous operations (async/await), manage payload sizes, and reuse connections with AWS SDK. SNS itself is highly scalable. For frequent opt-out checks, implement caching using libraries like 'node-cache' or external solutions like Redis.

What are some best practices for deploying a Node.js bulk SMS API?

Use environment-specific configuration, secure secret management, and IAM roles for AWS deployments. Package using Docker and utilize platforms like AWS ECS or EKS. Consider serverless deployment with AWS Lambda and API Gateway. Implement a robust CI/CD pipeline for automated deployments.

What are some common troubleshooting tips for SNS bulk SMS?

Check for credential or authorization errors, incorrect phone number formats (E.164), or throttling issues. Be aware that successful SNS publishing does not guarantee delivery - use Delivery Status Logging for confirmation. Monitor costs via CloudWatch and ensure the correct message encoding.