code examples

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

How to Send Bulk SMS with Vonage Messages API in Node.js and Express

Step-by-step tutorial: Build a production-ready bulk SMS system using Vonage Messages API, Node.js, and Express. Includes rate limiting, webhooks, 10DLC compliance, and best practices for SMS broadcasting.

Build a Bulk SMS Broadcast System with Vonage Messages API, Node.js, and Express

This guide provides a step-by-step walkthrough for building a robust bulk SMS broadcasting system using Node.js, Express, and the Vonage Messages API. You'll cover everything from project setup and core Vonage integration to essential production considerations like rate limiting, error handling, security, and deployment.

By the end of this guide, you'll have a functional Node.js application capable of sending SMS messages to a list of recipients via an API endpoint, incorporating best practices for reliability and scalability.

Project Overview and Goals

What You're Building:

You'll construct a Node.js application using the Express framework that exposes an API endpoint. This endpoint accepts a list of phone numbers and a message text, then uses the Vonage Messages API to broadcast the SMS message to all specified recipients efficiently and reliably.

Problem Solved:

This system addresses the need to send the same SMS message to multiple recipients simultaneously (e.g., notifications, alerts, marketing campaigns) while managing API rate limits, tracking message status, and ensuring secure handling of credentials.

Technologies Used:

  • Node.js: A JavaScript runtime environment ideal for building scalable network applications. This guide targets Node.js v22 Long Term Support (LTS) (Active LTS through April 2027, recommended) or v20 LTS (Maintenance LTS through April 2026). Node.js v18 reaches end-of-life on April 30, 2025.
  • Express: A minimal and flexible Node.js web application framework for creating the API layer. This guide uses Express v5.1.0 (released October 2024), which requires Node.js 18 or higher and includes important security fixes for Regular Expression Denial of Service (ReDoS) attacks.
  • Vonage Messages API: A unified API from Vonage for sending messages across various channels, including SMS. Choose the Messages API over the older SMS API for its modern features, better support for different channels, and detailed status webhooks.
  • dotenv: A zero-dependency module that loads environment variables from a .env file into process.env. Latest version: 17.2.3 (January 2025). Note: For Node.js v20.6.0+, native .env support via --env-file flag is available. dotenv is suitable for development but not recommended for production secrets – use secrets managers like Infisical, AWS Secrets Manager, or similar.
  • @vonage/server-sdk: The official Vonage Server SDK for Node.js (v3.24.1 as of January 2025), simplifying interaction with Vonage APIs.
  • ngrok (for development): A tool to expose local servers to the internet, necessary for testing Vonage webhooks. Provides secure HTTPS tunneling, webhook inspection, and replay capabilities.

System Architecture:

(A diagram illustrating the system architecture would typically be placed here, showing client, Node.js app, Vonage API, Mobile Network, and Recipient interactions, including the webhook flow.)

Prerequisites:

  • A Vonage API account.
  • Node.js and npm (or yarn) installed locally. Node.js v22 (Active LTS) or v20 (Maintenance LTS) recommended.
  • Basic understanding of JavaScript, Node.js, and REST APIs (Representational State Transfer Application Programming Interfaces).
  • ngrok installed for local webhook testing (Download here).
  • Vonage CLI (Command Line Interface) (Optional but helpful for managing applications/numbers): Run on-demand using npx @vonage/cli [...] or install globally (npm install -g @vonage/cli), which may require administrator privileges.
  • US Application-to-Person (A2P) 10 Digit Long Code (10DLC) Registration (Mandatory for US SMS): If sending SMS to US recipients, complete 10DLC brand and campaign registration. This process involves vetting by Vonage and carriers and may take several days to weeks. Unregistered long codes will be blocked or heavily filtered. (Source: Vonage API Support, January 2025)

Final Outcome:

A Node.js Express application with:

  1. An API endpoint (/broadcast) to trigger bulk SMS sends.
  2. Integration with the Vonage Messages API using the Node.js SDK.
  3. Basic rate limiting to comply with Vonage API constraints.
  4. Environment variable management for secure credential handling.
  5. Webhook endpoints to receive message status updates from Vonage.
  6. Logging for monitoring and troubleshooting.

How Do You Set Up Your Node.js Project for Bulk SMS?

Initialize your Node.js project and install the necessary dependencies.

Step 1: Create Project Directory

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

bash
mkdir vonage-bulk-sms
cd vonage-bulk-sms

Step 2: Initialize Node.js Project

Initialize the project using npm (or yarn). This creates a package.json file. The -y flag accepts the default settings.

bash
npm init -y

Step 3: Install Dependencies

Install Express for the web server, the Vonage Server SDK, and dotenv for environment variables. Version constraints ensure compatibility.

bash
npm install express@^5.1.0 @vonage/server-sdk@^3.24.0 dotenv@^17.2.0

Note on Express v5: Express v5 includes breaking changes from v4, including removal of deprecated methods and updated path-to-regexp@8.x for security (ReDoS mitigation). If migrating from Express v4, review the Express v5 migration guide.

