How to Send Bulk SMS with Node.js, Express, and Vonage Messages API
Learn how to build a production-ready bulk SMS system using Node.js, Express, and Vonage Messages API. Complete tutorial covering rate limiting, webhooks, delivery receipts, JWT authentication, and database integration with Prisma and PostgreSQL.
Learn how to build a robust system that sends bulk SMS messages using Node.js, Express, and the Vonage Messages API. This comprehensive tutorial covers everything from initial project setup to handling rate limits, implementing JWT authentication, managing webhooks, and integrating with PostgreSQL using Prisma.
Whether you're sending marketing campaigns, appointment reminders, or transactional notifications, this guide will help you reliably send SMS messages to hundreds or thousands of recipients while navigating API rate limits, carrier regulations (like A2P 10DLC in the US), and tracking delivery status.
What You'll Build: A Node.js/Express application with an API endpoint that accepts a list of recipients and a message, then efficiently sends SMS via Vonage while managing concurrency, handling errors, and tracking delivery receipts.
Technologies You'll Use:
Node.js: JavaScript runtime environment for building scalable server-side applications.
Express: Minimalist web framework for Node.js – builds your API layer.
Vonage Messages API: Vonage's unified messaging API for sending SMS, MMS, and messages across multiple channels. You'll use the @vonage/server-sdk and @vonage/messages.
dotenv: Module that loads environment variables from a .env file into process.env.
(Optional) Prisma & PostgreSQL: Manages recipient lists and logs message status in a database.
(Optional) p-queue: A promise queue library for concurrency control – crucial for managing Vonage API rate limits.
@vonage/server-sdk: Official Vonage SDK for Node.js (core client).
@vonage/messages: Part of the SDK for constructing message objects.
dotenv: Loads environment variables from .env.
Install Development/Optional Dependencies:
Bash
npm install --save-dev nodemon # Optional: for auto-restarting the server during development
npm install p-queue # Optional: for advanced concurrency control# Optional: For database integration with Prisma
npm install @prisma/client
npm install --save-dev prisma
Create Project Structure:
vonage-bulk-sms/
node_modules/
src/
controllers/
smsController.js
routes/
smsRoutes.js
services/
vonageService.js
utils/
logger.js
prismaClient.js(Optional: if using Prisma)
server.js
prisma/(Optional: for database schema)
schema.prisma
.env
.env.example
.gitignore
private.key(Your downloaded Vonage private key)
package.json
Create these directories and empty files.
Configure .gitignore: Create a .gitignore file in the root directory:
.gitignore
node_modules/
.env
dist/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
private.key # Ensure your private key is not committed
Setup Environment Variables (.env.example): Create a .env.example file. Never commit the actual .env file.
.env.example
# Vonage API Credentials (JWT Authentication)
VONAGE_APPLICATION_ID=YOUR_VONAGE_APP_ID
VONAGE_PRIVATE_KEY_PATH=./private.key # Path relative to project root
# Vonage Number
VONAGE_NUMBER=YOUR_VONAGE_VIRTUAL_NUMBER # E.g., 14155550100
# Application Settings
PORT=3000
LOG_LEVEL=info
# Optional: Database URL (if using Prisma)
# DATABASE_URL="postgresql://user:password@host:port/database?schema=public"
# Optional: Concurrency Settings (if using p-queue)
VONAGE_CONCURRENCY=5 # Adjust based on your Vonage account limits (often starts low)
VONAGE_INTERVAL_MS=1000 # Interval between bursts (e.g., 1000ms = 1 second)
VONAGE_INTERVAL_CAP=5 # Max requests per interval (matches concurrency here)
Create a copy named .env and fill in your actual credentials later. Place your downloaded private.key file in the project root.
Create a Vonage Application: The Messages API requires a Vonage Application. This application acts as a container for configuration, including webhook URLs and security credentials (public/private key pair for JWT authentication).
Navigate to "Applications" in the dashboard menu.
Click "Create a new application".
Give it a name (e.g., "Node Bulk SMS App").
Click "Generate public and private key". A private.key file will be downloaded. Save this file in your project's root directory. Ensure the path in your .env file (VONAGE_PRIVATE_KEY_PATH) matches its location (e.g., ./private.key). Also add private.key to your .gitignore.
Enable the "Messages" capability.
Status URL: This is crucial for tracking message delivery status (Delivery Receipts – DLRs).
If developing locally, run ngrok http YOUR_PORT (e.g., ngrok http 3000).
Copy the https:// Forwarding URL provided by ngrok.
Enter the Status URL as YOUR_NGROK_HTTPS_URL/api/sms/status (you'll create this endpoint later).
In production, use your server's public URL.
Inbound URL: If you also need to receive SMS replies, enter a URL like YOUR_NGROK_HTTPS_URL/api/sms/inbound. For sending only, this can often be left blank or set the same as the status URL, but ideally, handle it even if just with a 200 OK.
Click "Generate new application".
Note the Application ID provided. Add this to your .env file (VONAGE_APPLICATION_ID).
Link Your Vonage Number:
Go back to the Application details page (Applications → Your App Name).
Scroll down to "Link virtual numbers".
Select the Vonage virtual number you want to send SMS from. Add this number (in E.164 format, e.g., 14155550100) to your .env file (VONAGE_NUMBER).
Click "Link".
Set Default SMS API (Important):
Navigate to "Settings" in the dashboard menu.
Under "API settings" → "SMS settings", ensure "Messages API" is selected as the default API for sending SMS messages. Vonage has older APIs, and using the wrong one will cause unexpected behavior or webhook format mismatches.
// src/services/vonageService.jsrequire('dotenv').config(); // Ensure env vars are loadedconst { Vonage } = require('@vonage/server-sdk');
const { SMS } = require('@vonage/messages'); // Import SMS class for message constructionconst logger = require('../utils/logger');
const fs = require('fs'); // Needed to read the private keyconst { default: PQueue } = require('p-queue'); // Use default import syntax// Ensure required environment variables are presentif (!process.env.VONAGE_APPLICATION_ID || !process.env.VONAGE_PRIVATE_KEY_PATH || !process.env.VONAGE_NUMBER) {
logger.error('Missing required Vonage environment variables (VONAGE_APPLICATION_ID, VONAGE_PRIVATE_KEY_PATH, VONAGE_NUMBER)');
process.exit(1); // Exit if critical config is missing
}
let privateKey;
try {
privateKey = fs.readFileSync(process.env.VONAGE_PRIVATE_KEY_PATH);
} catch (err) {
logger.error(`Error reading private key from ${process.env.VONAGE_PRIVATE_KEY_PATH}:`, err);
process.exit(1);
}
const vonageClient = newVonage({
applicationId: process.env.VONAGE_APPLICATION_ID,
privateKey: privateKey
});
// --- Core Sending Function ---constsendSingleSms = async (message) => {
try {
// The message object should already be created using 'new SMS(...)' before calling thisconst response = await vonageClient.messages.send(message);
logger.info(`Message sent successfully to ${message.to}. Message UUID: ${response.message_uuid}`);
return { success: true, message_uuid: response.message_uuid, recipient: message.to };
} catch (error) {
// Log detailed error information from Vonage if availableconst errorDetails = error?.response?.data || error.message;
logger.error(`Error sending message to ${message.to}:`, JSON.stringify(errorDetails));
return { success: false, error: errorDetails, recipient: message.to };
}
};
// --- Simple Sequential Sending (Basic Approach) ---constsendBulkSmsSequential = async (recipients, messageText) => {
const results = [];
for (const recipient of recipients) {
// Construct the message object for each recipientconst message = newSMS({
to: recipient,
from: process.env.VONAGE_NUMBER,
text: messageText,
channel: 'sms',
message_type: 'text'
});
const result = awaitsendSingleSms(message);
results.push(result);
// Simple delay to respect basic rate limits (e.g., 1 SMS/sec for Long Codes)// Adjust delay based on your number type and regulations (e.g., 10DLC)awaitnewPromise(resolve =>setTimeout(resolve, 1100));
}
return results;
};
// --- Concurrent Sending with Rate Limiting (Advanced Approach using p-queue) ---// Configure queue based on .env settings or defaultsconst concurrency = parseInt(process.env.VONAGE_CONCURRENCY || '5', 10);
const intervalMs = parseInt(process.env.VONAGE_INTERVAL_MS || '1000', 10);
const intervalCap = parseInt(process.env.VONAGE_INTERVAL_CAP || '5', 10);
const queue = newPQueue({
concurrency: concurrency,
interval: intervalMs,
intervalCap: intervalCap,
// autoStart: true // default
});
logger.info(`Initialized Vonage send queue: Concurrency=${concurrency}, Interval=${intervalMs}ms, Cap=${intervalCap}`);
constsendBulkSmsConcurrent = async (recipients, messageText) => {
const sendPromises = recipients.map(recipient => {
// Add the task function to the queuereturn queue.add(async () => {
// Construct the message object inside the queued taskconst message = newSMS({
to: recipient,
from: process.env.VONAGE_NUMBER,
text: messageText,
channel: 'sms',
message_type: 'text'
});
returnsendSingleSms(message); // Execute the sending logic
});
});
// Wait for all tasks scheduled in the queue to completeconst results = awaitPromise.all(sendPromises);
return results;
};
// --- Webhook Handlers ---consthandleStatusWebhook = (payload) => {
logger.info('Received Status Webhook:', JSON.stringify(payload, null, 2));
// Key fields: message_uuid, status (delivered, expired, failed, rejected, accepted, buffered), timestamp, error, to, fromif (payload.status === 'delivered') {
logger.info(`Message ${payload.message_uuid} successfully delivered to ${payload.to}`);
} elseif (payload.status === 'failed' || payload.status === 'rejected') {
const reason = payload.error?.reason || payload.error?.code || payload.error?.type || 'Unknown error';
logger.error(`Message ${payload.message_uuid} to ${payload.to} failed/rejected. Status: ${payload.status}, Reason: ${reason}`);
// See Vonage DLR error codes for details:// https://developer.vonage.com/en/messaging/sms/guides/delivery-receipts#dlr-error-codes
} else {
logger.info(`Message ${payload.message_uuid} to ${payload.to} has status: ${payload.status}`);
}
// TODO: Add logic to update database record based on payload.message_uuid (see Optional Section 4)
};
consthandleInboundWebhook = (payload) => {
logger.info('Received Inbound SMS:', JSON.stringify(payload, null, 2));
// Key fields: msisdn (sender), to (your Vonage number), text, messageId, message-timestamp, type (usually 'text')// TODO: Process the inbound message (e.g., store in DB, trigger reply)
};
module.exports = {
sendSingleSms,
sendBulkSmsSequential, // Export basic version
sendBulkSmsConcurrent, // Export advanced version
handleStatusWebhook,
handleInboundWebhook,
// vonageClient // Optionally export client if needed elsewhere
};
This service initializes the Vonage client using Application ID and Private Key (JWT) from .env.
It provides sendSingleSms for sending one message.
sendBulkSmsSequential: Simple loop, inefficient for large volumes.
sendBulkSmsConcurrent: Uses p-queue for rate-limited concurrency - recommended for bulk.
Includes placeholder functions for handling status and inbound webhooks.
Delivery Receipt Status Codes: Vonage DLRs include status values like delivered, accepted, buffered, expired, failed, rejected, and unknown. Error codes (err-code) range from 0 (success) to 99 (general error), with specific codes indicating temporary issues (retryable) vs permanent failures.1
3. Building the Express API for Bulk SMS Sending
Now, let's create the Express routes and controller to handle incoming bulk SMS requests and webhook callbacks from Vonage.
// src/controllers/smsController.jsconst vonageService = require('../services/vonageService');
const logger = require('../utils/logger');
// Optional: Import Prisma client if using database// const prisma = require('../utils/prismaClient');constsendBulkHandler = async (req, res, next) => {
const { recipients, message } = req.body;
// Basic Validationif (!recipients || !Array.isArray(recipients) || recipients.length === 0) {
return res.status(400).json({ error: 'Invalid or empty "recipients" array is required.' });
}
if (!message || typeof message !== 'string' || message.trim() === '') {
return res.status(400).json({ error: 'Invalid or empty "message" string is required.' });
}
// TODO: Add more robust phone number validation (e.g., check E.164 format) for each recipienttry {
logger.info(`Received bulk send request for ${recipients.length} recipients.`);
// Choose the sending strategy: concurrent is generally preferred for bulkconst results = await vonageService.sendBulkSmsConcurrent(recipients, message);
// const results = await vonageService.sendBulkSmsSequential(recipients, message); // Alternativeconst summary = {
total_requested: recipients.length,
total_sent_successfully: results.filter(r => r.success).length,
total_failed: results.filter(r => !r.success).length,
// Optionally include detailed results (can be large, consider omitting in production response)// details: results
};
logger.info(`Bulk send attempt completed. Success: ${summary.total_sent_successfully}, Failed: ${summary.total_failed}`);
// Log failures with details
results.filter(r => !r.success).forEach(failure => {
logger.error(`Failed sending to ${failure.recipient}: ${JSON.stringify(failure.error)}`);
});
// Respond with 202 Accepted as the process is asynchronous (delivery happens later)
res.status(202).json({ message: 'Bulk send request processed.', summary });
} catch (error) {
logger.error('Error in sendBulkHandler:', error);
// Pass error to the global error handlernext(error);
}
};
conststatusWebhookHandler = (req, res) => {
const payload = req.body;
try {
vonageService.handleStatusWebhook(payload);
// Respond quickly to Vonage to acknowledge receipt
res.status(200).send('OK');
} catch (error) {
logger.error('Error processing status webhook:', error);
// Still try to send OK, but log the error server-side
res.status(500).send('Error processing webhook');
}
};
constinboundWebhookHandler = (req, res) => {
const payload = req.body;
try {
vonageService.handleInboundWebhook(payload);
// Respond quickly to Vonage
res.status(200).send('OK');
} catch (error) {
logger.error('Error processing inbound webhook:', error);
res.status(500).send('Error processing webhook');
}
};
module.exports = {
sendBulkHandler,
statusWebhookHandler,
inboundWebhookHandler,
};
Handles the /bulk request, validates input, calls the appropriate vonageService function, logs results, and returns a summary.
Includes handlers for status and inbound webhooks. Crucially, these must respond with 200 OK quickly, otherwise Vonage will retry.
Create SMS Routes (src/routes/smsRoutes.js):
JavaScript
// src/routes/smsRoutes.jsconst express = require('express');
const smsController = require('../controllers/smsController');
// Optional: Add rate limiting middleware for the API endpointconst rateLimit = require('express-rate-limit');
const router = express.Router();
// Rate Limiter for the bulk send endpoint (adjust limits as needed)// Apply this only if the API is exposed to potentially untrusted clientsconst bulkSendLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutesmax: 100, // Limit each IP to 100 requests per windowMsmessage: 'Too many bulk send requests created from this IP, please try again after 15 minutes',
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headerslegacyHeaders: false, // Disable the `X-RateLimit-*` headers
});
// Route for sending bulk messages// Apply rate limiting middleware specifically to this sensitive endpoint if needed
router.post('/bulk', bulkSendLimiter, smsController.sendBulkHandler);
// Routes for Vonage Webhooks (Status/DLR and Inbound)// These should generally NOT be rate-limited as they come from Vonage IPs.// Ensure body parsing middleware (express.json, express.urlencoded) is applied *before* these routes in server.js.
router.post('/status', smsController.statusWebhookHandler);
router.get('/status', (req, res) => res.status(200).send('Status webhook GET endpoint reached. Use POST.')); // Handle potential GET probe
router.post('/inbound', smsController.inboundWebhookHandler);
router.get('/inbound', (req, res) => res.status(200).send('Inbound webhook GET endpoint reached. Use POST.')); // Handle potential GET probemodule.exports = router;
Defines the API endpoints:
POST /api/sms/bulk: Accepts the bulk send request. Includes optional express-rate-limit.
POST /api/sms/status: Receives delivery receipts from Vonage.
POST /api/sms/inbound: Receives incoming SMS messages from Vonage.
Includes simple GET handlers for webhook URLs as Vonage sometimes probes with GET.
Testing the /bulk endpoint:
Ensure your .env file is populated with your Vonage Application ID, Private Key Path, and Vonage Number.
Place the private.key file in the project root.
Start the server: npm run dev
Use curl or Postman:
Curl Example:
Bash
curl -X POST http://localhost:3000/api/sms/bulk \
-H "Content-Type: application/json" \
-d '{
"recipients": ["YOUR_TEST_PHONE_NUMBER_1", "YOUR_TEST_PHONE_NUMBER_2"],
"message": "Hello from Node.js Bulk Sender! Test message."
}'# Replace YOUR_TEST_PHONE_NUMBER_* with actual E.164 formatted numbers (e.g., 14155550101)
Expected Response (JSON):
JSON
{"message":"Bulk send request processed.","summary":{"total_requested":2,"total_sent_successfully":2,// Or based on actual outcome"total_failed":0// Or based on actual outcome}}
Check your terminal logs for detailed output from the logger, including message UUIDs for successful sends and error details for failures.
Check your test phone(s) for the SMS message.
If using ngrok, check the ngrok console (http://localhost:4040) for incoming POST requests to /api/sms/status (these are the delivery reports). Check your server logs to see how handleStatusWebhook processed them.
4. (Optional) Integrating PostgreSQL Database with Prisma for SMS Tracking
Managing large recipient lists and tracking message status is easier with a database. Let's integrate PostgreSQL using Prisma ORM.
Install PostgreSQL: Use Docker (recommended) or install natively.
Docker: Create a docker-compose.yml:
YAML
# docker-compose.ymlversion:'3.8'services:db:image:postgres:14# Use a specific versionrestart:alwaysenvironment:POSTGRES_USER:your_db_userPOSTGRES_PASSWORD:your_db_passwordPOSTGRES_DB:vonage_bulk_smsports:-"5432:5432"volumes:-postgres_data:/var/lib/postgresql/datavolumes:postgres_data:
Run docker-compose up -d.
Update your .envDATABASE_URL accordingly:
DATABASE_URL="postgresql://your_db_user:your_db_password@localhost:5432/vonage_bulk_sms?schema=public"
Initialize Prisma:
Bash
npx prisma init --datasource-provider postgresql
This creates prisma/schema.prisma and updates .env with a placeholder DATABASE_URL. Ensure your actual DATABASE_URL is correct in .env.
Define Schema (prisma/schema.prisma):
Prisma
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// Model for storing recipient information
model Recipient {
id Int @id @default(autoincrement())
phoneNumber String @unique @map("phone_number") // E.164 format
firstName String? @map("first_name")
lastName String? @map("last_name")
list String // Categorize recipients (e.g., 'newsletter', 'promotions')
subscribed Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relation to message logs for this recipient
messageLogs MessageLog[]
@@map("recipients")
}
// Model for logging individual message send attempts and status updates
model MessageLog {
id Int @id @default(autoincrement())
recipientId Int @map("recipient_id") // Foreign key to Recipient
messageUuid String? @unique @map("message_uuid") // Vonage message ID (nullable for initial failures)
status String // e.g., submitted, sent, delivered, failed, rejected
errorCode String? @map("error_code") // Store error code/reason from Vonage DLR
errorMessage String? @map("error_message") @db.Text // Store detailed error message if available
submittedAt DateTime @default(now()) @map("submitted_at") // When we tried to send
statusAt DateTime? @map("status_at") // When the final status was received via webhook
// Relation back to the recipient
recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
@@index([recipientId])
@@index([status])
@@index([messageUuid]) // Index for quick lookup by Vonage UUID
@@map("message_logs")
}
// Optional: Model for Bulk Send Campaigns
model BulkCampaign {
id Int @id @default(autoincrement())
name String
messageText String @map("message_text") @db.Text
listTarget String @map("list_target") // Which recipient list to use
status String // e.g., pending, processing, completed, failed
startedAt DateTime? @map("started_at")
completedAt DateTime? @map("completed_at")
createdAt DateTime @default(now()) @map("created_at")
// Optional: Add relation to MessageLog if needed (can get complex)
@@map("bulk_campaigns")
}
Apply Schema Migrations:
Bash
# Create the first migration file and apply it to the database
npx prisma migrate dev --name init
# Generate Prisma Client based on the schema
npx prisma generate
This creates the tables in your database and generates the Prisma Client library in node_modules/@prisma/client.
Integrate Prisma Client:
Create a Prisma client instance (src/utils/prismaClient.js):
// src/controllers/smsController.js (modified example with Prisma)const vonageService = require('../services/vonageService');
const logger = require('../utils/logger');
const prisma = require('../utils/prismaClient'); // Import Prisma client// Modified handler to fetch recipients from DB and log resultsconstsendBulkHandler = async (req, res, next) => {
// Example: Get list target from request body instead of raw numbersconst { listTarget, message } = req.body;
if (!listTarget) return res.status(400).json({ error: '"listTarget" is required.' });
if (!message || typeof message !== 'string' || message.trim() === '') {
return res.status(400).json({ error: 'Invalid or empty "message" string is required.' });
}
try {
// 1. Fetch subscribed recipients from the specified listconst recipientsFromDb = await prisma.recipient.findMany({
where: {
list: listTarget,
subscribed: true,
},
select: {
id: true, // Need ID to link logsphoneNumber: true,
}
});
if (recipientsFromDb.length === 0) {
logger.warn(`No subscribed recipients found for list: ${listTarget}`);
return res.status(404).json({ message: `No subscribed recipients found for list: ${listTarget}` });
}
const recipientNumbers = recipientsFromDb.map(r => r.phoneNumber);
logger.info(`Fetched ${recipientNumbers.length} recipients from list "${listTarget}". Starting send.`);
// TODO: Implement logic to create MessageLog entries *before* sending,// then update them based on send results and webhooks.// This example focuses on fetching; full logging requires more steps.// 2. Send messages using the fetched numbersconst results = await vonageService.sendBulkSmsConcurrent(recipientNumbers, message);
// 3. Process results and potentially update DB (simplified example)const summary = {
total_requested: recipientNumbers.length,
total_sent_successfully: results.filter(r => r.success).length,
total_failed: results.filter(r => !r.success).length,
};
logger.info(`Bulk send attempt for list "${listTarget}" completed. Success: ${summary.total_sent_successfully}, Failed: ${summary.total_failed}`);
results.filter(r => !r.success).forEach(failure => {
logger.error(`Failed sending to ${failure.recipient}: ${JSON.stringify(failure.error)}`);
// TODO: Update corresponding MessageLog entry in DB to 'failed' status
});
results.filter(r => r.success).forEach(success => {
// TODO: Update corresponding MessageLog entry in DB with message_uuid and 'submitted' status
logger.info(`Submitted message to ${success.recipient}, UUID: ${success.message_uuid}`);
});
res.status(202).json({ message: `Bulk send request for list "${listTarget}" processed.`, summary });
} catch (error) {
logger.error(`Error in sendBulkHandler for list ${listTarget}:`, error);
next(error);
}
};
// Modify webhook handlers to update DB based on message_uuidconststatusWebhookHandler = async (req, res) => {
const payload = req.body;
try {
vonageService.handleStatusWebhook(payload); // Log the webhook first// Update database recordif (payload.message_uuid) {
await prisma.messageLog.updateMany({ // Use updateMany as UUID should be uniquewhere: { messageUuid: payload.message_uuid },
data: {
status: payload.status,
statusAt: newDate(payload.timestamp), // Convert timestamp string to DateerrorCode: payload.error?.code || payload.error?.type,
errorMessage: payload.error?.reason,
},
});
logger.info(`Updated DB status for message ${payload.message_uuid} to ${payload.status}`);
} else {
logger.warn('Status webhook received without message_uuid:', payload);
}
res.status(200).send('OK');
} catch (error) {
logger.error('Error processing status webhook and updating DB:', error);
res.status(500).send('Error processing webhook');
}
};
// Modify inbound handler if storing inbound messagesconstinboundWebhookHandler = async (req, res) => {
const payload = req.body;
try {
vonageService.handleInboundWebhook(payload); // Log the webhook// TODO: Add logic to store inbound message in DB if needed// Example: Find recipient by sender number, create an inbound log entry
res.status(200).send('OK');
} catch (error) {
logger.error('Error processing inbound webhook:', error);
res.status(500).send('Error processing webhook');
}
};
module.exports = {
sendBulkHandler,
statusWebhookHandler,
inboundWebhookHandler,
};
References
Footnotes
Vonage Developer Documentation. "SMS Delivery Receipts API Guide." Vonage API Documentation. Accessed October 2025. https://developer.vonage.com/en/messaging/sms/guides/delivery-receipts. Explains delivery receipt status codes (delivered, accepted, buffered, expired, failed, rejected, unknown) and error codes ranging from 0 (success) to 99 (general error), with specific codes indicating temporary vs permanent failures. ↩
Frequently asked questions
Use the Vonage Messages API with Node.js and Express. The provided code example demonstrates setting up a server and sending messages via an API endpoint using the '@vonage/server-sdk' and '@vonage/messages' libraries. You'll need a Vonage API account and application set up to use the API.
The Vonage Messages API is a unified platform that allows sending messages across various channels like SMS, WhatsApp, and more. It is used in this tutorial with the 'vonage/server-sdk' and 'vonage/messages' libraries to send SMS in bulk efficiently.
The tutorial recommends the 'p-queue' library to manage API request concurrency. This helps avoid exceeding Vonage's rate limits and ensures reliable sending. Configure concurrency, interval, and request cap in your environment variables (e.g. VONAGE_CONCURRENCY) and the p-queue configuration accordingly.
JWT (JSON Web Tokens) provide a secure way to authenticate with the Vonage Messages API. The tutorial uses an application ID and private key (stored securely and never committed to version control) for JWT authentication, eliminating the need to expose API secrets directly.
Vonage provides delivery receipts (DLRs) via webhooks. Set up a status URL (e.g., 'YOUR_NGROK_HTTPS_URL/api/sms/status') in your Vonage application settings. The code example provides a 'handleStatusWebhook' function to process these status updates.
A2P 10DLC is a US regulation for Application-to-Person messaging via 10-digit long codes. The tutorial mentions this as a consideration for bulk SMS sending in the US, emphasizing the importance of complying with carrier rules when setting up your sending logic and respecting rate limits.
ngrok creates a public, secure tunnel to your locally running server. This is needed for Vonage webhooks (like delivery receipts) to reach your local development environment during testing as Vonage can't send requests directly to 'localhost'.
Download the 'private.key' file generated when creating a Vonage application. Store this file securely (outside version control, e.g., in the project's root directory) and reference its path in your .env file as VONAGE_PRIVATE_KEY_PATH. The provided example code reads this key to initialize the Vonage client.
When dealing with substantial recipient lists or needing detailed message logging. Prisma simplifies database interaction with PostgreSQL, allowing efficient management and tracking of SMS sending attempts and delivery statuses as shown in the tutorial's optional section.
The tutorial demonstrates (optionally) using PostgreSQL and Prisma. Prisma models are defined for Recipients (including their subscription status and list affiliation) and MessageLogs, offering a clear way to organize and query message sending history related to recipients.
The tutorial recommends a structured approach with controllers (e.g. smsController.js), routes (smsRoutes.js), and services (vonageService.js). This organization promotes maintainability and separates different aspects of the application's logic. Use npm packages like 'dotenv' for managing environment variables and optional packages like 'nodemon' and 'p-queue' for enhanced development and message sending respectively.
p-queue is a promise-based queue library that allows control over concurrent operations. The tutorial highlights its use for respecting Vonage API rate limits, crucial for handling bursts of SMS messages without exceeding limits, using env vars like VONAGE_CONCURRENCY.
Yes, the tutorial demonstrates database integration using PostgreSQL and Prisma (optional). This enables storing recipients, managing lists, logging messages, and tracking delivery status more robustly, simplifying querying and data management in bulk operations.
Use tools like 'curl' or Postman to send POST requests to your API endpoint (e.g., 'http://localhost:3000/api/sms/bulk'). Provide sample JSON data with recipient numbers (E.164 format) and the message text in the request body as shown in the tutorial examples. Monitor your terminal for server logs and use ngrok to expose local endpoints for webhook testing.
Learn how to build a robust system that sends bulk SMS messages using Node.js, Express, and the Vonage Messages API. This comprehensive tutorial covers everything from initial project setup to handling rate limits, implementing JWT authentication, managing webhooks, and integrating with PostgreSQL using Prisma.
Whether you're sending marketing campaigns, appointment reminders, or transactional notifications, this guide will help you reliably send SMS messages to hundreds or thousands of recipients while navigating API rate limits, carrier regulations (like A2P 10DLC in the US), and tracking delivery status.
What You'll Build: A Node.js/Express application with an API endpoint that accepts a list of recipients and a message, then efficiently sends SMS via Vonage while managing concurrency, handling errors, and tracking delivery receipts.
Technologies You'll Use:
@vonage/server-sdkand@vonage/messages..envfile intoprocess.env.System Architecture:
Final Outcome: A functional Node.js application that can:
What You'll Need:
ngrokinstalled (Download here).npm install -g @vonage/cli.1. Setting Up Your Node.js Project for Bulk SMS
Initialize your Node.js project and install the necessary dependencies to send SMS messages with Vonage.
Create Project Directory:
mkdir vonage-bulk-sms cd vonage-bulk-smsInitialize npm:
This creates a
package.jsonfile.Install Core Dependencies:
express: Web framework.@vonage/server-sdk: Official Vonage SDK for Node.js (core client).@vonage/messages: Part of the SDK for constructing message objects.dotenv: Loads environment variables from.env.Install Development/Optional Dependencies:
npm install --save-dev nodemon # Optional: for auto-restarting the server during development npm install p-queue # Optional: for advanced concurrency control # Optional: For database integration with Prisma npm install @prisma/client npm install --save-dev prismaCreate Project Structure:
vonage-bulk-sms/node_modules/src/controllers/smsController.jsroutes/smsRoutes.jsservices/vonageService.jsutils/logger.jsprismaClient.js(Optional: if using Prisma)server.jsprisma/(Optional: for database schema)schema.prisma.env.env.example.gitignoreprivate.key(Your downloaded Vonage private key)package.jsonCreate these directories and empty files.
Configure
.gitignore: Create a.gitignorefile in the root directory:Setup Environment Variables (
.env.example): Create a.env.examplefile. Never commit the actual.envfile..envand fill in your actual credentials later. Place your downloadedprivate.keyfile in the project root.Setup Basic Express Server (
src/server.js):// src/server.js require('dotenv').config(); const express = require('express'); const smsRoutes = require('./routes/smsRoutes'); const logger = require('./utils/logger'); const app = express(); const PORT = process.env.PORT || 3000; // Middleware app.use(express.json()); // Parse JSON bodies app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies // Basic Logging Middleware app.use((req, res, next) => { logger.info(`${req.method} ${req.url}`); next(); }); // Routes app.use('/api/sms', smsRoutes); // Basic Health Check app.get('/health', (req, res) => { res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() }); }); // Global Error Handler (Very Basic) app.use((err, req, res, next) => { logger.error('Unhandled error:', err); res.status(500).json({ error: 'Internal Server Error' }); }); app.listen(PORT, () => { logger.info(`Server running on port ${PORT}`); logger.info(`Access health check at http://localhost:${PORT}/health`); }); module.exports = app; // Export for potential testingSetup Basic Logger (
src/utils/logger.js):// src/utils/logger.js const logLevel = process.env.LOG_LEVEL || 'info'; const logger = { info: (...args) => { if (logLevel === 'info' || logLevel === 'debug') { console.log('[INFO]', ...args); } }, warn: (...args) => { if (logLevel === 'info' || logLevel === 'debug' || logLevel === 'warn') { console.warn('[WARN]', ...args); } }, error: (...args) => { console.error('[ERROR]', ...args); }, debug: (...args) => { if (logLevel === 'debug') { console.debug('[DEBUG]', ...args); } }, }; module.exports = logger;Add
startanddevscripts topackage.json:// package.json (add/modify scripts section) "scripts": { "start": "node src/server.js", "dev": "nodemon src/server.js", "test": "echo \"Error: no test specified\" && exit 1" // Add prisma scripts here if using database, e.g., // "prisma:migrate:dev": "prisma migrate dev", // "prisma:generate": "prisma generate" },2. Configuring Vonage Messages API for SMS Sending
Configure your Vonage account and integrate the Messages API before writing the core SMS sending logic.
Log in to Vonage: Go to your Vonage API Dashboard.
Create a Vonage Application: The Messages API requires a Vonage Application. This application acts as a container for configuration, including webhook URLs and security credentials (public/private key pair for JWT authentication).
private.keyfile will be downloaded. Save this file in your project's root directory. Ensure the path in your.envfile (VONAGE_PRIVATE_KEY_PATH) matches its location (e.g.,./private.key). Also addprivate.keyto your.gitignore.ngrok http YOUR_PORT(e.g.,ngrok http 3000).https://Forwarding URL provided by ngrok.YOUR_NGROK_HTTPS_URL/api/sms/status(you'll create this endpoint later).YOUR_NGROK_HTTPS_URL/api/sms/inbound. For sending only, this can often be left blank or set the same as the status URL, but ideally, handle it even if just with a200 OK..envfile (VONAGE_APPLICATION_ID).Link Your Vonage Number:
14155550100) to your.envfile (VONAGE_NUMBER).Set Default SMS API (Important):
Initialize Vonage Client (
src/services/vonageService.js):// src/services/vonageService.js require('dotenv').config(); // Ensure env vars are loaded const { Vonage } = require('@vonage/server-sdk'); const { SMS } = require('@vonage/messages'); // Import SMS class for message construction const logger = require('../utils/logger'); const fs = require('fs'); // Needed to read the private key const { default: PQueue } = require('p-queue'); // Use default import syntax // Ensure required environment variables are present if (!process.env.VONAGE_APPLICATION_ID || !process.env.VONAGE_PRIVATE_KEY_PATH || !process.env.VONAGE_NUMBER) { logger.error('Missing required Vonage environment variables (VONAGE_APPLICATION_ID, VONAGE_PRIVATE_KEY_PATH, VONAGE_NUMBER)'); process.exit(1); // Exit if critical config is missing } let privateKey; try { privateKey = fs.readFileSync(process.env.VONAGE_PRIVATE_KEY_PATH); } catch (err) { logger.error(`Error reading private key from ${process.env.VONAGE_PRIVATE_KEY_PATH}:`, err); process.exit(1); } const vonageClient = new Vonage({ applicationId: process.env.VONAGE_APPLICATION_ID, privateKey: privateKey }); // --- Core Sending Function --- const sendSingleSms = async (message) => { try { // The message object should already be created using 'new SMS(...)' before calling this const response = await vonageClient.messages.send(message); logger.info(`Message sent successfully to ${message.to}. Message UUID: ${response.message_uuid}`); return { success: true, message_uuid: response.message_uuid, recipient: message.to }; } catch (error) { // Log detailed error information from Vonage if available const errorDetails = error?.response?.data || error.message; logger.error(`Error sending message to ${message.to}:`, JSON.stringify(errorDetails)); return { success: false, error: errorDetails, recipient: message.to }; } }; // --- Simple Sequential Sending (Basic Approach) --- const sendBulkSmsSequential = async (recipients, messageText) => { const results = []; for (const recipient of recipients) { // Construct the message object for each recipient const message = new SMS({ to: recipient, from: process.env.VONAGE_NUMBER, text: messageText, channel: 'sms', message_type: 'text' }); const result = await sendSingleSms(message); results.push(result); // Simple delay to respect basic rate limits (e.g., 1 SMS/sec for Long Codes) // Adjust delay based on your number type and regulations (e.g., 10DLC) await new Promise(resolve => setTimeout(resolve, 1100)); } return results; }; // --- Concurrent Sending with Rate Limiting (Advanced Approach using p-queue) --- // Configure queue based on .env settings or defaults const concurrency = parseInt(process.env.VONAGE_CONCURRENCY || '5', 10); const intervalMs = parseInt(process.env.VONAGE_INTERVAL_MS || '1000', 10); const intervalCap = parseInt(process.env.VONAGE_INTERVAL_CAP || '5', 10); const queue = new PQueue({ concurrency: concurrency, interval: intervalMs, intervalCap: intervalCap, // autoStart: true // default }); logger.info(`Initialized Vonage send queue: Concurrency=${concurrency}, Interval=${intervalMs}ms, Cap=${intervalCap}`); const sendBulkSmsConcurrent = async (recipients, messageText) => { const sendPromises = recipients.map(recipient => { // Add the task function to the queue return queue.add(async () => { // Construct the message object inside the queued task const message = new SMS({ to: recipient, from: process.env.VONAGE_NUMBER, text: messageText, channel: 'sms', message_type: 'text' }); return sendSingleSms(message); // Execute the sending logic }); }); // Wait for all tasks scheduled in the queue to complete const results = await Promise.all(sendPromises); return results; }; // --- Webhook Handlers --- const handleStatusWebhook = (payload) => { logger.info('Received Status Webhook:', JSON.stringify(payload, null, 2)); // Key fields: message_uuid, status (delivered, expired, failed, rejected, accepted, buffered), timestamp, error, to, from if (payload.status === 'delivered') { logger.info(`Message ${payload.message_uuid} successfully delivered to ${payload.to}`); } else if (payload.status === 'failed' || payload.status === 'rejected') { const reason = payload.error?.reason || payload.error?.code || payload.error?.type || 'Unknown error'; logger.error(`Message ${payload.message_uuid} to ${payload.to} failed/rejected. Status: ${payload.status}, Reason: ${reason}`); // See Vonage DLR error codes for details: // https://developer.vonage.com/en/messaging/sms/guides/delivery-receipts#dlr-error-codes } else { logger.info(`Message ${payload.message_uuid} to ${payload.to} has status: ${payload.status}`); } // TODO: Add logic to update database record based on payload.message_uuid (see Optional Section 4) }; const handleInboundWebhook = (payload) => { logger.info('Received Inbound SMS:', JSON.stringify(payload, null, 2)); // Key fields: msisdn (sender), to (your Vonage number), text, messageId, message-timestamp, type (usually 'text') // TODO: Process the inbound message (e.g., store in DB, trigger reply) }; module.exports = { sendSingleSms, sendBulkSmsSequential, // Export basic version sendBulkSmsConcurrent, // Export advanced version handleStatusWebhook, handleInboundWebhook, // vonageClient // Optionally export client if needed elsewhere };.env.sendSingleSmsfor sending one message.sendBulkSmsSequential: Simple loop, inefficient for large volumes.sendBulkSmsConcurrent: Usesp-queuefor rate-limited concurrency - recommended for bulk.delivered,accepted,buffered,expired,failed,rejected, andunknown. Error codes (err-code) range from 0 (success) to 99 (general error), with specific codes indicating temporary issues (retryable) vs permanent failures.13. Building the Express API for Bulk SMS Sending
Now, let's create the Express routes and controller to handle incoming bulk SMS requests and webhook callbacks from Vonage.
Create SMS Controller (
src/controllers/smsController.js):// src/controllers/smsController.js const vonageService = require('../services/vonageService'); const logger = require('../utils/logger'); // Optional: Import Prisma client if using database // const prisma = require('../utils/prismaClient'); const sendBulkHandler = async (req, res, next) => { const { recipients, message } = req.body; // Basic Validation if (!recipients || !Array.isArray(recipients) || recipients.length === 0) { return res.status(400).json({ error: 'Invalid or empty "recipients" array is required.' }); } if (!message || typeof message !== 'string' || message.trim() === '') { return res.status(400).json({ error: 'Invalid or empty "message" string is required.' }); } // TODO: Add more robust phone number validation (e.g., check E.164 format) for each recipient try { logger.info(`Received bulk send request for ${recipients.length} recipients.`); // Choose the sending strategy: concurrent is generally preferred for bulk const results = await vonageService.sendBulkSmsConcurrent(recipients, message); // const results = await vonageService.sendBulkSmsSequential(recipients, message); // Alternative const summary = { total_requested: recipients.length, total_sent_successfully: results.filter(r => r.success).length, total_failed: results.filter(r => !r.success).length, // Optionally include detailed results (can be large, consider omitting in production response) // details: results }; logger.info(`Bulk send attempt completed. Success: ${summary.total_sent_successfully}, Failed: ${summary.total_failed}`); // Log failures with details results.filter(r => !r.success).forEach(failure => { logger.error(`Failed sending to ${failure.recipient}: ${JSON.stringify(failure.error)}`); }); // Respond with 202 Accepted as the process is asynchronous (delivery happens later) res.status(202).json({ message: 'Bulk send request processed.', summary }); } catch (error) { logger.error('Error in sendBulkHandler:', error); // Pass error to the global error handler next(error); } }; const statusWebhookHandler = (req, res) => { const payload = req.body; try { vonageService.handleStatusWebhook(payload); // Respond quickly to Vonage to acknowledge receipt res.status(200).send('OK'); } catch (error) { logger.error('Error processing status webhook:', error); // Still try to send OK, but log the error server-side res.status(500).send('Error processing webhook'); } }; const inboundWebhookHandler = (req, res) => { const payload = req.body; try { vonageService.handleInboundWebhook(payload); // Respond quickly to Vonage res.status(200).send('OK'); } catch (error) { logger.error('Error processing inbound webhook:', error); res.status(500).send('Error processing webhook'); } }; module.exports = { sendBulkHandler, statusWebhookHandler, inboundWebhookHandler, };/bulkrequest, validates input, calls the appropriatevonageServicefunction, logs results, and returns a summary.200 OKquickly, otherwise Vonage will retry.Create SMS Routes (
src/routes/smsRoutes.js):// src/routes/smsRoutes.js const express = require('express'); const smsController = require('../controllers/smsController'); // Optional: Add rate limiting middleware for the API endpoint const rateLimit = require('express-rate-limit'); const router = express.Router(); // Rate Limiter for the bulk send endpoint (adjust limits as needed) // Apply this only if the API is exposed to potentially untrusted clients const bulkSendLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // Limit each IP to 100 requests per windowMs message: 'Too many bulk send requests created from this IP, please try again after 15 minutes', standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // Disable the `X-RateLimit-*` headers }); // Route for sending bulk messages // Apply rate limiting middleware specifically to this sensitive endpoint if needed router.post('/bulk', bulkSendLimiter, smsController.sendBulkHandler); // Routes for Vonage Webhooks (Status/DLR and Inbound) // These should generally NOT be rate-limited as they come from Vonage IPs. // Ensure body parsing middleware (express.json, express.urlencoded) is applied *before* these routes in server.js. router.post('/status', smsController.statusWebhookHandler); router.get('/status', (req, res) => res.status(200).send('Status webhook GET endpoint reached. Use POST.')); // Handle potential GET probe router.post('/inbound', smsController.inboundWebhookHandler); router.get('/inbound', (req, res) => res.status(200).send('Inbound webhook GET endpoint reached. Use POST.')); // Handle potential GET probe module.exports = router;POST /api/sms/bulk: Accepts the bulk send request. Includes optionalexpress-rate-limit.POST /api/sms/status: Receives delivery receipts from Vonage.POST /api/sms/inbound: Receives incoming SMS messages from Vonage.Testing the
/bulkendpoint:.envfile is populated with your Vonage Application ID, Private Key Path, and Vonage Number.private.keyfile in the project root.npm run devcurlor Postman:Curl Example:
curl -X POST http://localhost:3000/api/sms/bulk \ -H "Content-Type: application/json" \ -d '{ "recipients": ["YOUR_TEST_PHONE_NUMBER_1", "YOUR_TEST_PHONE_NUMBER_2"], "message": "Hello from Node.js Bulk Sender! Test message." }' # Replace YOUR_TEST_PHONE_NUMBER_* with actual E.164 formatted numbers (e.g., 14155550101)Expected Response (JSON):
{ "message": "Bulk send request processed.", "summary": { "total_requested": 2, "total_sent_successfully": 2, // Or based on actual outcome "total_failed": 0 // Or based on actual outcome } }http://localhost:4040) for incoming POST requests to/api/sms/status(these are the delivery reports). Check your server logs to see howhandleStatusWebhookprocessed them.4. (Optional) Integrating PostgreSQL Database with Prisma for SMS Tracking
Managing large recipient lists and tracking message status is easier with a database. Let's integrate PostgreSQL using Prisma ORM.
Install PostgreSQL: Use Docker (recommended) or install natively.
Docker: Create a
docker-compose.yml:# docker-compose.yml version: '3.8' services: db: image: postgres:14 # Use a specific version restart: always environment: POSTGRES_USER: your_db_user POSTGRES_PASSWORD: your_db_password POSTGRES_DB: vonage_bulk_sms ports: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data volumes: postgres_data:docker-compose up -d..envDATABASE_URLaccordingly:DATABASE_URL="postgresql://your_db_user:your_db_password@localhost:5432/vonage_bulk_sms?schema=public"Initialize Prisma:
This creates
prisma/schema.prismaand updates.envwith a placeholderDATABASE_URL. Ensure your actualDATABASE_URLis correct in.env.Define Schema (
prisma/schema.prisma):Apply Schema Migrations:
# Create the first migration file and apply it to the database npx prisma migrate dev --name init # Generate Prisma Client based on the schema npx prisma generateThis creates the tables in your database and generates the Prisma Client library in
node_modules/@prisma/client.Integrate Prisma Client:
Create a Prisma client instance (
src/utils/prismaClient.js):// src/utils/prismaClient.js const { PrismaClient } = require('@prisma/client'); const prisma = new PrismaClient(); module.exports = prisma;Modify
smsController.jsto use Prisma:// src/controllers/smsController.js (modified example with Prisma) const vonageService = require('../services/vonageService'); const logger = require('../utils/logger'); const prisma = require('../utils/prismaClient'); // Import Prisma client // Modified handler to fetch recipients from DB and log results const sendBulkHandler = async (req, res, next) => { // Example: Get list target from request body instead of raw numbers const { listTarget, message } = req.body; if (!listTarget) return res.status(400).json({ error: '"listTarget" is required.' }); if (!message || typeof message !== 'string' || message.trim() === '') { return res.status(400).json({ error: 'Invalid or empty "message" string is required.' }); } try { // 1. Fetch subscribed recipients from the specified list const recipientsFromDb = await prisma.recipient.findMany({ where: { list: listTarget, subscribed: true, }, select: { id: true, // Need ID to link logs phoneNumber: true, } }); if (recipientsFromDb.length === 0) { logger.warn(`No subscribed recipients found for list: ${listTarget}`); return res.status(404).json({ message: `No subscribed recipients found for list: ${listTarget}` }); } const recipientNumbers = recipientsFromDb.map(r => r.phoneNumber); logger.info(`Fetched ${recipientNumbers.length} recipients from list "${listTarget}". Starting send.`); // TODO: Implement logic to create MessageLog entries *before* sending, // then update them based on send results and webhooks. // This example focuses on fetching; full logging requires more steps. // 2. Send messages using the fetched numbers const results = await vonageService.sendBulkSmsConcurrent(recipientNumbers, message); // 3. Process results and potentially update DB (simplified example) const summary = { total_requested: recipientNumbers.length, total_sent_successfully: results.filter(r => r.success).length, total_failed: results.filter(r => !r.success).length, }; logger.info(`Bulk send attempt for list "${listTarget}" completed. Success: ${summary.total_sent_successfully}, Failed: ${summary.total_failed}`); results.filter(r => !r.success).forEach(failure => { logger.error(`Failed sending to ${failure.recipient}: ${JSON.stringify(failure.error)}`); // TODO: Update corresponding MessageLog entry in DB to 'failed' status }); results.filter(r => r.success).forEach(success => { // TODO: Update corresponding MessageLog entry in DB with message_uuid and 'submitted' status logger.info(`Submitted message to ${success.recipient}, UUID: ${success.message_uuid}`); }); res.status(202).json({ message: `Bulk send request for list "${listTarget}" processed.`, summary }); } catch (error) { logger.error(`Error in sendBulkHandler for list ${listTarget}:`, error); next(error); } }; // Modify webhook handlers to update DB based on message_uuid const statusWebhookHandler = async (req, res) => { const payload = req.body; try { vonageService.handleStatusWebhook(payload); // Log the webhook first // Update database record if (payload.message_uuid) { await prisma.messageLog.updateMany({ // Use updateMany as UUID should be unique where: { messageUuid: payload.message_uuid }, data: { status: payload.status, statusAt: new Date(payload.timestamp), // Convert timestamp string to Date errorCode: payload.error?.code || payload.error?.type, errorMessage: payload.error?.reason, }, }); logger.info(`Updated DB status for message ${payload.message_uuid} to ${payload.status}`); } else { logger.warn('Status webhook received without message_uuid:', payload); } res.status(200).send('OK'); } catch (error) { logger.error('Error processing status webhook and updating DB:', error); res.status(500).send('Error processing webhook'); } }; // Modify inbound handler if storing inbound messages const inboundWebhookHandler = async (req, res) => { const payload = req.body; try { vonageService.handleInboundWebhook(payload); // Log the webhook // TODO: Add logic to store inbound message in DB if needed // Example: Find recipient by sender number, create an inbound log entry res.status(200).send('OK'); } catch (error) { logger.error('Error processing inbound webhook:', error); res.status(500).send('Error processing webhook'); } }; module.exports = { sendBulkHandler, statusWebhookHandler, inboundWebhookHandler, };References
Footnotes
Vonage Developer Documentation. "SMS Delivery Receipts API Guide." Vonage API Documentation. Accessed October 2025. https://developer.vonage.com/en/messaging/sms/guides/delivery-receipts. Explains delivery receipt status codes (delivered, accepted, buffered, expired, failed, rejected, unknown) and error codes ranging from 0 (success) to 99 (general error), with specific codes indicating temporary vs permanent failures. ↩