How to Send Bulk SMS with Vonage and Node.js: Queue-Based Broadcasting Guide (2025)
Learn how to build a production-ready bulk SMS broadcasting system using Vonage Messages API, Node.js, BullMQ queues, and Redis. Complete tutorial with code examples for high-volume SMS campaigns.
This guide walks you through building a production-ready bulk SMS broadcasting system using Node.js, Express, and the Vonage Messages API. You'll learn how to send bulk SMS messages with Vonage using a queue-based architecture with BullMQ and Redis for reliable, high-volume messaging. The tutorial covers project setup, core implementation, error handling, security, performance optimization, monitoring, and deployment.
By the end, you'll have a functional API that queues and reliably sends large volumes of SMS messages with logging, error handling, and monitoring.
What Is Bulk SMS Broadcasting?
Bulk SMS broadcasting enables businesses to send high-volume text messages to multiple recipients simultaneously. Common use cases include:
Transactional alerts: Order confirmations, shipping updates, appointment reminders
Emergency notifications: System alerts, security warnings, crisis communications
Customer engagement: Surveys, feedback requests, loyalty program updates
This tutorial shows you how to build a scalable bulk SMS system that handles thousands of messages reliably using message queues and the Vonage Messages API.
Project Overview and Goals
What You're Building:
You're building a backend service with a simple API endpoint that accepts recipient phone numbers and message text. The service queues individual SMS jobs and processes them reliably using the Vonage Messages API while respecting rate limits and handling errors gracefully.
Why Use a Queue-Based Architecture for Bulk SMS?
Sending thousands of SMS messages in a single web request loop fails due to:
API Rate Limits: Vonage and carriers impose throughput limits.
Request Timeouts: Long-running loops cause HTTP timeouts.
Lack of Reliability: Server crashes mid-loop lose messages without tracking.
Scalability Issues: Direct loops don't scale horizontally.
This solution introduces a message queue that decouples API requests from SMS sending.
Technologies Used:
Node.js: JavaScript runtime for building scalable I/O-bound applications.
Express: Minimal Node.js web framework for the API layer.
Vonage Messages API: Unified API for sending messages across channels, including SMS. The Messages API is at General Availability (GA) status and supports SMS text messages up to 1000 characters (source: Vonage Messages API v1.0 Documentation). Provides flexibility and features like status webhooks.
@vonage/server-sdk: Official Vonage Node.js SDK.
Redis: In-memory data store serving as a fast, reliable message queue broker.
BullMQ: Robust Node.js library for creating and managing Redis-backed message queues.
Prisma: Modern ORM for Node.js and TypeScript handling database interactions (recipient management).
PostgreSQL (or other SQL DB): Relational database storing recipient information.
dotenv: Module loading environment variables from .env into process.env.
express-validator: Input validation for Express API.
A client sends a POST request to the Express API with a message and recipient list.
The Express app validates the request, fetches recipient details from PostgreSQL (using Prisma), and adds individual SMS jobs (one per recipient) to the BullMQ queue (Redis).
A separate Node.js worker process listens to the BullMQ queue.
The worker picks up jobs one by one, uses the Vonage SDK to send the SMS via the Vonage API.
(Optional but Recommended) Vonage sends message status updates (e.g., delivered, failed) to a configured webhook endpoint on the Express server.
The worker and/or webhook handler can log status updates or update the database.
Prerequisites:
Node.js: Version 20.x or later recommended (Node.js 20 LTS "Iron" and 22 LTS "Jod" are currently supported through April 2026 and April 2027 respectively). (Download Node.js)
You'll need to create a Vonage Application and generate a Private Key.
You'll need a Vonage virtual number capable of sending SMS.
Vonage CLI (Optional but Recommended):npm install -g @vonage/cli for easier setup and management.
Redis: A running Redis instance accessible to your application. (Install Redis or use a cloud provider).
PostgreSQL (or other DB): A running SQL database instance. (Install PostgreSQL or use a cloud provider).
ngrok (for local development): To expose your local server for testing Vonage webhooks. (Download ngrok)
Basic understanding of JavaScript, Node.js, REST APIs, and Promises.
Note: While this guide covers the basics, implementing a truly production-ready system with robust security, monitoring, and deployment strategies requires intermediate-to-advanced knowledge beyond these prerequisites.
1. Setting up the Project
Let's initialize the project, install dependencies, and configure the basic structure.
1.1 Create Project Directory and Initialize:
Bash
mkdir vonage-bulk-sms
cd vonage-bulk-sms
npm init -y
Initialize Prisma, which creates a prisma directory with a schema.prisma file and a .env file (if one doesn't exist).
Bash
# Replace postgresql with your chosen DB provider if different (e.g., mysql, sqlite)
npx prisma init --datasource-provider postgresql
1.4 Configure Environment Variables (.env):
Create a .env file in the project root. Never commit this file to version control. Add the following variables, obtaining the values as described:
.env
# .env
# Vonage API Credentials (Get from Vonage Dashboard > API Settings)
VONAGE_API_KEY=YOUR_VONAGE_API_KEY
VONAGE_API_SECRET=YOUR_VONAGE_API_SECRET
# Vonage Application Credentials (Create an Application in Vonage Dashboard > Your Applications)
# Application ID is shown after creation.
VONAGE_APPLICATION_ID=YOUR_VONAGE_APPLICATION_ID
# Path relative to project root where you save the generated private key file.
VONAGE_PRIVATE_KEY_PATH=./private.key
# Vonage Number (Purchase/find in Vonage Dashboard > Numbers > Your Numbers)
# Use E.164 format (e.g., 12015550123)
VONAGE_NUMBER=YOUR_VONAGE_VIRTUAL_NUMBER
# Database Connection String (Specific to your DB setup)
# Example for PostgreSQL: postgresql://USER:PASSWORD@HOST:PORT/DATABASE
DATABASE_URL="postgresql://user:password@localhost:5432/bulk_sms?schema=public"
# Redis Connection URL (Specific to your Redis setup)
# Example: redis://HOST:PORT or redis://:PASSWORD@HOST:PORT
REDIS_URL="redis://localhost:6379"
# Server Port
PORT=3000
# API Key for simple authentication (Generate a strong random string)
API_SECRET_KEY=YOUR_STRONG_RANDOM_API_KEY
Getting Vonage Credentials:
API Key/Secret: Log in to the Vonage API Dashboard. They are displayed prominently on the home page.
Application ID / Private Key:
Navigate to "Your applications" > "Create a new application".
Give it a name (e.g., "Bulk SMS Broadcaster").
Enable the "Messages" capability.
Enter placeholder URLs for Status and Inbound (we'll configure these later, e.g., http://localhost:3000/webhooks/status and http://localhost:3000/webhooks/inbound). You'll update these with your ngrok or public URL.
Click "Generate public and private key". Save the private.key file immediately into your project root (or the path specified in VONAGE_PRIVATE_KEY_PATH).
Click "Generate new application".
Copy the generated Application ID.
Link your Vonage virtual number to this application under the "Linked numbers" section.
Vonage Number: Ensure all phone numbers use E.164 format (country code + number without leading + or 00, e.g., 12015550123).
US Bulk SMS Compliance: If you're sending bulk SMS to US recipients, review 10DLC registration requirements to ensure compliance with carrier regulations and avoid message filtering.
Database URL: Format depends on your database. See Prisma Docs for details.
Redis URL: Format depends on your Redis setup (local, cloud, password-protected).
API Secret Key: Generate a secure random string for basic API protection.
1.5 Define Project Structure:
Create the following directories and files:
Text
vonage-bulk-sms/
├── prisma/
│ ├── schema.prisma # Database schema definition
│ └── migrations/ # Generated by Prisma Migrate
├── node_modules/
├── .env # Environment variables (DO NOT COMMIT)
├── .gitignore # Git ignore file
├── index.js # Main application entry point (API Server)
├── worker.js # Queue worker process
├── config.js # Centralized configuration loader
├── queue.js # Queue definition and setup
├── services/
│ ├── vonageClient.js # Vonage SDK initialization
│ └── prismaClient.js # Prisma client initialization
├── routes/
│ ├── broadcast.js # API routes for broadcasting
│ └── webhooks.js # Routes for Vonage webhooks
├── middleware/
│ └── auth.js # Authentication middleware
├── private.key # Your Vonage private key (DO NOT COMMIT)
├── package.json
└── package-lock.json
1.6 Configure .gitignore:
Add at least the following to your .gitignore file:
This process listens to the queue and sends the SMS messages.
JavaScript
// worker.jsconst { Worker } = require('./queue'); // Import Worker from queue setupconst config = require('./config');
const { messages } = require('./services/vonageClient'); // Use the Messages clientconstIORedis = require('ioredis');
const connection = newIORedis(config.redis.url, {
maxRetriesPerRequest: null
});
connection.on('error', err => {
console.error('Worker Redis Connection Error:', err);
});
console.log(`Worker connecting to Redis at ${config.redis.url} for queue ${config.queue.name}`);
const worker = newWorker(config.queue.name, async job => {
const { to, message } = job.data;
console.log(`Processing job ${job.id}: Sending SMS to ${to}`);
try {
const response = await messages.send({
message_type: "text",
to: to, // Recipient numberfrom: config.vonage.senderNumber, // Your Vonage numberchannel: "sms",
text: message, // The message content// Optional: Client reference for tracking// client_ref: `job-${job.id}`
});
console.log(`Job ${job.id}: SMS sent to ${to}. Message UUID: ${response.message_uuid}`);
return { message_uuid: response.message_uuid }; // Return data on success
} catch (error) {
console.error(`Job ${job.id}: Failed to send SMS to ${to}. Error: ${error.message}`, error);
// Check for specific Vonage/network errors if needed// Example: Check for rate limit errors (429 status code in error response)if (error.response && error.response.status === 429) {
console.warn(`Job ${job.id}: Rate limit hit. Re-throwing error for BullMQ to handle retry with backoff.`);
// No manual delay needed here - BullMQ's backoff strategy will handle the delay before the next attempt.
}
// Throw error to signal failure to BullMQ, triggering its retry mechanism based on configured attempts/backoffthrow error;
}
}, {
connection,
concurrency: 5, // Process up to 5 jobs concurrently (Adjust based on Vonage/Carrier limits)limiter: { // Rate limitingmax: 25, // Max 25 jobsduration: 1000// per 1 second (Adjust based on Vonage/Carrier limits - 30/sec is Vonage API max)
},
attempts: 3, // Retry failed jobs up to 3 timesbackoff: { // Exponential backoff strategytype: 'exponential',
delay: 5000, // Initial delay 5 seconds
}
});
worker.on('error', err => {
// Log worker errorsconsole.error('Worker encountered an error:', err);
});
console.log("SMS Worker started. Waiting for jobs...");
Concurrency & Rate Limiting: The concurrency and limiter options in the Worker constructor are crucial.
concurrency: How many jobs the worker processes simultaneously.
limiter: How many jobs are started within a given duration. Set this based on your Vonage account limits and, more importantly, any 10DLC or carrier-specific throughput limits (see Caveats section). Start conservatively (e.g., 5-10/sec) and monitor.
Retries & Backoff: BullMQ handles retries automatically for failed jobs based on attempts and backoff settings. Exponential backoff prevents hammering the API after transient failures. When an error is thrown from the job processor, BullMQ catches it and schedules a retry according to the backoff strategy.
3. Building the API Layer (Express)
Now, let's create the Express server and the API endpoint to trigger broadcasts.
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql" // Or your chosen DB
url = env("DATABASE_URL")
}
model Recipient {
id String @id @default(cuid())
phoneNumber String @unique // E.164 format recommended
firstName String?
lastName String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Add other relevant fields like tags, lists, optOut status, etc.
// Example:
// isOptedOut Boolean @default(false)
// tags String[] @default([])
}
// Consider adding models for Broadcasts/Campaigns to track jobs
// model Broadcast {
// id String @id @default(cuid())
// message String
// status String @default("PENDING") // PENDING, PROCESSING, COMPLETED, FAILED
// createdAt DateTime @default(now())
// updatedAt DateTime @updatedAt
// // Relation to jobs or recipients if needed
// }
// Consider adding a model for tracking individual message statuses via webhooks
// model MessageLog {
// id String @id @default(cuid())
// messageUuid String @unique // From Vonage API response/webhook
// recipientNumber String
// broadcastId String? // Optional link to a Broadcast
// status String // e.g., submitted, delivered, failed, rejected
// statusTimestamp DateTime?
// createdAt DateTime @default(now())
// updatedAt DateTime @updatedAt
// }
3.3 Apply Database Migrations:
Generate and apply the SQL migration to create the Recipient table.
Bash
# Generate SQL migration files based on schema changes
npx prisma migrate dev --name init_recipients
# Optional: Populate with sample data (Create a seed script)# npx prisma db seed (Requires setup in package.json and a seed file)
3.4 Create API Authentication Middleware (middleware/auth.js):
A simple API key check. For production, use more robust methods (JWT, OAuth).
JavaScript
// middleware/auth.jsconst config = require('../config');
constauthenticateApiKey = (req, res, next) => {
const apiKey = req.headers['x-api-key'];
if (!config.auth.apiKey) {
console.warn("API Key auth middleware active, but no API_SECRET_KEY set in config. Allowing request.");
returnnext(); // Allow if no key is configured (for local dev maybe)
}
if (!apiKey || apiKey !== config.auth.apiKey) {
return res.status(401).json({ message: 'Unauthorized: Invalid or missing API key' });
}
next();
};
module.exports = { authenticateApiKey };
3.5 Create Broadcast Route (routes/broadcast.js):
Handles the /broadcast endpoint.
JavaScript
// routes/broadcast.jsconst express = require('express');
const { body, validationResult } = require('express-validator');
const { smsQueue } = require('../queue');
const prisma = require('../services/prismaClient');
const router = express.Router();
router.post(
'/',
[ // Input validationbody('message').isString().notEmpty().withMessage('Message text is required'),
body('recipientIds')
.optional({ nullable: true })
.isArray({ min: 1 }).withMessage('recipientIds must be an array with at least one ID if provided'),
body('recipientIds.*').isString().withMessage('Each recipientId must be a string'),
// --- Placeholder for additional recipient selection methods ---// Validation for alternative ways (e.g., tags, all) would go here.// body('recipientTags').optional().isArray()...// body('sendToAll').optional().isBoolean()...// --- Mutual Exclusivity Check Example ---// Ensure only one method of specifying recipients is used per request.// This example assumes recipientIds is the only method implemented for now.body().custom((value, { req }) => {
const { recipientIds /*, recipientTags, sendToAll */ } = req.body;
// Extend this logic if recipientTags or sendToAll are implementedconst providedMethods = [recipientIds].filter(v => v !== undefined).length;
if (providedMethods !== 1) {
// Adjust error message if other methods are addedthrownewError('Please provide exactly one method for specifying recipients (e.g., recipientIds).');
}
returntrue;
})
],
async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { message, recipientIds /*, recipientTags, sendToAll */ } = req.body;
let recipients = [];
try {
// --- Logic to fetch recipients based on input ---if (recipientIds) {
recipients = await prisma.recipient.findMany({
where: {
id: { in: recipientIds },
// Add other conditions like NOT opted out// isOptedOut: false
},
select: { phoneNumber: true } // Only select needed field
});
// Check if all provided IDs were foundif (recipients.length !== recipientIds.length) {
console.warn(`Some recipient IDs not found or match filtering criteria (e.g., opted out). Proceeding with ${recipients.length} found recipients.`);
// Decide if this is an error or just proceed with found ones
}
}
// --- Add logic for fetching by tags or all recipients ---// else if (recipientTags) {// console.log('Fetching recipients by tags - Implementation needed.');// // Implementation left as an exercise: query Prisma based on tags.// }// else if (sendToAll) {// console.log('Fetching all recipients - Implementation needed.');// // Implementation left as an exercise: query Prisma for all non-opted-out recipients.// }if (!recipients || recipients.length === 0) {
return res.status(404).json({ message: 'No valid recipients found for the given criteria.' });
}
// --- Enqueue jobs ---const jobPromises = recipients.map(recipient => {
return smsQueue.add(
'send-sms', // Job name (can be more descriptive)
{
to: recipient.phoneNumber,
message: message
},
{
// Optional: Job specific settings like unique job IDs// jobId: `sms-${recipient.id}-${Date.now()}` // Example unique IDremoveOnComplete: true, // Keep queue cleanremoveOnFail: 50// Keep failed jobs for inspection
}
);
});
awaitPromise.all(jobPromises);
const messageCount = recipients.length;
console.log(`Successfully enqueued ${messageCount} SMS jobs for broadcast.`);
res.status(202).json({
message: `Accepted: Queued ${messageCount} SMS messages for delivery.`,
// Optional: Return a broadcast ID for tracking// broadcastId: "some-unique-id"
});
} catch (error) {
console.error('Error processing broadcast request:', error);
res.status(500).json({ message: 'Internal Server Error while queuing messages.' });
}
}
);
module.exports = router;
3.6 Create Webhook Route (routes/webhooks.js):
Handles incoming status updates from Vonage.
JavaScript
// routes/webhooks.jsconst express = require('express');
const prisma = require('../services/prismaClient'); // Import prisma if updating DBconst router = express.Router();
// Endpoint for Vonage Message Status updates
router.post('/status', async (req, res) => { // Make async if doing DB operationsconst statusData = req.body;
console.log('Received Vonage Status Webhook:', JSON.stringify(statusData, null, 2));
// --- Process the status update ---// Examples:// - Log the status (already done above)// - Update a database record tracking the message status using `message_uuid` or `client_ref`// - Trigger alerts on failure statuses ('failed', 'rejected')// --- Example: Update DB (pseudo-code) ---// Note: This requires a 'MessageLog' model in schema.prisma (see schema example)// and corresponding database migration./*
if (statusData.message_uuid && statusData.status) {
try {
// Find the message log entry using the UUID provided by Vonage
await prisma.messageLog.updateMany({ // Use updateMany in case client_ref maps to multiple messages
where: { messageUuid: statusData.message_uuid },
data: {
status: statusData.status,
statusTimestamp: statusData.timestamp ? new Date(statusData.timestamp) : new Date(),
// You might also store error codes if provided
// errorCode: statusData.error?.code,
// networkCode: statusData.network_code
}
});
console.log(`Updated status for message ${statusData.message_uuid} to ${statusData.status}`);
} catch (dbError) {
console.error(`Failed to update message status in DB for UUID ${statusData.message_uuid}:`, dbError);
// Decide how to handle DB errors - potentially retry later or log for investigation
}
} else {
console.warn('Received status webhook without message_uuid or status.');
}
*/// Vonage expects a 200 OK response to acknowledge receipt
res.status(200).send('OK');
});
// Endpoint for Vonage Inbound Messages (if needed for replies)
router.post('/inbound', async (req, res) => { // Make async if doing DB operationsconst inboundData = req.body;
console.log('Received Vonage Inbound SMS:', JSON.stringify(inboundData, null, 2));
// --- Process inbound message ---// Examples:// - Handle STOP/OPT-OUT keywords (update recipient status in DB)// - Route message to support system// - Trigger automated replies// Example: Basic STOP keyword handling/*
if (inboundData.text && inboundData.msisdn) { // msisdn is the sender's number
const messageText = inboundData.text.trim().toUpperCase();
if (messageText === 'STOP' || messageText === 'UNSUBSCRIBE') {
try {
await prisma.recipient.updateMany({
where: { phoneNumber: inboundData.msisdn }, // Use E.164 format match
data: { isOptedOut: true } // Assuming an isOptedOut field
});
console.log(`Recipient ${inboundData.msisdn} opted out.`);
} catch (dbError) {
console.error(`Failed to process opt-out for ${inboundData.msisdn}:`, dbError);
}
}
}
*/
res.status(200).send('OK');
});
module.exports = router;
3.7 Setup Main Express App (index.js):
Tie everything together.
JavaScript
// index.jsconst express = require('express');
const config = require('./config');
const { authenticateApiKey } = require('./middleware/auth');
const broadcastRoutes = require('./routes/broadcast');
const webhookRoutes = require('./routes/webhooks');
const app = express();
// --- Middleware ---console.log('Setting up middleware...');
app.use(express.json()); // Parse JSON request bodies
app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies (needed for Vonage webhooks sometimes)// --- Routes ---console.log('Registering routes...');
// Apply API key auth ONLY to the broadcast route
app.use('/api/broadcast', authenticateApiKey, broadcastRoutes);
// Webhooks should generally be open but could have signature validation (see Vonage docs - recommended for production)
app.use('/webhooks', webhookRoutes);
// --- Basic Health Check ---
app.get('/health', (req, res) => {
res.status(200).json({ status: 'UP', timestamp: newDate().toISOString() });
});
// --- Error Handling (Basic) ---// Catch-all for unhandled routes
app.use((req, res, next) => {
res.status(404).json({ message: 'Not Found' });
});
// Global error handler
app.use((err, req, res, next) => {
console.error('Unhandled Application Error:', err);
res.status(err.status || 500).json({
message: err.message || 'Internal Server Error',
// Optionally include stack trace in development// stack: process.env.NODE_ENV === 'development' ? err.stack : undefined
});
});
// --- Start Server ---constPORT = config.server.port;
app.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
console.log(`Redis configured at ${config.redis.url}`);
console.log(`API Key Authentication ${config.auth.apiKey ? 'Enabled' : 'Disabled'}`);
console.log(`Vonage Sender Number: ${config.vonage.senderNumber}`);
});
4. Testing Your Bulk SMS System
Before deploying to production, thoroughly test your bulk SMS broadcasting system.
Local Development with ngrok
Start your Express server:
Bash
node index.js
Start your worker process in a separate terminal:
Bash
node worker.js
Start ngrok to expose your local server:
Bash
ngrok http 3000
Update Vonage webhook URLs:
Copy the https://<your-random-id>.ngrok.io forwarding URL
Go to your Vonage Application settings in the dashboard
Update Status URL to https://<your-ngrok-url>/webhooks/status
Update Inbound URL to https://<your-ngrok-url>/webhooks/inbound
How many SMS messages can I send per second with Vonage?
The Vonage Messages API supports up to 30 requests per second. However, you should also consider carrier-specific throughput limits and 10DLC restrictions for US messaging. Start with conservative rate limits (5-10 messages/second) and gradually increase based on monitoring.
What's the difference between Vonage SMS API and Messages API?
The Messages API is the newer, unified API that supports multiple channels (SMS, MMS, WhatsApp, Viber, Facebook Messenger). It provides better webhook support and more features. The SMS API is legacy but still supported. This tutorial uses the Messages API for better functionality and future compatibility.
How do I handle failed SMS messages?
BullMQ automatically retries failed jobs based on the attempts and backoff configuration. Monitor failed jobs in the queue, log errors to your monitoring system, and implement alerts. Consider adding a dead letter queue for messages that fail after all retry attempts.
Can I send MMS messages with this system?
Yes, you can modify the worker to send MMS by changing the message_type to "image" and adding media URLs. See the Vonage MMS documentation for details.
How do I prevent sending duplicate messages?
Implement deduplication logic in your broadcast route by checking for duplicate phone numbers in the recipient list. You can also use BullMQ's jobId option to create unique job identifiers based on recipient and message content.
What's the cost of sending bulk SMS with Vonage?
Vonage SMS pricing varies by destination country. Check the Vonage pricing calculator for specific rates. Monitor your usage through the Vonage dashboard to control costs.
Frequently asked questions
Use the Vonage Messages API with a queueing system like BullMQ and Redis. This approach handles rate limits, prevents timeouts, ensures reliability, and improves scalability by decoupling the API request from the actual SMS sending process. The provided tutorial offers a step-by-step guide for setting up this system using Express.js and Node.js.
The Vonage Messages API is a versatile tool for sending messages through various channels, including SMS. In this tutorial, it's the core component for sending bulk SMS messages from your Node.js application, providing flexibility and essential features like status webhooks for tracking message delivery.
Sending many SMS messages directly in a loop can lead to issues with API rate limits imposed by Vonage and carriers, request timeouts due to long processing, reliability concerns if the server crashes, and difficulty scaling the application effectively.
A message queue is essential when sending bulk SMS to manage API rate limits, prevent request timeouts, ensure reliability, and enable horizontal scaling. It decouples the API request from the sending process, allowing the application to handle large volumes of messages efficiently.
Yes, Prisma supports various database providers like PostgreSQL, MySQL, and SQLite. Specify your preferred provider in the schema.prisma file's datasource configuration. The tutorial provides examples for PostgreSQL, but you can adapt it to other databases.
Obtain your API Key and API Secret from the Vonage API Dashboard. Create a Vonage Application, generate a Private Key (store securely), and copy the Application ID. Link a Vonage virtual number to the application, ensuring it's SMS-capable. These credentials are then added to your project's .env file.
Redis acts as a fast, in-memory data store and message broker, working with BullMQ to create the message queue. This queue stores individual SMS jobs until the worker process picks them up for sending, enabling asynchronous processing and handling potential rate limits.
Configure a webhook URL in your Vonage Application settings (and your Express app). Vonage will send status updates (e.g., 'delivered', 'failed') to this endpoint. The webhook handler in your app can then log, update databases, or trigger alerts based on these updates.
ngrok creates a secure tunnel to your local development server, making it publicly accessible. This allows Vonage webhooks to reach your application during testing, even if it's running on your local machine.
The concurrency option in the BullMQ worker controls how many SMS jobs the worker processes concurrently. It should be set based on Vonage and carrier limits, starting conservatively to avoid exceeding rate limits and monitoring performance as you adjust.
While Vonage has API limits, carrier limits are usually lower, especially with 10DLC. This is why using a queue and setting conservative concurrency and rate limiting in BullMQ is essential. Start with a low rate (e.g., 5-10 messages/second) and gradually increase while observing Vonage and carrier limitations.
The BullMQ worker provides retry mechanisms with exponential backoff. This is essential for handling transient errors. The worker code also includes error handling to catch issues, log them, and check for specific error codes like rate limiting (429 status) for more informed handling and retry strategies.
Install PostgreSQL locally or use a cloud-based instance. Configure the connection URL in your .env file. Prisma handles database interactions and migrations. Use npx prisma migrate dev to create the necessary tables defined in your schema.prisma file.
Prisma is an Object-Relational Mapper (ORM) that simplifies database interactions in Node.js. It's used for managing recipient information stored in the database, including fetching recipients for broadcasts and potentially updating opt-out statuses based on webhook data.
This guide walks you through building a production-ready bulk SMS broadcasting system using Node.js, Express, and the Vonage Messages API. You'll learn how to send bulk SMS messages with Vonage using a queue-based architecture with BullMQ and Redis for reliable, high-volume messaging. The tutorial covers project setup, core implementation, error handling, security, performance optimization, monitoring, and deployment.
By the end, you'll have a functional API that queues and reliably sends large volumes of SMS messages with logging, error handling, and monitoring.
What Is Bulk SMS Broadcasting?
Bulk SMS broadcasting enables businesses to send high-volume text messages to multiple recipients simultaneously. Common use cases include:
This tutorial shows you how to build a scalable bulk SMS system that handles thousands of messages reliably using message queues and the Vonage Messages API.
Project Overview and Goals
What You're Building:
You're building a backend service with a simple API endpoint that accepts recipient phone numbers and message text. The service queues individual SMS jobs and processes them reliably using the Vonage Messages API while respecting rate limits and handling errors gracefully.
Why Use a Queue-Based Architecture for Bulk SMS?
Sending thousands of SMS messages in a single web request loop fails due to:
This solution introduces a message queue that decouples API requests from SMS sending.
Technologies Used:
@vonage/server-sdk: Official Vonage Node.js SDK.dotenv: Module loading environment variables from.envintoprocess.env.express-validator: Input validation for Express API.System Architecture:
delivered,failed) to a configured webhook endpoint on the Express server.Prerequisites:
npm install -g @vonage/clifor easier setup and management.ngrok(for local development): To expose your local server for testing Vonage webhooks. (Download ngrok)1. Setting up the Project
Let's initialize the project, install dependencies, and configure the basic structure.
1.1 Create Project Directory and Initialize:
mkdir vonage-bulk-sms cd vonage-bulk-sms npm init -y1.2 Install Dependencies:
# Core Framework & Vonage SDK npm install express @vonage/server-sdk dotenv # Queueing System npm install bullmq ioredis # Database ORM & Client npm install @prisma/client npm install prisma --save-dev # Input Validation npm install express-validator1.3 Setup Prisma:
Initialize Prisma, which creates a
prismadirectory with aschema.prismafile and a.envfile (if one doesn't exist).# Replace postgresql with your chosen DB provider if different (e.g., mysql, sqlite) npx prisma init --datasource-provider postgresql1.4 Configure Environment Variables (
.env):Create a
.envfile in the project root. Never commit this file to version control. Add the following variables, obtaining the values as described:http://localhost:3000/webhooks/statusandhttp://localhost:3000/webhooks/inbound). You'll update these with yourngrokor public URL.private.keyfile immediately into your project root (or the path specified inVONAGE_PRIVATE_KEY_PATH).1.5 Define Project Structure:
Create the following directories and files:
1.6 Configure
.gitignore:Add at least the following to your
.gitignorefile:2. Implementing Core Functionality (Queue & Worker)
The core idea is to enqueue messages rapidly via the API and process them reliably in a separate worker.
2.1 Configure Centralized Settings (
config.js):Load environment variables safely.
// config.js require('dotenv').config(); const config = { vonage: { apiKey: process.env.VONAGE_API_KEY, apiSecret: process.env.VONAGE_API_SECRET, applicationId: process.env.VONAGE_APPLICATION_ID, privateKeyPath: process.env.VONAGE_PRIVATE_KEY_PATH, senderNumber: process.env.VONAGE_NUMBER, }, database: { url: process.env.DATABASE_URL, }, redis: { url: process.env.REDIS_URL, }, server: { port: process.env.PORT || 3000, }, auth: { apiKey: process.env.API_SECRET_KEY, }, queue: { name: 'smsQueue', } }; // Basic validation if (!config.vonage.apiKey || !config.vonage.apiSecret || !config.vonage.applicationId || !config.vonage.privateKeyPath || !config.vonage.senderNumber) { console.error("Vonage configuration missing in .env file"); process.exit(1); } if (!config.database.url) { console.error("DATABASE_URL missing in .env file"); process.exit(1); } if (!config.redis.url) { console.error("REDIS_URL missing in .env file"); process.exit(1); } if (!config.auth.apiKey) { console.warn("API_SECRET_KEY not set in .env file. API will be unprotected."); } module.exports = config;2.2 Initialize Vonage Client (
services/vonageClient.js):// services/vonageClient.js const { Vonage } = require('@vonage/server-sdk'); const { Messages } = require('@vonage/messages'); const config = require('../config'); const fs = require('fs'); let privateKey; try { privateKey = fs.readFileSync(config.vonage.privateKeyPath); } catch (err) { console.error(`Error reading private key from ${config.vonage.privateKeyPath}:`, err.message); process.exit(1); // Exit if key is essential and missing } const vonage = new Vonage({ apiKey: config.vonage.apiKey, apiSecret: config.vonage.apiSecret, applicationId: config.vonage.applicationId, privateKey: privateKey }, { // Optional: Add custom headers or configure timeouts // appendUserAgent: "my-bulk-sms-app/1.0.0" }); const messages = new Messages(vonage.credentials); // Export the Messages client instance directly for sending SMS module.exports = { messages };2.3 Setup BullMQ Queue (
queue.js):// queue.js const { Queue, Worker, QueueEvents } = require('bullmq'); const IORedis = require('ioredis'); const config = require('./config'); const connection = new IORedis(config.redis.url, { maxRetriesPerRequest: null // BullMQ handles retries }); connection.on('error', err => { console.error('Redis Connection Error:', err); // Optional: Implement more robust error handling/alerting }); const smsQueue = new Queue(config.queue.name, { connection }); // Optional: Listen to queue events for logging/monitoring const queueEvents = new QueueEvents(config.queue.name, { connection }); queueEvents.on('completed', ({ jobId }) => { console.log(`Job ${jobId} completed successfully.`); }); queueEvents.on('failed', ({ jobId, failedReason }) => { console.error(`Job ${jobId} failed: ${failedReason}`); }); // Export necessary components for API server and worker module.exports = { smsQueue, connection, Worker, QueueEvents };2.4 Create the Worker Process (
worker.js):This process listens to the queue and sends the SMS messages.
// worker.js const { Worker } = require('./queue'); // Import Worker from queue setup const config = require('./config'); const { messages } = require('./services/vonageClient'); // Use the Messages client const IORedis = require('ioredis'); const connection = new IORedis(config.redis.url, { maxRetriesPerRequest: null }); connection.on('error', err => { console.error('Worker Redis Connection Error:', err); }); console.log(`Worker connecting to Redis at ${config.redis.url} for queue ${config.queue.name}`); const worker = new Worker(config.queue.name, async job => { const { to, message } = job.data; console.log(`Processing job ${job.id}: Sending SMS to ${to}`); try { const response = await messages.send({ message_type: "text", to: to, // Recipient number from: config.vonage.senderNumber, // Your Vonage number channel: "sms", text: message, // The message content // Optional: Client reference for tracking // client_ref: `job-${job.id}` }); console.log(`Job ${job.id}: SMS sent to ${to}. Message UUID: ${response.message_uuid}`); return { message_uuid: response.message_uuid }; // Return data on success } catch (error) { console.error(`Job ${job.id}: Failed to send SMS to ${to}. Error: ${error.message}`, error); // Check for specific Vonage/network errors if needed // Example: Check for rate limit errors (429 status code in error response) if (error.response && error.response.status === 429) { console.warn(`Job ${job.id}: Rate limit hit. Re-throwing error for BullMQ to handle retry with backoff.`); // No manual delay needed here - BullMQ's backoff strategy will handle the delay before the next attempt. } // Throw error to signal failure to BullMQ, triggering its retry mechanism based on configured attempts/backoff throw error; } }, { connection, concurrency: 5, // Process up to 5 jobs concurrently (Adjust based on Vonage/Carrier limits) limiter: { // Rate limiting max: 25, // Max 25 jobs duration: 1000 // per 1 second (Adjust based on Vonage/Carrier limits - 30/sec is Vonage API max) }, attempts: 3, // Retry failed jobs up to 3 times backoff: { // Exponential backoff strategy type: 'exponential', delay: 5000, // Initial delay 5 seconds } }); worker.on('error', err => { // Log worker errors console.error('Worker encountered an error:', err); }); console.log("SMS Worker started. Waiting for jobs...");concurrencyandlimiteroptions in theWorkerconstructor are crucial.concurrency: How many jobs the worker processes simultaneously.limiter: How many jobs are started within a given duration. Set this based on your Vonage account limits and, more importantly, any 10DLC or carrier-specific throughput limits (see Caveats section). Start conservatively (e.g., 5-10/sec) and monitor.attemptsandbackoffsettings. Exponential backoff prevents hammering the API after transient failures. When an error is thrown from the job processor, BullMQ catches it and schedules a retry according to thebackoffstrategy.3. Building the API Layer (Express)
Now, let's create the Express server and the API endpoint to trigger broadcasts.
3.1 Initialize Prisma Client (
services/prismaClient.js):// services/prismaClient.js const { PrismaClient } = require('@prisma/client'); const prisma = new PrismaClient(); module.exports = prisma;3.2 Define Database Schema (
prisma/schema.prisma):Add a model to store recipient information.
3.3 Apply Database Migrations:
Generate and apply the SQL migration to create the
Recipienttable.# Generate SQL migration files based on schema changes npx prisma migrate dev --name init_recipients # Optional: Populate with sample data (Create a seed script) # npx prisma db seed (Requires setup in package.json and a seed file)3.4 Create API Authentication Middleware (
middleware/auth.js):A simple API key check. For production, use more robust methods (JWT, OAuth).
// middleware/auth.js const config = require('../config'); const authenticateApiKey = (req, res, next) => { const apiKey = req.headers['x-api-key']; if (!config.auth.apiKey) { console.warn("API Key auth middleware active, but no API_SECRET_KEY set in config. Allowing request."); return next(); // Allow if no key is configured (for local dev maybe) } if (!apiKey || apiKey !== config.auth.apiKey) { return res.status(401).json({ message: 'Unauthorized: Invalid or missing API key' }); } next(); }; module.exports = { authenticateApiKey };3.5 Create Broadcast Route (
routes/broadcast.js):Handles the
/broadcastendpoint.// routes/broadcast.js const express = require('express'); const { body, validationResult } = require('express-validator'); const { smsQueue } = require('../queue'); const prisma = require('../services/prismaClient'); const router = express.Router(); router.post( '/', [ // Input validation body('message').isString().notEmpty().withMessage('Message text is required'), body('recipientIds') .optional({ nullable: true }) .isArray({ min: 1 }).withMessage('recipientIds must be an array with at least one ID if provided'), body('recipientIds.*').isString().withMessage('Each recipientId must be a string'), // --- Placeholder for additional recipient selection methods --- // Validation for alternative ways (e.g., tags, all) would go here. // body('recipientTags').optional().isArray()... // body('sendToAll').optional().isBoolean()... // --- Mutual Exclusivity Check Example --- // Ensure only one method of specifying recipients is used per request. // This example assumes recipientIds is the only method implemented for now. body().custom((value, { req }) => { const { recipientIds /*, recipientTags, sendToAll */ } = req.body; // Extend this logic if recipientTags or sendToAll are implemented const providedMethods = [recipientIds].filter(v => v !== undefined).length; if (providedMethods !== 1) { // Adjust error message if other methods are added throw new Error('Please provide exactly one method for specifying recipients (e.g., recipientIds).'); } return true; }) ], async (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ errors: errors.array() }); } const { message, recipientIds /*, recipientTags, sendToAll */ } = req.body; let recipients = []; try { // --- Logic to fetch recipients based on input --- if (recipientIds) { recipients = await prisma.recipient.findMany({ where: { id: { in: recipientIds }, // Add other conditions like NOT opted out // isOptedOut: false }, select: { phoneNumber: true } // Only select needed field }); // Check if all provided IDs were found if (recipients.length !== recipientIds.length) { console.warn(`Some recipient IDs not found or match filtering criteria (e.g., opted out). Proceeding with ${recipients.length} found recipients.`); // Decide if this is an error or just proceed with found ones } } // --- Add logic for fetching by tags or all recipients --- // else if (recipientTags) { // console.log('Fetching recipients by tags - Implementation needed.'); // // Implementation left as an exercise: query Prisma based on tags. // } // else if (sendToAll) { // console.log('Fetching all recipients - Implementation needed.'); // // Implementation left as an exercise: query Prisma for all non-opted-out recipients. // } if (!recipients || recipients.length === 0) { return res.status(404).json({ message: 'No valid recipients found for the given criteria.' }); } // --- Enqueue jobs --- const jobPromises = recipients.map(recipient => { return smsQueue.add( 'send-sms', // Job name (can be more descriptive) { to: recipient.phoneNumber, message: message }, { // Optional: Job specific settings like unique job IDs // jobId: `sms-${recipient.id}-${Date.now()}` // Example unique ID removeOnComplete: true, // Keep queue clean removeOnFail: 50 // Keep failed jobs for inspection } ); }); await Promise.all(jobPromises); const messageCount = recipients.length; console.log(`Successfully enqueued ${messageCount} SMS jobs for broadcast.`); res.status(202).json({ message: `Accepted: Queued ${messageCount} SMS messages for delivery.`, // Optional: Return a broadcast ID for tracking // broadcastId: "some-unique-id" }); } catch (error) { console.error('Error processing broadcast request:', error); res.status(500).json({ message: 'Internal Server Error while queuing messages.' }); } } ); module.exports = router;3.6 Create Webhook Route (
routes/webhooks.js):Handles incoming status updates from Vonage.
// routes/webhooks.js const express = require('express'); const prisma = require('../services/prismaClient'); // Import prisma if updating DB const router = express.Router(); // Endpoint for Vonage Message Status updates router.post('/status', async (req, res) => { // Make async if doing DB operations const statusData = req.body; console.log('Received Vonage Status Webhook:', JSON.stringify(statusData, null, 2)); // --- Process the status update --- // Examples: // - Log the status (already done above) // - Update a database record tracking the message status using `message_uuid` or `client_ref` // - Trigger alerts on failure statuses ('failed', 'rejected') // --- Example: Update DB (pseudo-code) --- // Note: This requires a 'MessageLog' model in schema.prisma (see schema example) // and corresponding database migration. /* if (statusData.message_uuid && statusData.status) { try { // Find the message log entry using the UUID provided by Vonage await prisma.messageLog.updateMany({ // Use updateMany in case client_ref maps to multiple messages where: { messageUuid: statusData.message_uuid }, data: { status: statusData.status, statusTimestamp: statusData.timestamp ? new Date(statusData.timestamp) : new Date(), // You might also store error codes if provided // errorCode: statusData.error?.code, // networkCode: statusData.network_code } }); console.log(`Updated status for message ${statusData.message_uuid} to ${statusData.status}`); } catch (dbError) { console.error(`Failed to update message status in DB for UUID ${statusData.message_uuid}:`, dbError); // Decide how to handle DB errors - potentially retry later or log for investigation } } else { console.warn('Received status webhook without message_uuid or status.'); } */ // Vonage expects a 200 OK response to acknowledge receipt res.status(200).send('OK'); }); // Endpoint for Vonage Inbound Messages (if needed for replies) router.post('/inbound', async (req, res) => { // Make async if doing DB operations const inboundData = req.body; console.log('Received Vonage Inbound SMS:', JSON.stringify(inboundData, null, 2)); // --- Process inbound message --- // Examples: // - Handle STOP/OPT-OUT keywords (update recipient status in DB) // - Route message to support system // - Trigger automated replies // Example: Basic STOP keyword handling /* if (inboundData.text && inboundData.msisdn) { // msisdn is the sender's number const messageText = inboundData.text.trim().toUpperCase(); if (messageText === 'STOP' || messageText === 'UNSUBSCRIBE') { try { await prisma.recipient.updateMany({ where: { phoneNumber: inboundData.msisdn }, // Use E.164 format match data: { isOptedOut: true } // Assuming an isOptedOut field }); console.log(`Recipient ${inboundData.msisdn} opted out.`); } catch (dbError) { console.error(`Failed to process opt-out for ${inboundData.msisdn}:`, dbError); } } } */ res.status(200).send('OK'); }); module.exports = router;3.7 Setup Main Express App (
index.js):Tie everything together.
// index.js const express = require('express'); const config = require('./config'); const { authenticateApiKey } = require('./middleware/auth'); const broadcastRoutes = require('./routes/broadcast'); const webhookRoutes = require('./routes/webhooks'); const app = express(); // --- Middleware --- console.log('Setting up middleware...'); app.use(express.json()); // Parse JSON request bodies app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies (needed for Vonage webhooks sometimes) // --- Routes --- console.log('Registering routes...'); // Apply API key auth ONLY to the broadcast route app.use('/api/broadcast', authenticateApiKey, broadcastRoutes); // Webhooks should generally be open but could have signature validation (see Vonage docs - recommended for production) app.use('/webhooks', webhookRoutes); // --- Basic Health Check --- app.get('/health', (req, res) => { res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() }); }); // --- Error Handling (Basic) --- // Catch-all for unhandled routes app.use((req, res, next) => { res.status(404).json({ message: 'Not Found' }); }); // Global error handler app.use((err, req, res, next) => { console.error('Unhandled Application Error:', err); res.status(err.status || 500).json({ message: err.message || 'Internal Server Error', // Optionally include stack trace in development // stack: process.env.NODE_ENV === 'development' ? err.stack : undefined }); }); // --- Start Server --- const PORT = config.server.port; app.listen(PORT, () => { console.log(`Server listening on port ${PORT}`); console.log(`Redis configured at ${config.redis.url}`); console.log(`API Key Authentication ${config.auth.apiKey ? 'Enabled' : 'Disabled'}`); console.log(`Vonage Sender Number: ${config.vonage.senderNumber}`); });4. Testing Your Bulk SMS System
Before deploying to production, thoroughly test your bulk SMS broadcasting system.
Local Development with ngrok
Start your Express server:
Start your worker process in a separate terminal:
Start ngrok to expose your local server:
Update Vonage webhook URLs:
https://<your-random-id>.ngrok.ioforwarding URLhttps://<your-ngrok-url>/webhooks/statushttps://<your-ngrok-url>/webhooks/inboundTesting the Broadcast Endpoint
Send a POST request to your broadcast endpoint:
curl -X POST http://localhost:3000/api/broadcast \ -H "Content-Type: application/json" \ -H "x-api-key: YOUR_STRONG_RANDOM_API_KEY" \ -d '{ "message": "Test bulk SMS message from Vonage", "recipientIds": ["recipient-id-1", "recipient-id-2"] }'Monitor the logs in both the Express server and worker terminals to verify:
5. Production Deployment Considerations
When deploying your bulk SMS system to production:
Infrastructure Requirements
Scaling Strategies
concurrencyandlimitersettings based on your Vonage API rate limits and carrier requirementsphoneNumber,isOptedOut)Security Best Practices
Monitoring and Logging
Related Resources
Frequently Asked Questions
How many SMS messages can I send per second with Vonage?
The Vonage Messages API supports up to 30 requests per second. However, you should also consider carrier-specific throughput limits and 10DLC restrictions for US messaging. Start with conservative rate limits (5-10 messages/second) and gradually increase based on monitoring.
What's the difference between Vonage SMS API and Messages API?
The Messages API is the newer, unified API that supports multiple channels (SMS, MMS, WhatsApp, Viber, Facebook Messenger). It provides better webhook support and more features. The SMS API is legacy but still supported. This tutorial uses the Messages API for better functionality and future compatibility.
How do I handle failed SMS messages?
BullMQ automatically retries failed jobs based on the
attemptsandbackoffconfiguration. Monitor failed jobs in the queue, log errors to your monitoring system, and implement alerts. Consider adding a dead letter queue for messages that fail after all retry attempts.Can I send MMS messages with this system?
Yes, you can modify the worker to send MMS by changing the
message_typeto"image"and adding media URLs. See the Vonage MMS documentation for details.How do I prevent sending duplicate messages?
Implement deduplication logic in your broadcast route by checking for duplicate phone numbers in the recipient list. You can also use BullMQ's
jobIdoption to create unique job identifiers based on recipient and message content.What's the cost of sending bulk SMS with Vonage?
Vonage SMS pricing varies by destination country. Check the Vonage pricing calculator for specific rates. Monitor your usage through the Vonage dashboard to control costs.