Step 4: Set Up Project Structure

Create the basic files and folders for your application.

bash
touch index.js .env .gitignore

Your basic structure should look like this:

vonage-bulk-sms/ ├── node_modules/ ├── .env ├── .gitignore ├── index.js └── package.json

Step 5: Configure .gitignore

Add node_modules and .env to your .gitignore file to prevent committing sensitive information and dependencies to version control.

text
node_modules
.env

Step 6: Configure Environment Variables (.env)

Create a .env file in the root of your project. This file stores your Vonage credentials and other configuration securely. Fill in the actual values later.

Security Note: dotenv is suitable for local development only. For production environments, use dedicated secrets management solutions (AWS Secrets Manager, Infisical, HashiCorp Vault, etc.) to avoid storing plaintext secrets in environment files. Over 1 million secrets from 58,000+ websites have been exposed through leaked .env files (Source: Security research, 2024).

dotenv
# Vonage Credentials & Config
VONAGE_API_KEY=YOUR_VONAGE_API_KEY
VONAGE_API_SECRET=YOUR_VONAGE_API_SECRET
VONAGE_APPLICATION_ID=YOUR_VONAGE_APPLICATION_ID
VONAGE_PRIVATE_KEY_PATH=./private.key # Path relative to project root
VONAGE_FROM_NUMBER=YOUR_VONAGE_VIRTUAL_NUMBER

# Application Settings
PORT=3000
BASE_URL=http://localhost:3000 # For local testing, will be replaced by ngrok URL for webhooks

Why This Setup?

  • npm init: Standard way to start a Node.js project, managing dependencies and scripts.
  • Dependencies: Express is standard for Node.js web apps; @vonage/server-sdk simplifies Vonage interaction; dotenv is crucial for securely managing API keys outside of code.
  • .gitignore: Prevents accidental exposure of secrets (.env) and unnecessary bloat (node_modules) in Git.
  • .env: Isolates configuration and secrets from the codebase, making it easier to manage different environments (development, production) and enhancing security.

How Do You Initialize the Vonage SDK and Send SMS?

Now, integrate the Vonage SDK and implement the logic to send SMS messages.

Step 1: Initialize Express and Vonage SDK

Open index.js and set up the basic Express server and initialize the Vonage SDK using the environment variables.

javascript
// index.js
require('dotenv').config(); // Load environment variables from .env file
const express = require('express');
const { Vonage } = require('@vonage/server-sdk');
const fs = require('fs'); // Needed to read the private key file

const app = express();
app.use(express.json()); // Middleware to parse JSON bodies
app.use(express.urlencoded({ extended: true })); // Middleware for URL-encoded bodies

const PORT = process.env.PORT || 3000;
const VONAGE_FROM_NUMBER = process.env.VONAGE_FROM_NUMBER;

// --- Vonage Initialization ---
// Check if required Vonage environment variables are set
if (!process.env.VONAGE_API_KEY || !process.env.VONAGE_API_SECRET || !process.env.VONAGE_APPLICATION_ID || !process.env.VONAGE_PRIVATE_KEY_PATH || !VONAGE_FROM_NUMBER) {
    console.error('Error: Missing required Vonage environment variables in .env file.');
    process.exit(1); // Exit if configuration is missing
}

let vonage;
try {
    // Read the private key
    const privateKey = fs.readFileSync(process.env.VONAGE_PRIVATE_KEY_PATH);

    vonage = new Vonage({
        apiKey: process.env.VONAGE_API_KEY,
        apiSecret: process.env.VONAGE_API_SECRET,
        applicationId: process.env.VONAGE_APPLICATION_ID,
        privateKey: privateKey // Use the file content directly
    }, {
        // Optional: Add custom user agent for tracking/debugging
        appendToUserAgent: 'vonage-bulk-sms-guide/1.0.0'
    });
    console.log('Vonage SDK initialized successfully.');
} catch (error) {
    console.error('Error initializing Vonage SDK:', error);
    if (error.code === 'ENOENT') {
        console.error(`Private key file not found at path: ${process.env.VONAGE_PRIVATE_KEY_PATH}`);
    }
    process.exit(1);
}

// --- Basic Root Route ---
app.get('/', (req, res) => {
    res.send('Vonage Bulk SMS Service is running!');
});

// --- Start Server ---
// Export server for testing purposes
const server = app.listen(PORT, () => {
    console.log(`Server listening on port ${PORT}`);
    console.log(`Base URL: ${process.env.BASE_URL}`);
    console.log(`Ensure BASE_URL matches your ngrok forwarding URL for webhooks.`);
});

// --- Placeholder for other routes (API, Webhooks) ---
// We will add these later

// Export app and server for testing
module.exports = { app, server, vonage }; // Export vonage if needed for mocking in tests
  • require('dotenv').config();: Must be called early to load variables.
  • express.json() / express.urlencoded(): Necessary middleware to parse incoming request bodies.
  • Vonage Initialization: We use the Vonage class from the SDK, providing credentials and the content of the private key file read using fs.readFileSync. Using the Messages API requires an Application ID and Private Key for authentication.
  • Error Handling: Added checks for missing environment variables and errors during SDK initialization (like a missing private key file).

Step 2: Implement Single SMS Sending Function

Create a reusable function to send a single SMS message. This helps structure the code. (This version includes enhanced logging).

javascript
// index.js (add this function)

// --- Function to Send Single SMS (with enhanced logging) ---
async function sendSms(toNumber, messageText) {
    const logPrefix = `[SMS to ${toNumber}]`;
    console.log(`${logPrefix} Attempting to send...`);
    try {
        const resp = await vonage.messages.send({
            message_type: "text",
            text: messageText,
            to: toNumber,
            from: VONAGE_FROM_NUMBER, // Must be a Vonage virtual number linked to your application
            channel: "sms"
        });
        console.log(`${logPrefix} Submitted successfully. UUID: ${resp.messageUuid}`);
        return { success: true, messageUuid: resp.messageUuid, recipient: toNumber };
    } catch (err) {
        // Extract more specific error details if available
        const errorMessage = err.response?.data?.title || err.response?.data?.detail || err.message || 'Unknown error';
        // Try to get a numeric status code, or a specific Vonage code, or 'N/A'
        const errorCode = err.response?.status?.toString() || err.response?.data?.invalid_parameters?.[0]?.name || 'N/A';
        console.error(`${logPrefix} Submission failed. Code: ${errorCode}, Reason: ${errorMessage}`);
        // Log the full error object for deeper debugging if needed
        // console.error(`${logPrefix} Full error object:`, JSON.stringify(err.response?.data || err, null, 2));
        return { success: false, error: errorMessage, errorCode: errorCode, recipient: toNumber };
    }
}
  • vonage.messages.send: The core method from the SDK for the Messages API.
  • Parameters: message_type, text, to, from, and channel are required for SMS.
  • async/await: Used for cleaner handling of the asynchronous API call.
  • Return Value: Returns an object indicating success/failure, including the messageUuid for tracking and errorCode on failure.
  • Enhanced Logging: Provides more context in logs and attempts to extract meaningful error codes/messages.

Step 3: Implement Basic Rate Limiting (Delay)

Vonage imposes rate limits. The base limit is 30 API requests per second for all API keys. However, actual SMS throughput depends heavily on:

  • Number Type: Long Code, Toll-Free, or Short Code
  • US A2P 10DLC Registration: Mandatory for US messaging. Campaign types determine throughput:
    • Standard campaigns: Typically 4,500 messages/minute (75 msg/sec) on T-Mobile, varies by AT&T based on Trust Score
    • Low Volume Mixed: 1,000 messages/day, 15 messages/minute
    • Sole Proprietor: 3,000 messages/day, 15 messages/minute
    • Higher throughput: Available upon request for select use cases (up to 30 msg/sec for sanctioned 10DLCs)
  • Carrier-Specific Limits: AT&T applies per-minute throughput limits based on campaign type; T-Mobile has per-second limits
  • Target Country Regulations: International destinations have varying carrier limits

Critical: Exceeding your allowed rate will result in HTTP 429 errors and message blocking/queueing. The simple delay approach below is a basic starting point for low-volume testing only. Production systems must implement proper rate limiting based on your specific 10DLC campaign throughput.

(Source: Vonage API Support - "10 DLC Throughput Limits" and "What is the Throughput Limit for Outbound SMS?", January 2025)

javascript
// index.js (add this helper function)

// --- Helper function for delays ---
function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

// Define delay in milliseconds.
// WARNING: 1100ms (~1 SMS/sec) is a VERY basic starting point for UNREGISTERED long codes (which are now BLOCKED in the US).
// Your ACTUAL required delay depends entirely on:
// - Your Vonage number type (Long Code, Toll-Free, Short Code)
// - US A2P 10DLC registration status and approved campaign throughput (MANDATORY for US)
// - Target country regulations and carrier-specific limits
// EXCEEDING YOUR ALLOWED RATE WILL LEAD TO ERRORS (429) AND MESSAGE BLOCKING.
// For 10DLC Standard campaigns: ~75 msg/sec (T-Mobile), varies for AT&T
// For Low Volume/Sole Proprietor: 15 msg/minute maximum
// Consult Vonage documentation and your 10DLC campaign details for appropriate limits.
const SEND_DELAY_MS = 1100; // EXAMPLE ONLY - ADJUST BASED ON YOUR ACTUAL 10DLC THROUGHPUT LIMITS

Why this approach?

  • Modularity: Separating the sendSms function makes the code cleaner and reusable.
  • Messages API: Chosen for its modern features and webhook capabilities.
  • Rate Limiting (Simple Delay): The sleep function provides a basic mechanism to avoid hitting simple per-second limits. Crucially, this is an oversimplification. Production systems sending moderate to high volume, especially in regulated markets like the US (A2P 10DLC), must account for specific throughput limits tied to number types and 10DLC campaign registration. A simple fixed delay is often inadequate. Consider concurrent sending within verified limits (see Section 5/6) or a background job queue for better management.

How Do You Create a Bulk SMS Broadcast API Endpoint?

Create the /broadcast endpoint to trigger the bulk sending process.

Step 1: Define the /broadcast Route

Add a POST route in index.js that accepts a list of numbers and a message.

javascript
// index.js (add this route handler)

// --- API Endpoint for Bulk Broadcasting ---
app.post('/broadcast', async (req, res) => {
    const { recipients, message } = req.body;

    // --- Input Validation ---
    if (!recipients || !Array.isArray(recipients) || recipients.length === 0) {
        return res.status(400).json({ error: 'Invalid or missing "recipients" array in request body.' });
    }
    if (!message || typeof message !== 'string' || message.trim() === '') {
        return res.status(400).json({ error: 'Invalid or missing "message" string in request body.' });
    }

    // Basic phone number format validation.
    // WARNING: This regex is very basic and NOT recommended for production.
    // It doesn't validate E.164 format (missing '+') or handle international variations well.
    // Use a dedicated library like 'libphonenumber-js' for robust validation.
    const phoneRegex = /^\d{10,15}$/;
    const invalidNumbers = recipients.filter(num => !phoneRegex.test(num.toString()));

    if (invalidNumbers.length > 0) {
        console.warn(`Warning: Potential invalid phone numbers detected (based on basic check): ${invalidNumbers.join(', ')}`);
        // Decide whether to reject or proceed. For production, stricter validation before this point is better.
        // return res.status(400).json({ error: 'Invalid phone number format detected.', invalidNumbers });
    }

    console.log(`Received broadcast request for ${recipients.length} recipients.`);
    console.log(`Message: "${message}"`);

    // --- Process Recipients Sequentially with Delay ---
    const results = [];
    let successCount = 0;
    let failureCount = 0;

    for (const recipient of recipients) {
        const result = await sendSms(recipient, message);
        results.push(result);
        if (result.success) {
            successCount++;
        } else {
            failureCount++;
        }
        // Wait before sending the next message to respect rate limits (see caveats in Section 2)
        await sleep(SEND_DELAY_MS);
    }

    console.log(`Broadcast attempt finished. Success: ${successCount}, Failure: ${failureCount}`);

    // --- Respond to Client ---
    // Respond with initial submission results. Actual delivery status comes via webhook.
    // Status 202 implies processing continues, but here we wait for the loop.
    // For true async, respond earlier and use background jobs (See Section 6).
    res.status(200).json({ // Changed to 200 OK as we wait for the loop here. Use 202 if responding before loop.
        message: `Broadcast submission process completed for ${recipients.length} recipients. Check webhook or logs for delivery status.`,
        summary: {
            totalRecipients: recipients.length,
            submittedSuccessfully: successCount,
            submissionFailures: failureCount,
        },
        details: results // Provides per-recipient submission status
    });
});
  • Input Validation: Checks for the presence and basic format of recipients and message. Includes a very basic regex for phone numbers with a strong recommendation to use a proper library like libphonenumber-js for production.
  • Sequential Processing: Iterates through the recipients array.
  • await sendSms(...): Calls our SMS sending function for each recipient.
  • await sleep(...): Pauses execution between each send attempt, subject to the caveats mentioned earlier.
  • Response: Returns a 200 OK status after attempting all submissions in the loop. Includes a summary and detailed results of the submission attempts.

Step 2: Testing the Endpoint (Example using curl)

Make sure your server is running (node index.js). Open another terminal and run the following command (replace YOUR_TEST_PHONE_NUMBER_... with actual E.164 formatted numbers):

bash
curl -X POST http://localhost:3000/broadcast \
-H "Content-Type: application/json" \
-d '{
  "recipients": ["YOUR_TEST_PHONE_NUMBER_1", "YOUR_TEST_PHONE_NUMBER_2"],
  "message": "Hello from the Bulk SMS Broadcaster!"
}'

Expected JSON Response (example):

json
{
  "message": "Broadcast submission process completed for 2 recipients. Check webhook or logs for delivery status.",
  "summary": {
    "totalRecipients": 2,
    "submittedSuccessfully": 2,
    "submissionFailures": 0
  },
  "details": [
    { "success": true, "messageUuid": "...", "recipient": "...", "errorCode": null },
    { "success": true, "messageUuid": "...", "recipient": "...", "errorCode": null }
  ]
}

(The actual success, messageUuid, recipient, and errorCode values will reflect the outcome of the submission attempts.)

You should also see logs in your server console and receive the SMS messages on the test phones (after the specified delay).


How Do You Set Up Vonage Webhooks for SMS Delivery Status?

Proper configuration in the Vonage Dashboard and setting up webhooks are crucial for the Messages API.

Step 1: Obtain Vonage Credentials

  1. Log in to your Vonage API Dashboard.
  2. Your API Key and API Secret are displayed on the main dashboard page. Copy these into your .env file.

Step 2: Create a Vonage Application

The Messages API requires an Application to handle authentication (via private key) and webhooks.

  1. Navigate to ""Applications"" in the dashboard sidebar.
  2. Click ""Create a new application"".
  3. Give it a name (e.g., ""Bulk SMS Broadcaster"").
  4. Click ""Generate public and private key"". Immediately save the private.key file that downloads. Place this file in your project's root directory (or update VONAGE_PRIVATE_KEY_PATH in .env if you save it elsewhere). The public key is stored by Vonage.
  5. Enable the ""Messages"" capability.
  6. You'll need to provide two webhook URLs (we'll get the YOUR_NGROK_URL part in Step 4):
    • Inbound URL: Receives incoming messages sent to your Vonage number. Define it as YOUR_NGROK_URL/webhooks/inbound.
    • Status URL: Receives status updates (delivered, failed, etc.) for messages you send. This is critical. Define it as YOUR_NGROK_URL/webhooks/status.
  7. Click ""Generate new application"".
  8. Copy the generated Application ID into your .env file.

Step 3: Link a Vonage Number

  1. Go back to the Application details page (Applications -> Your Application Name).
  2. Under ""Linked numbers"", click ""Link"" and choose one of your Vonage virtual numbers capable of sending SMS. This number will be used as the FROM number.
  3. Copy this Vonage Virtual Number into the VONAGE_FROM_NUMBER field in your .env file (use E.164 format, e.g., 14155550100).

Step 4: Set up ngrok for Local Webhook Testing

Vonage needs a publicly accessible URL to send webhooks. ngrok creates a secure tunnel to your local machine.

  1. If your server is running, stop it (Ctrl+C).
  2. Open a terminal in your project directory and run ngrok (replace 3000 if your app uses a different port):
    bash
    ngrok http 3000
  3. ngrok will display forwarding URLs (e.g., https://random-subdomain.ngrok-free.app). Copy the https URL. This is YOUR_NGROK_URL.
  4. Update Vonage Application: Go back to your Application settings in the Vonage dashboard. Edit the Messages capability URLs:
    • Inbound URL: YOUR_NGROK_URL/webhooks/inbound
    • Status URL: YOUR_NGROK_URL/webhooks/status
    • Save the changes.
  5. Update .env: Set BASE_URL=YOUR_NGROK_URL in your .env file. This helps keep track of the currently active ngrok URL.
  6. Restart your Node.js server: node index.js

Step 5: Implement Webhook Handlers

Add routes in index.js to receive POST requests from Vonage at the URLs you configured.

javascript
// index.js (add these route handlers)

// --- Webhook Handler for Message Status Updates ---
app.post('/webhooks/status', (req, res) => {
    const statusData = req.body;
    console.log('Received Status Webhook:', JSON.stringify(statusData, null, 2));

    // --- Process the status update ---
    // Examples: Update a database record, log specific statuses, trigger alerts
    try {
        if (statusData.status === 'delivered') {
            console.log(`Message ${statusData.message_uuid} delivered to ${statusData.to} at ${statusData.timestamp}`);
        } else if (statusData.status === 'failed' || statusData.status === 'rejected') {
            // Extract error details carefully, structure might vary slightly
            const reason = statusData.error?.reason || statusData.error?.title || 'Unknown';
            const code = statusData.error?.code || 'N/A';
            console.error(`Message ${statusData.message_uuid} failed for ${statusData.to}. Reason: ${reason} (Code: ${code})`);
            // Log full error object if needed for debugging complex failures
            // console.error('Full error object:', statusData.error);
        } else {
            console.log(`Message ${statusData.message_uuid} status update: ${statusData.status}`);
        }
    } catch (error) {
        console.error('Error processing status webhook:', error);
        // Decide if you still want to send 200 OK even if processing failed internally
    }

    // --- IMPORTANT: Respond with 200 OK ---
    // Vonage expects a 200 OK response to acknowledge receipt of the webhook.
    // Failure to do so will result in retries from Vonage.
    res.status(200).send('OK');
});

// --- Webhook Handler for Inbound Messages (Optional) ---
app.post('/webhooks/inbound', (req, res) => {
    const inboundData = req.body;
    console.log('Received Inbound SMS:', JSON.stringify(inboundData, null, 2));

    // --- Process inbound message ---
    // Example: Log message, trigger auto-reply, etc.
    try {
        console.log(`SMS received from ${inboundData.from} with text: ""${inboundData.text}""`);
        // Add your inbound message processing logic here
    } catch (error) {
        console.error('Error processing inbound webhook:', error);
    }

    // Respond with 200 OK
    res.status(200).send('OK');
});
  • /webhooks/status: Logs the received status data. You would typically parse this data (message_uuid, status, error, timestamp) to update the delivery status of your sent messages, perhaps in a database. Error field extraction is made slightly more robust. Added basic try...catch.
  • /webhooks/inbound: Logs incoming messages sent to your Vonage number. Added basic try...catch.
  • res.status(200).send('OK'): Crucial! Vonage needs this acknowledgment.

Verification: Send a test broadcast using the /broadcast endpoint again. Watch your server logs. You should see:

  1. Logs from /broadcast initiating the sends.
  2. Logs from /webhooks/status showing updates like submitted, delivered, or failed for each message sent (these may arrive with some delay).

How Do You Add Error Handling and Retry Logic for SMS?

Robust applications need solid error handling and logging.

Error Handling:

  • SDK Errors: The try...catch block in the sendSms function (updated in Section 2) handles errors during the API call. It now attempts to log more detailed error information.
  • Webhook Errors: Wrap the logic inside webhook handlers in try...catch (as shown in Section 4) to prevent the server from crashing if processing fails. Ensure you still send 200 OK unless it's a fundamental issue receiving the request.
  • Input Validation: The /broadcast endpoint includes basic validation. Enhance this using libraries like joi or express-validator, and especially libphonenumber-js for phone numbers.

Logging:

  • Consistency: Use console.log, console.warn, console.error consistently. The enhanced sendSms provides better context.
  • Information: Log key events: server start, request received, message submission attempts (success/failure with details), webhook received, specific statuses (delivered/failed). Include relevant data like message_uuid, recipient number, timestamps, and error details.
  • Production Logging: For production, consider using a dedicated logging library like winston or pino for structured logging (JSON), log levels, and transports (files, external services).

Retry Mechanisms:

  • Vonage Webhooks: Vonage automatically retries sending webhooks if it doesn't receive a 200 OK response. Ensure your webhook handlers are reliable and respond quickly.
  • Sending SMS: If sendSms fails due to potentially temporary issues (e.g., network timeout, rate limiting error 429), you might implement a limited retry strategy.
    • Simple Retry: Wrap the sendSms call in a loop with delays.
    • Exponential Backoff: Increase the delay between retries (e.g., 1s, 2s, 4s). Libraries like async-retry can help.

Example: Simple Retry Logic (Conceptual - Integrate into /broadcast loop)

This shows how you might add retry logic within the /broadcast handler's loop.

javascript
// Conceptual - Integrating into the /broadcast loop
// Requires careful implementation to avoid infinite loops and manage delays
const MAX_RETRIES = 2;
const RETRY_DELAY_MS = 2000; // Initial delay for first retry

for (const recipient of recipients) {
    let attempt = 0;
    let result;
    while (attempt <= MAX_RETRIES) {
        result = await sendSms(recipient, message);
        // Check if successful OR if the error is NOT retryable
        if (result.success || !isRetryableError(result.errorCode)) {
            break; // Success or non-retryable error, stop retrying
        }

        attempt++;
        if (attempt <= MAX_RETRIES) {
            const delay = RETRY_DELAY_MS * Math.pow(2, attempt - 1); // Exponential backoff
            console.warn(`[SMS to ${recipient}] Retrying (${attempt}/${MAX_RETRIES}) after ${delay}ms delay due to error code ${result.errorCode}...`);
            await sleep(delay);
        } else {
             console.error(`[SMS to ${recipient}] Max retries reached. Giving up after error code ${result.errorCode}.`);
        }
    }
    results.push(result); // Add final result (success or last failure)
    // ... rest of loop ...
    // Main delay between *different* recipients (still subject to Section 2 caveats)
    if (attempt <= MAX_RETRIES) { // Only delay if we didn't just finish retrying
        await sleep(SEND_DELAY_MS);
    }
}

function isRetryableError(errorCode) {
    // Example: Vonage uses 429 for rate limits, 5xx for server issues
    // Check if errorCode is a string representing a number before parsing
    const numericCode = parseInt(errorCode, 10);
    if (isNaN(numericCode)) {
        return false; // Cannot retry non-numeric error codes this way
    }
    const retryableCodes = [429, 500, 502, 503, 504]; // Add other transient codes if identified
    return retryableCodes.includes(numericCode);
}
  • Caution: Implement retries carefully. Only retry transient issues (like 429, 5xx). Retrying permanent errors (400 bad number) is wasteful. Ensure delays don't compound excessively. For large scale, offload sending to a background queue system (like BullMQ) which handles retries more robustly. The isRetryableError function now handles potentially non-numeric errorCode values.

How Do You Track SMS Messages with a Database?

For tracking broadcast jobs, individual message statuses, and managing recipients effectively, a database is highly recommended. Here's a conceptual example using Prisma (a popular Node.js ORM).

Step 1: Install Prisma

bash
npm install prisma@^6.16.0 --save-dev
npm install @prisma/client@^6.16.0
npx prisma init --datasource-provider postgresql # Or sqlite, mysql, etc.

Prisma 6 Updates: Prisma 6.16.3 (January 2025) includes the completed migration from Rust to TypeScript for core logic, a new ESM-first generator splitting Prisma Client into multiple files, and enhanced full-text search capabilities. Minimum supported versions: Node.js 18.18.0+ and TypeScript 5.0+. (Source: Prisma Changelog, January 2025)

This creates a prisma folder with schema.prisma and updates .env with DATABASE_URL.

Step 2: Define Schema (prisma/schema.prisma)

prisma
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

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

datasource db {
  provider = ""postgresql"" // Or your chosen provider
  url      = env(""DATABASE_URL"")
}

model Broadcast {
  id        String   @id @default(cuid())
  message   String
  status    String   @default(""PENDING"") // PENDING, PROCESSING, COMPLETED, FAILED
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  recipients Recipient[]
}

model Recipient {
  id               String    @id @default(cuid())
  phoneNumber      String
  status           String    @default(""PENDING"") // PENDING, QUEUED, SUBMITTED, DELIVERED, FAILED, REJECTED
  vonageMessageUuid String?   @unique // Link to Vonage message
  submittedAt      DateTime?
  lastStatusUpdate DateTime?
  errorMessage     String?
  errorCode        String?   // Store error code from submission or webhook

  broadcast   Broadcast @relation(fields: [broadcastId], references: [id])
  broadcastId String

  @@index([broadcastId])
  @@index([status])
  @@index([vonageMessageUuid]) // Index for webhook lookup
}
  • Broadcast: Represents a single bulk sending job.
  • Recipient: Represents each individual message within a broadcast, tracking its specific status and Vonage messageUuid. Added index on vonageMessageUuid.
  • Relations: A one-to-many relationship between Broadcast and Recipient.
  • Indexes: Added for potentially common queries.

Step 3: Apply Schema Changes

Create and apply the first migration.

bash
npx prisma migrate dev --name init

Step 4: Integrate Prisma Client

Instantiate the client and use it to interact with the database. Handle potential connection errors and ensure graceful shutdown.

javascript
// index.js (add near the top)
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();

// Basic check for DB connection on startup (optional but good)
async function checkDbConnection() {
    try {
        await prisma.$connect();
        console.log('Database connection successful.');
    } catch (error) {
        console.error('Database connection failed:', error);
        process.exit(1); // Exit if DB is essential
    }
}
checkDbConnection(); // Call the check

// Graceful shutdown for Prisma
async function shutdown() {
    console.log('Disconnecting database...');
    try {
        await prisma.$disconnect();
        console.log('Database disconnected.');
    } catch (e) {
        console.error('Error disconnecting database:', e);
    } finally {
        server.close(() => { // Close HTTP server after DB disconnect attempt
            console.log('Server closed.');
            process.exit(0);
        });
        // Force exit after timeout if server doesn't close gracefully
        setTimeout(() => {
            console.error('Forcefully shutting down after timeout.');
            process.exit(1);
        }, 5000); // 5 second timeout
    }
}

process.on('SIGTERM', shutdown); // Handle termination signal
process.on('SIGINT', shutdown);  // Handle interrupt signal (Ctrl+C)
  • Added basic connection check and graceful shutdown handlers.

Step 5: Modify API and Webhooks to Use Database

  • /broadcast Endpoint:
    1. Create a Broadcast record.
    2. Create Recipient records (status PENDING or QUEUED).
    3. Respond 202 Accepted immediately after creating records to indicate processing will happen asynchronously.
    4. Then, iterate through recipients (or preferably, trigger background jobs).
    5. When sendSms is called and returns successfully, update the corresponding Recipient record (status: 'SUBMITTED', vonageMessageUuid, submittedAt).
    6. If sendSms fails, update the Recipient record (status: 'FAILED', errorMessage, errorCode).
  • /webhooks/status Handler:
    1. Find the Recipient record using the vonageMessageUuid from the webhook data.
    2. Update the Recipient's status, lastStatusUpdate, and potentially errorMessage/errorCode based on the webhook content (delivered, failed, rejected, etc.).
    3. Handle cases where the messageUuid might not be found (e.g., log an error).

(Implementing these database interactions is left as an exercise but involves using prisma.broadcast.create, prisma.recipient.createMany, prisma.recipient.update, and prisma.recipient.findUnique within the respective route handlers.)


Frequently Asked Questions About Vonage Bulk SMS Broadcasting

How do you send bulk SMS messages with Vonage?

Send bulk SMS messages with Vonage by using the Vonage Messages API with Node.js and Express. Create an application with the Vonage Server SDK, authenticate with your API credentials and private key, then iterate through your recipient list calling the vonage.messages.send() method for each phone number. Implement rate limiting based on your 10DLC campaign throughput to avoid HTTP 429 errors.

What is the Vonage Messages API rate limit?

The Vonage Messages API has a base limit of 30 API requests per second. However, actual SMS throughput depends on your number type and US A2P 10DLC registration. Standard 10DLC campaigns typically allow 4,500 messages/minute (75 msg/sec on T-Mobile), while Low Volume campaigns are limited to 15 messages/minute. Exceeding your rate limit results in HTTP 429 errors and message queueing or blocking.

Why use the Vonage Messages API instead of the SMS API?

Use the Vonage Messages API over the older SMS API for modern features including multi-channel support (SMS, WhatsApp, Facebook Messenger, Viber), detailed status webhooks with delivery confirmations, better error reporting, and application-based authentication with private keys. The Messages API provides more robust tracking and supports future channel expansion.

What is US A2P 10DLC registration and why is it mandatory?

US A2P 10DLC (Application-to-Person 10 Digit Long Code) registration is mandatory for all SMS traffic to US recipients using standard long codes. The registration process involves brand and campaign vetting by Vonage and carriers, taking several days to weeks. Unregistered long codes are blocked or heavily filtered by carriers. Registration determines your throughput limits based on campaign type (Standard, Low Volume, or Sole Proprietor).

How do Vonage webhook callbacks work for message status?

Vonage sends webhook callbacks as HTTP POST requests to your configured Status URL whenever a message status changes (submitted, delivered, failed, rejected). Your webhook endpoint receives JSON data including message_uuid, status, timestamp, and error details. Respond with HTTP 200 OK to acknowledge receipt. Vonage automatically retries if it doesn't receive a 200 response.

What Node.js version do you need for Vonage bulk SMS?

Use Node.js v22 LTS (Active LTS through April 2027, recommended) or v20 LTS (Maintenance LTS through April 2026) for Vonage bulk SMS applications. Express v5.1.0 requires Node.js 18 or higher. Avoid Node.js v18 as it reaches end-of-life on April 30, 2025, and won't receive security updates.

How do you handle rate limiting in production?

Handle rate limiting in production by implementing delays between sends based on your 10DLC campaign throughput, using a background job queue (BullMQ, Bull) for asynchronous processing, implementing exponential backoff for HTTP 429 errors, and monitoring carrier-specific limits (AT&T applies per-minute limits, T-Mobile per-second). Track submission attempts in a database to avoid duplicate sends during retries.

What database should you use for tracking SMS broadcasts?

Use PostgreSQL with Prisma ORM (v6.16.3+) for tracking SMS broadcasts. Create tables for Broadcast jobs and Recipients with fields for phoneNumber, status, vonageMessageUuid, submittedAt, lastStatusUpdate, and errorCode. Index the vonageMessageUuid field for fast webhook lookups. Alternative databases include MySQL, MongoDB, or SQLite for development.

How do you secure Vonage API credentials?

Secure Vonage API credentials by storing them in environment variables using dotenv for development, never committing .env files to version control, using secrets managers (AWS Secrets Manager, Infisical, HashiCorp Vault) for production, rotating API keys regularly, and restricting private key file permissions (chmod 600 private.key). Over 1 million secrets have been exposed through leaked .env files.

What are the throughput limits for different 10DLC campaign types?

Throughput limits for 10DLC campaigns vary by type: Standard campaigns allow 4,500 messages/minute (75 msg/sec on T-Mobile), Low Volume Mixed campaigns limit to 1,000 messages/day and 15 messages/minute, Sole Proprietor campaigns allow 3,000 messages/day and 15 messages/minute, and sanctioned 10DLCs can send up to 30 messages/second. AT&T applies per-minute limits based on Trust Score; T-Mobile uses per-second limits.

How do you test Vonage webhooks locally?

Test Vonage webhooks locally using ngrok to create a secure HTTPS tunnel to your localhost. Run ngrok http 3000 to get a public URL, configure your Vonage Application's Status URL and Inbound URL with the ngrok URL plus your webhook paths (e.g., https://abc123.ngrok-free.app/webhooks/status), and use ngrok's web interface to inspect and replay webhook requests for debugging.

What is the difference between synchronous and asynchronous SMS sending?

Synchronous SMS sending processes recipients sequentially in the API request, blocking until all messages are submitted, resulting in long response times for large recipient lists. Asynchronous sending responds immediately with HTTP 202 Accepted, processes sends in background jobs or queues, allows better rate limiting and retry logic, and scales better for production workloads. Use background job queues like BullMQ for production systems.

How do you implement retry logic for failed SMS?

Implement retry logic by catching errors from sendSms(), checking if the error is retryable (HTTP 429, 5xx status codes), using exponential backoff for delays between retries (1s, 2s, 4s, 8s), limiting maximum retry attempts (2-3 retries), and avoiding retries for permanent errors (HTTP 400 bad number format). Log all retry attempts with error codes for debugging and tracking delivery issues.

What Express.js middleware do you need for Vonage?

Essential Express.js middleware for Vonage includes express.json() to parse JSON request bodies, express.urlencoded({ extended: true }) for form data, custom rate limiting middleware or express-rate-limit package, error handling middleware for catching async errors, and logging middleware like morgan or winston for request/response tracking. Express v5+ natively handles promise rejections from middleware.

How do you validate phone numbers before sending SMS?

Validate phone numbers using the libphonenumber-js library to check E.164 format (e.g., +14155550100), verify the number is valid for SMS, extract country code and national number, format numbers consistently, and check against carrier databases. Avoid basic regex validation in production as it doesn't handle international variations, special cases, or invalid number ranges accurately.