Vonage SMS Delivery Status & Webhooks in Node.js Express: Complete Tutorial
Learn how to track SMS delivery status with Vonage webhooks in Node.js Express. Complete guide with code examples for inbound SMS, delivery receipts (DLRs), and error handling.
Learn how to implement Vonage SMS delivery status tracking and webhooks in Node.js with Express. This hands-on tutorial shows you how to send SMS messages, receive delivery receipts (DLRs), handle inbound SMS via webhooks, and track message status in real-time. Perfect for building appointment reminders, order notifications, and two-factor authentication systems that need reliable delivery confirmation.
What you'll build: SMS webhooks with delivery tracking
Build a Node.js service that can:
Send SMS messages: Send SMS messages programmatically to specified phone numbers using the Vonage Messages API.
Receive inbound SMS messages: Handle incoming SMS messages sent to a dedicated Vonage virtual number via a webhook.
Track SMS delivery status: Receive real-time delivery receipts (DLRs) for outbound messages via a status webhook.
Problem Solved: This application enables real-time, two-way SMS communication with status tracking – crucial for appointment reminders, order confirmations, two-factor authentication (2FA), and customer support notifications where delivery confirmation is essential.
Technologies Used:
Node.js: A JavaScript runtime environment chosen for its asynchronous, event-driven nature, well-suited for handling concurrent webhook requests and I/O operations efficiently. Popular within the JavaScript ecosystem. Node.js v22 LTS (v22.20.0) is recommended as of October 2025 (Active LTS from October 29, 2024 until October 2025; Maintenance LTS until April 2027) (Source: Node.js Release Schedule).
Express.js: A minimal and flexible Node.js web application framework used to quickly set up the web server and API endpoints required for Vonage webhooks.
Vonage Messages API: A unified API for sending and receiving messages across various channels (SMS, MMS, WhatsApp, etc.). We focus on SMS for its ubiquity and reliability. It provides robust features including delivery receipts.
@vonage/server-sdk: The official Vonage Node.js SDK (version 3.24.1 as of October 2025) simplifies interaction with the Vonage APIs (Source: npm registry).
ngrok: A utility to expose local development servers to the internet, essential for testing Vonage webhooks during development.
dotenv: A module to load environment variables from a .env file into process.env, keeping sensitive credentials out of source code.
Node.js and npm: Node.js v22 LTS (v22.20.0) recommended (Active LTS from October 29, 2024 until October 2025; Maintenance LTS until April 2027). Download Node.js
Vonage API Account: A free account is sufficient to start. Sign up for Vonage
6. Configure .env:
Open the .env file and add placeholders for your Vonage credentials and application details. Fill these in later.
.env
# Vonage API Credentials (Get from Vonage Dashboard Settings)
VONAGE_API_KEY=YOUR_API_KEY
VONAGE_API_SECRET=YOUR_API_SECRET
# Vonage Application Details (Get after creating Vonage Application)
VONAGE_APPLICATION_ID=YOUR_APPLICATION_ID
VONAGE_PRIVATE_KEY_PATH=./private.key # Path relative to project root
# Vonage Number (The virtual number you rented)
VONAGE_NUMBER=YOUR_VONAGE_VIRTUAL_NUMBER
# Recipient Number (For testing sending SMS)
TO_NUMBER=RECIPIENT_PHONE_NUMBER # Use E.164 format, e.g., 14155552671
# Server Port
PORT=3000
Explanation of Configuration Choices:
.env File: Using a .env file and dotenv is a standard practice for managing configuration and secrets in Node.js applications, keeping sensitive data separate from the codebase.
.gitignore: Essential for preventing accidental exposure of API keys, private keys, and environment files in version control systems like Git.
private.key: The Vonage Messages API often uses JWTs generated with a public/private key pair for authentication via Applications. Storing the key securely is paramount.
2. Configure Vonage webhooks for delivery status callbacks
Configure your Vonage account and create a Vonage Application to handle Messages API interactions and webhooks before writing code.
1. Get API Key and Secret:
Log in to your Vonage API Dashboard. Your API Key and Secret are displayed at the top. Copy these values into your .env file for VONAGE_API_KEY and VONAGE_API_SECRET.
2. Set Default SMS API:
Navigate to your Account Settings in the Vonage dashboard. Scroll down to "API settings" > "SMS settings". Ensure "Default SMS Setting" is set to Messages API. This ensures webhooks use the Messages API format. Click "Save changes".
3. Create a Vonage Application:
Vonage Applications act as containers for your communication configurations, including webhook URLs and authentication keys.
Give your application a descriptive name (e.g., "NodeJS SMS Callbacks App").
Click "Generate public and private key". This will automatically download the private.key file. Save this file in the root directory of your project (the same location as your server.js). Ensure the VONAGE_PRIVATE_KEY_PATH in your .env file correctly points to it (./private.key). The public key is automatically stored by Vonage.
Enable the Messages capability.
You will see fields for Inbound URL and Status URL. You need a public URL for these, which you'll get from ngrok in the next step. For now, you can enter temporary placeholders like http://example.com/webhooks/inbound and http://example.com/webhooks/status. You will update these shortly. Ensure the method is set to POST.
Click "Generate new application".
On the next screen, you'll see your Application ID. Copy this ID and paste it into your .env file for VONAGE_APPLICATION_ID.
4. Link Your Vonage Number:
Scroll down on the Application details page to the "Link virtual numbers" section. Find the Vonage virtual number you rented and click the "Link" button next to it. This directs incoming messages and status updates for this number to the webhooks defined in this application. Copy this number (in E.164 format, e.g., 18885551212) into your .env file for VONAGE_NUMBER.
5. Start ngrok:
Get the public URL for your webhooks. Open a new terminal window (keep the first one for running the server later). Navigate to your project directory (optional but good practice) and run ngrok, telling it to expose the port your Express server will run on (defined as PORT in .env, default is 3000).
Bash
ngrok http 3000
ngrok will display output similar to this:
Code
ngrok by @inconshreveable (Ctrl+C to quit)
Session Status online
Account Your Name (Plan: Free)
Version x.x.x
Region United States (us-cal-1)
Web Interface http://127.0.0.1:4040
Forwarding http://<random-string>.ngrok.io -> http://localhost:3000
Forwarding https://<random-string>.ngrok.io -> http://localhost:3000
Connections ttl opn rt1 rt5 p50 p90
0 0 0.00 0.00 0.00 0.00
Copy the https://<random-string>.ngrok.io URL (use the HTTPS version). This is your public base URL.
6. Update Webhook URLs in Vonage Application:
Go back to your Vonage Application settings in the dashboard (https://dashboard.nexmo.com/applications, find your app, and click "Edit").
Update the Inbound URL to: YOUR_NGROK_HTTPS_URL/webhooks/inbound (e.g., https://<random-string>.ngrok.io/webhooks/inbound)
Update the Status URL to: YOUR_NGROK_HTTPS_URL/webhooks/status (e.g., https://<random-string>.ngrok.io/webhooks/status)
Ensure the HTTP method for both is POST.
Click "Save changes".
Now, Vonage knows where to send incoming messages and status updates.
3. Implement Express webhook endpoints for SMS delivery tracking
Write the Node.js code in server.js to initialize the server, configure Vonage, send SMS, and handle the webhooks.
JavaScript
// server.js// 1. Import necessary modulesrequire('dotenv').config(); // Load environment variables from .env fileconst express = require('express');
const { Vonage } = require('@vonage/server-sdk');
const { FileSystemCredentials } = require('@vonage/server-sdk/dist/credentials/fileSystemCredentials'); // For private key auth// 2. Initialize Express appconst app = express();
const port = process.env.PORT || 3000;
// 3. Middleware for parsing JSON and URL-encoded request bodies// Vonage webhooks typically send data as JSON
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// 4. Initialize Vonage SDK// Using Application ID and Private Key for authentication with Messages APIconst vonageCredentials = newFileSystemCredentials(
process.env.VONAGE_APPLICATION_ID,
process.env.VONAGE_PRIVATE_KEY_PATH
);
const vonage = newVonage(vonageCredentials);
// --- API Endpoint to Send SMS ---// Example: POST /send-sms with JSON body {"to": "...", "text": "..."}
app.post('/send-sms', async (req, res) => {
const { to, text } = req.body;
constfrom = process.env.VONAGE_NUMBER;
// Basic validationif (!to || !text) {
console.error('Missing "to" or "text" in request body');
return res.status(400).json({ error: 'Missing "to" or "text" in request body' });
}
if (!from) {
console.error('VONAGE_NUMBER is not set in .env');
return res.status(500).json({ error: 'Server configuration error: Missing sender number.' });
}
console.log(`Attempting to send SMS from ${from} to ${to}: "${text}"`);
try {
const resp = await vonage.messages.send({
message_type: 'text',
text: text,
to: to,
from: from,
channel: 'sms'
});
console.log('SMS submitted successfully:', resp);
res.status(200).json({ message: 'SMS sent successfully!', message_uuid: resp.message_uuid });
} catch (err) {
console.error('Error sending SMS:', err.response ? err.response.data : err.message);
// Provide more context if available from Vonage error responseconst errorDetails = err.response ? err.response.data : { message: err.message };
res.status(err.response?.status || 500).json({ error: 'Failed to send SMS', details: errorDetails });
}
});
// --- Webhook Endpoint for Inbound SMS ---
app.post('/webhooks/inbound', (req, res) => {
console.log('--- Inbound SMS Received ---');
console.log('Request Body:', JSON.stringify(req.body, null, 2));
// TODO: Add logic here to process the inbound message// e.g., save to database, trigger automated reply, forward to another system// Example: Log key informationconst { from, text, message_uuid, timestamp } = req.body;
console.log(`From: ${from?.number || 'Unknown'}, Text: ${text}, UUID: ${message_uuid}, Timestamp: ${timestamp}`);
// Vonage requires a 2xx response to acknowledge receipt of the webhook
res.status(200).send('OK');
// Using .send('OK') or .end() is fine. Avoid sending back large bodies.
});
// --- Webhook Endpoint for Delivery Status Updates ---
app.post('/webhooks/status', (req, res) => {
console.log('--- Delivery Status Update Received ---');
console.log('Request Body:', JSON.stringify(req.body, null, 2));
// TODO: Add logic here to update message status in your database// based on message_uuid and status// Example: Log key informationconst { message_uuid, status, timestamp, error } = req.body;
console.log(`UUID: ${message_uuid}, Status: ${status}, Timestamp: ${timestamp}`);
if (error) {
console.error(`Error Code: ${error.code}, Reason: ${error.reason}`);
}
// Acknowledge receipt
res.status(200).send('OK');
});
// 5. Basic Root Route (Optional)
app.get('/', (req, res) => {
res.send(`SMS Application is running. Use POST /send-sms to send messages. Webhooks configured at /webhooks/inbound and /webhooks/status.`);
});
// 6. Start the server
app.listen(port, () => {
console.log(`Server listening at http://localhost:${port}`);
console.log('Make sure ngrok is running and pointing to this port.');
console.log(`Webhook URLs should be configured in Vonage Application:`);
console.log(`Inbound: YOUR_NGROK_URL/webhooks/inbound`);
console.log(`Status: YOUR_NGROK_URL/webhooks/status`);
});
Code Explanation:
Imports: Load dotenv first to ensure environment variables are available. Import express and necessary components from @vonage/server-sdk.
Express Init: Standard Express application setup.
Middleware:express.json() and express.urlencoded() are crucial for parsing incoming webhook request bodies, which Vonage sends as JSON.
Vonage SDK Init: Initialize Vonage using FileSystemCredentials, providing the Application ID and the path to the private.key file. This method is required for authenticating Messages API calls associated with an Application.
/send-sms Endpoint:
Defines a POST route to trigger sending an SMS.
Extracts to and text from the JSON request body.
Performs basic validation.
Uses vonage.messages.send() within an async function.
Specifies message_type: 'text', channel: 'sms', along with to, from, and text.
Uses try...catch for error handling, logging errors and returning appropriate HTTP status codes (400 for bad input, 500 or Vonage's status for API errors).
Returns the message_uuid on success, which is useful for tracking.
/webhooks/inbound Endpoint:
Defines a POST route matching the Inbound URL configured in Vonage.
Logs the entire request body (req.body) received from Vonage. This contains details about the incoming SMS (sender number, message text, timestamp, etc.).
Crucially, sends a 200 OK response. Vonage expects this acknowledgment; otherwise, it will retry sending the webhook, leading to duplicate processing.
Includes placeholders (TODO) for adding your application-specific logic (database saving, auto-replies).
/webhooks/status Endpoint:
Defines a POST route matching the Status URL configured in Vonage.
Logs the request body, which contains the message_uuid of the original outbound message and its delivery status (e.g., delivered, failed, rejected, accepted), along with timestamps and potential error details.
Also sends a 200 OK response to acknowledge receipt.
Includes placeholders (TODO) for updating your application's message records.
Root Route: A simple GET route for basic verification that the server is running.
Server Start: Starts the Express server listening on the configured port, providing helpful console messages.
4. Test SMS delivery status webhooks and DLRs
Bring it all together and test the flow.
1. Ensure ngrok is Running:
Verify that your ngrok http 3000 command is still running in its terminal window and that the Forwarding URL matches the one configured in your Vonage Application's webhook settings. If it stopped, restart it and update the URLs in Vonage if the random subdomain changed (unless you have a paid ngrok plan with a static domain).
2. Fill .env File:
Make sure you have filled in all the values in your .env file: VONAGE_API_KEY, VONAGE_API_SECRET, VONAGE_APPLICATION_ID, VONAGE_NUMBER, and a valid TO_NUMBER for testing. Remember to use the E.164 format for phone numbers (e.g., 14155552671).
3. Start the Node.js Server:
In your primary terminal window (the one where you created the files and installed dependencies), run:
Bash
node server.js
You should see output like:
Code
Server listening at http://localhost:3000
Make sure ngrok is running and pointing to this port.
Webhook URLs should be configured in Vonage Application:
Inbound: https://<random-string>.ngrok.io/webhooks/inbound
Status: https://<random-string>.ngrok.io/webhooks/status
4. Test Sending an SMS:
Open a third terminal window or use a tool like Postman or curl to send a POST request to your /send-sms endpoint.
Replace YOUR_TEST_RECIPIENT_NUMBER with the number you set in TO_NUMBER or any other valid number.
Check Server Logs: In the terminal running server.js, you should see:
Attempting to send SMS from <VONAGE_NUMBER> to <TO_NUMBER>: "Hello from Vonage Node.js Application!"
SMS submitted successfully: { message_uuid: '…' }
Check curl Output: You should receive a JSON response like: {"message":"SMS sent successfully!","message_uuid":"…"}.
Check Your Phone: You should receive the SMS message on the recipient phone.
5. Test Delivery Status Webhook:
Wait a few seconds after the message is delivered to your phone.
Check Server Logs: You should see the status webhook being received:
--- Delivery Status Update Received ---
Request Body: { … "status": "delivered", "message_uuid": "…", … } (or potentially accepted first, then delivered). The exact status flow can vary slightly.
UUID: …, Status: delivered, Timestamp: …
6. Test Inbound SMS Webhook:
Using the phone that received the test message (or any phone), send an SMS message to your Vonage virtual number (the one specified in VONAGE_NUMBER).
Check Server Logs: You should see the inbound webhook being received:
If all these steps work and you see the corresponding logs, your core functionality is correctly implemented!
5. Handle webhook errors and implement retry logic for production
While our basic example includes try...catch and console.log/console.error, production applications need more robust strategies. This involves replacing the basic console logging with a structured logger like Winston for better analysis and handling errors gracefully.
Error Handling Strategy:
API Errors: Catch errors from the vonage.messages.send() call. Inspect err.response.data or err.message for details from the Vonage API. Return appropriate HTTP status codes (e.g., 400 for invalid input to Vonage, 401 for auth issues, 500/503 for Vonage server issues). Log these errors using a structured logger.
Webhook Errors: Wrap the logic inside webhook handlers (/webhooks/inbound, /webhooks/status) in try...catch blocks. Log any errors encountered during processing (e.g., database write failure) using the logger. Crucially, still return a 200 OK response to Vonage unless the request itself is malformed (which is unlikely for valid Vonage webhooks). Acknowledging the webhook prevents Vonage retries for processing errors that Vonage can't fix. Handle the processing failure asynchronously (e.g., add to a retry queue).
Validation Errors: Validate request bodies (/send-sms) and webhook payloads (check for expected fields) early and return 400 Bad Request if invalid. Log validation failures.
Logging:
Using a dedicated logging library like Winston or Pino is highly recommended over console.log/console.error for production applications.
Replace console.*: The primary goal is to replace all instances of console.log and console.error in your server.js (specifically within the route handlers like /send-sms, /webhooks/inbound, /webhooks/status) with calls to a logger instance (e.g., logger.info, logger.error).
Configure Log Levels: Use levels like debug, info, warn, error. Log informational messages (like successful sends, received webhooks) at info, potential issues at warn, and definite errors at error. Use debug for verbose tracing during development.
Structured Logging: Log messages as JSON objects. This makes parsing and analysis much easier in log management systems (e.g., Datadog, Splunk, ELK stack). Include relevant context like message_uuid, request_id, endpoint, etc.
Log Formatting: Include timestamps, log levels, and consistent message formats.
Example using Winston (Basic Setup):
Install Winston: npm install winston
Configure Winston near the top of server.js:
JavaScript
// server.js (add near the top, before route definitions)const winston = require('winston');
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info', // Default to 'info', use 'debug' for devformat: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }), // Log stack traces for errors
winston.format.json() // Log as JSON
),
defaultMeta: { service: 'vonage-sms-service' }, // Add common metadatatransports: [
// For production, configure transports to write to files or log aggregation services.// Example file transports (commented out):// new winston.transports.File({ filename: 'error.log', level: 'error' }),// new winston.transports.File({ filename: 'combined.log' }),// For development, log to the console with a more readable format:new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
})
],
});
// Now, replace all console.log and console.error calls throughout// your route handlers (/send-sms, /webhooks/inbound, /webhooks/status)// with logger calls, including relevant context.// Example replacement in /send-sms success:// Instead of: console.log('SMS submitted successfully:', resp);// Use: logger.info('SMS submitted successfully', { message_uuid: resp.message_uuid, to, from });// Example replacement in /send-sms error handler:// Instead of: console.error('Error sending SMS:', err.response ? err.response.data : err.message);// Use: logger.error('Error sending SMS', {// errorMessage: err.message,// vonageResponse: err.response ? err.response.data : null,// to: to,// from: from,// stack: err.stack // Include stack trace if available// });// Example replacement in /webhooks/inbound:// Instead of: console.log(`From: ${from?.number || 'Unknown'}, Text: ${text}, ...`);// Use: logger.info('Inbound SMS Received', {// from: req.body.from?.number,// text: req.body.text,// message_uuid: req.body.message_uuid// });// Example replacement in /webhooks/status error log:// Instead of: console.error(`Error Code: ${error.code}, Reason: ${error.reason}`);// Use: logger.warn('Delivery status update contained error', { // Use warn or error based on severity// message_uuid: req.body.message_uuid,// status: req.body.status,// errorCode: error.code,// errorReason: error.reason// });
Integrating the Logger:
Go back through the server.js code provided in Section 3 and systematically replace every console.log(...) with logger.info(...) and every console.error(...) with logger.error(...). Ensure you pass relevant contextual information as the second argument (metadata object) to the logger methods, as shown in the examples above. This provides much richer and more useful logs for debugging and monitoring.
Retry Mechanisms (Webhook Processing):
Vonage automatically retries sending webhooks if it doesn't receive a 200 OK response within a certain timeout (usually a few seconds). However, this only handles network issues or your server being temporarily down. It doesn't handle errors within your webhook processing logic (like a database connection failure).
For robust processing:
Acknowledge Quickly: Always send res.status(200).send('OK') immediately after receiving and basic validation of the webhook.
Asynchronous Processing: Perform the actual work (database writes, triggering other actions) asynchronously after sending the 200 OK.
Use a Queue: For critical webhook processing, push the payload onto a reliable queue (e.g., Redis queue, RabbitMQ, AWS SQS).
Worker Process: Have separate worker processes consume jobs from the queue.
Implement Retries with Backoff: If a worker fails to process a job (e.g., database temporarily unavailable), implement a retry strategy with exponential backoff (e.g., retry after 1s, 2s, 4s, 8s…). Libraries like async-retry or queue-specific features can help.
Dead-Letter Queue: After several failed retries, move the job to a "dead-letter queue" for manual inspection.
This ensures your webhook endpoints remain responsive to Vonage while handling transient processing failures gracefully. For simpler applications, logging the error and potentially alerting might suffice, accepting that some webhook events might be lost if processing fails persistently.
6. Store SMS delivery status in a database for tracking
Storing message details and status is often necessary. Here's a conceptual schema and considerations.
Database Choice: PostgreSQL, MySQL, MongoDB, or others depending on needs. Relational databases (Postgres, MySQL) are often suitable for structured message data.
Conceptual Schema (Relational Example):
SQL
CREATE TABLE sms_messages (
id SERIAL PRIMARY KEY, -- Auto-incrementing ID
message_uuid VARCHAR(255) UNIQUE, -- Vonage message UUID (use this for correlation)
direction VARCHAR(10) NOT NULL, -- 'outbound' or 'inbound'
sender_number VARCHAR(20), -- E.164 format
recipient_number VARCHAR(20), -- E.164 format
message_body TEXT,
status VARCHAR(50) DEFAULT'submitted', -- e.g., submitted, accepted, delivered, failed, read (for RCS/WhatsApp), rejected
vonage_status_code VARCHAR(10) NULL, -- e.g., Vonage error code if status is 'failed'
vonage_status_reason TEXT NULL, -- e.g., Vonage error reason if status is 'failed'
submitted_at TIMESTAMPWITHTIME ZONE DEFAULTCURRENT_TIMESTAMP,
status_updated_at TIMESTAMPWITHTIME ZONE,
received_at TIMESTAMPWITHTIME ZONE -- For inbound messages-- Add foreign keys to users, orders, etc. as needed
);
-- Index for efficient status updates and lookupsCREATE INDEX idx_sms_messages_message_uuid ON sms_messages(message_uuid);
CREATE INDEX idx_sms_messages_status ON sms_messages(status);
CREATE INDEX idx_sms_messages_direction ON sms_messages(direction);
Data Layer Implementation:
ORM/Query Builder: Use libraries like Sequelize, TypeORM, Prisma (for relational DBs), or Mongoose (for MongoDB) to interact with the database. This abstracts SQL/database commands and helps prevent SQL injection.
Outbound Flow:
Before calling vonage.messages.send(), create a record in sms_messages with direction = 'outbound', status = 'submitted', recipient_number, message_body, etc. Store the intended message_uuid if you generate one client-side, or leave it NULL.
After a successful vonage.messages.send() call, update the record with the message_uuid returned by Vonage.
Status Webhook (/webhooks/status):
Receive the webhook.
Extract message_uuid, status, timestamp, and error details.
Find the corresponding record in sms_messages using message_uuid.
Update the status, status_updated_at, vonage_status_code, and vonage_status_reason. Handle potential race conditions if multiple status updates arrive for the same UUID (e.g., check timestamps).
Create a new record in sms_messages with direction = 'inbound', status = 'received', sender_number = from.number, message_body = text, received_at = timestamp, and the message_uuid.
Migrations: Use database migration tools (like Sequelize CLI, TypeORM CLI, Prisma Migrate) to manage schema changes version control.
Performance: Index columns frequently used in WHERE clauses (message_uuid, status, direction, timestamps).
Vonage SMS API authentication and security best practices
Frequently asked questions
Use the Vonage Messages API with the @vonage/server-sdk in your Node.js app. After setting up a Vonage application and linking your virtual number, make a POST request to the /send-sms endpoint with recipient number and message text in the request body. The server-side code will use vonage.messages.send() to send the SMS through the Vonage API.
The Vonage Messages API allows sending SMS messages, receiving inbound SMS, and tracking message delivery statuses. It's a unified API supporting several messaging channels, but this tutorial uses it for SMS because of its reliability and broad reach. It handles both outbound messaging and inbound webhooks.
Express.js simplifies setting up the web server and handling API requests and routing for our SMS application. It's lightweight and commonly used with Node.js. We define routes for sending SMS and receiving webhooks from Vonage.
ngrok is essential during development to expose your local server's webhook endpoints to the internet so Vonage can reach them. Vonage requires publicly accessible URLs for webhooks. Once deployed to a live server, ngrok is no longer required.
Yes, by setting up a status webhook URL in your Vonage application. The app will receive real-time delivery receipts (DLRs) at this endpoint, including the message UUID and delivery status (e.g., 'delivered', 'failed'). This is handled by the /webhooks/status route in the Express app.
Configure an inbound webhook URL in your Vonage application settings. Vonage will send incoming messages to this URL, which corresponds to the /webhooks/inbound route in your app. This route will receive the sender's number and message text. You'll need an SMS-enabled Vonage number linked to your application.
The private.key file is used for authentication with the Vonage Messages API, typically through JSON Web Tokens (JWT). The key is generated with your Vonage application and paired with a public key. Store it securely and never commit it to version control.
In the Vonage dashboard, create a new application, enable the Messages capability, and specify your ngrok HTTPS URLs for the inbound and status webhooks. Generate public and private keys, saving the private key securely. Then, link your Vonage virtual number to the application.
You'll need Node.js and npm installed, a Vonage API account with an API key and secret, a rented Vonage virtual number, ngrok for local development, and optionally the Vonage CLI. The tutorial also recommends using dotenv for environment variables.
dotenv loads environment variables from a .env file, keeping sensitive credentials like API keys out of your source code. This enhances security and makes managing configurations easier. It’s best practice for handling API secrets.
After setting up your Vonage application and linking the webhooks to ngrok URLs, start your Node.js server. Send an SMS to your Vonage number to trigger the inbound webhook. Send an SMS from your app to a test number to check the status webhook, observing logs for confirmation.
The @vonage/server-sdk simplifies interaction with Vonage APIs within your Node.js code. It handles authentication and provides methods like vonage.messages.send() to easily send messages and manage other Vonage services.
The Vonage Messages API provides delivery receipts (DLRs) via webhooks. Set up a status URL in your Vonage application. Your app will receive updates at this endpoint with the message UUID and status, enabling real-time delivery tracking within your application.
Learn how to implement Vonage SMS delivery status tracking and webhooks in Node.js with Express. This hands-on tutorial shows you how to send SMS messages, receive delivery receipts (DLRs), handle inbound SMS via webhooks, and track message status in real-time. Perfect for building appointment reminders, order notifications, and two-factor authentication systems that need reliable delivery confirmation.
What you'll build: SMS webhooks with delivery tracking
Build a Node.js service that can:
Problem Solved: This application enables real-time, two-way SMS communication with status tracking – crucial for appointment reminders, order confirmations, two-factor authentication (2FA), and customer support notifications where delivery confirmation is essential.
Technologies Used:
.envfile intoprocess.env, keeping sensitive credentials out of source code.System Architecture:
Prerequisites:
npm install -g @vonage/cli. Useful for managing applications and numbers.1. Set up your Node.js Express project for Vonage webhooks
Create the project structure, install dependencies, and configure the environment.
1. Create Project Directory: Open your terminal and create a new directory for the project, then navigate into it.
mkdir vonage-sms-app cd vonage-sms-app2. Initialize Node.js Project: Initialize the project using npm, accepting the defaults.
This creates a
package.jsonfile.3. Install Dependencies: Install Express for the web server, the Vonage SDK for API interaction, and
dotenvfor environment variable management.express: Web framework for handling HTTP requests and routes.@vonage/server-sdk: Simplifies calls to the Vonage APIs.dotenv: Loads environment variables from a.envfile.4. Create Project Structure: Create the necessary files and folders.
touch server.js .env .gitignore private.key # Create empty filesserver.js: Will contain our main Express application logic..env: Stores sensitive configuration like API keys (DO NOT commit this file)..gitignore: Specifies intentionally untracked files that Git should ignore (like.envandnode_modules).private.key: Will store the private key downloaded from your Vonage Application (DO NOT commit this file).5. Configure
.gitignore: Add the following lines to your.gitignorefile to prevent committing sensitive information and unnecessary files:6. Configure
.env: Open the.envfile and add placeholders for your Vonage credentials and application details. Fill these in later.Explanation of Configuration Choices:
.envFile: Using a.envfile anddotenvis a standard practice for managing configuration and secrets in Node.js applications, keeping sensitive data separate from the codebase..gitignore: Essential for preventing accidental exposure of API keys, private keys, and environment files in version control systems like Git.private.key: The Vonage Messages API often uses JWTs generated with a public/private key pair for authentication via Applications. Storing the key securely is paramount.2. Configure Vonage webhooks for delivery status callbacks
Configure your Vonage account and create a Vonage Application to handle Messages API interactions and webhooks before writing code.
1. Get API Key and Secret: Log in to your Vonage API Dashboard. Your API Key and Secret are displayed at the top. Copy these values into your
.envfile forVONAGE_API_KEYandVONAGE_API_SECRET.2. Set Default SMS API: Navigate to your Account Settings in the Vonage dashboard. Scroll down to "API settings" > "SMS settings". Ensure "Default SMS Setting" is set to Messages API. This ensures webhooks use the Messages API format. Click "Save changes".
3. Create a Vonage Application: Vonage Applications act as containers for your communication configurations, including webhook URLs and authentication keys.
private.keyfile. Save this file in the root directory of your project (the same location as yourserver.js). Ensure theVONAGE_PRIVATE_KEY_PATHin your.envfile correctly points to it (./private.key). The public key is automatically stored by Vonage.ngrokin the next step. For now, you can enter temporary placeholders likehttp://example.com/webhooks/inboundandhttp://example.com/webhooks/status. You will update these shortly. Ensure the method is set to POST..envfile forVONAGE_APPLICATION_ID.4. Link Your Vonage Number: Scroll down on the Application details page to the "Link virtual numbers" section. Find the Vonage virtual number you rented and click the "Link" button next to it. This directs incoming messages and status updates for this number to the webhooks defined in this application. Copy this number (in E.164 format, e.g.,
18885551212) into your.envfile forVONAGE_NUMBER.5. Start ngrok: Get the public URL for your webhooks. Open a new terminal window (keep the first one for running the server later). Navigate to your project directory (optional but good practice) and run ngrok, telling it to expose the port your Express server will run on (defined as
PORTin.env, default is 3000).ngrok will display output similar to this:
Copy the
https://<random-string>.ngrok.ioURL (use the HTTPS version). This is your public base URL.6. Update Webhook URLs in Vonage Application: Go back to your Vonage Application settings in the dashboard (https://dashboard.nexmo.com/applications, find your app, and click "Edit").
YOUR_NGROK_HTTPS_URL/webhooks/inbound(e.g.,https://<random-string>.ngrok.io/webhooks/inbound)YOUR_NGROK_HTTPS_URL/webhooks/status(e.g.,https://<random-string>.ngrok.io/webhooks/status)Now, Vonage knows where to send incoming messages and status updates.
3. Implement Express webhook endpoints for SMS delivery tracking
Write the Node.js code in
server.jsto initialize the server, configure Vonage, send SMS, and handle the webhooks.// server.js // 1. Import necessary modules require('dotenv').config(); // Load environment variables from .env file const express = require('express'); const { Vonage } = require('@vonage/server-sdk'); const { FileSystemCredentials } = require('@vonage/server-sdk/dist/credentials/fileSystemCredentials'); // For private key auth // 2. Initialize Express app const app = express(); const port = process.env.PORT || 3000; // 3. Middleware for parsing JSON and URL-encoded request bodies // Vonage webhooks typically send data as JSON app.use(express.json()); app.use(express.urlencoded({ extended: true })); // 4. Initialize Vonage SDK // Using Application ID and Private Key for authentication with Messages API const vonageCredentials = new FileSystemCredentials( process.env.VONAGE_APPLICATION_ID, process.env.VONAGE_PRIVATE_KEY_PATH ); const vonage = new Vonage(vonageCredentials); // --- API Endpoint to Send SMS --- // Example: POST /send-sms with JSON body {"to": "...", "text": "..."} app.post('/send-sms', async (req, res) => { const { to, text } = req.body; const from = process.env.VONAGE_NUMBER; // Basic validation if (!to || !text) { console.error('Missing "to" or "text" in request body'); return res.status(400).json({ error: 'Missing "to" or "text" in request body' }); } if (!from) { console.error('VONAGE_NUMBER is not set in .env'); return res.status(500).json({ error: 'Server configuration error: Missing sender number.' }); } console.log(`Attempting to send SMS from ${from} to ${to}: "${text}"`); try { const resp = await vonage.messages.send({ message_type: 'text', text: text, to: to, from: from, channel: 'sms' }); console.log('SMS submitted successfully:', resp); res.status(200).json({ message: 'SMS sent successfully!', message_uuid: resp.message_uuid }); } catch (err) { console.error('Error sending SMS:', err.response ? err.response.data : err.message); // Provide more context if available from Vonage error response const errorDetails = err.response ? err.response.data : { message: err.message }; res.status(err.response?.status || 500).json({ error: 'Failed to send SMS', details: errorDetails }); } }); // --- Webhook Endpoint for Inbound SMS --- app.post('/webhooks/inbound', (req, res) => { console.log('--- Inbound SMS Received ---'); console.log('Request Body:', JSON.stringify(req.body, null, 2)); // TODO: Add logic here to process the inbound message // e.g., save to database, trigger automated reply, forward to another system // Example: Log key information const { from, text, message_uuid, timestamp } = req.body; console.log(`From: ${from?.number || 'Unknown'}, Text: ${text}, UUID: ${message_uuid}, Timestamp: ${timestamp}`); // Vonage requires a 2xx response to acknowledge receipt of the webhook res.status(200).send('OK'); // Using .send('OK') or .end() is fine. Avoid sending back large bodies. }); // --- Webhook Endpoint for Delivery Status Updates --- app.post('/webhooks/status', (req, res) => { console.log('--- Delivery Status Update Received ---'); console.log('Request Body:', JSON.stringify(req.body, null, 2)); // TODO: Add logic here to update message status in your database // based on message_uuid and status // Example: Log key information const { message_uuid, status, timestamp, error } = req.body; console.log(`UUID: ${message_uuid}, Status: ${status}, Timestamp: ${timestamp}`); if (error) { console.error(`Error Code: ${error.code}, Reason: ${error.reason}`); } // Acknowledge receipt res.status(200).send('OK'); }); // 5. Basic Root Route (Optional) app.get('/', (req, res) => { res.send(`SMS Application is running. Use POST /send-sms to send messages. Webhooks configured at /webhooks/inbound and /webhooks/status.`); }); // 6. Start the server app.listen(port, () => { console.log(`Server listening at http://localhost:${port}`); console.log('Make sure ngrok is running and pointing to this port.'); console.log(`Webhook URLs should be configured in Vonage Application:`); console.log(`Inbound: YOUR_NGROK_URL/webhooks/inbound`); console.log(`Status: YOUR_NGROK_URL/webhooks/status`); });Code Explanation:
dotenvfirst to ensure environment variables are available. Importexpressand necessary components from@vonage/server-sdk.express.json()andexpress.urlencoded()are crucial for parsing incoming webhook request bodies, which Vonage sends as JSON.FileSystemCredentials, providing the Application ID and the path to theprivate.keyfile. This method is required for authenticating Messages API calls associated with an Application./send-smsEndpoint:POSTroute to trigger sending an SMS.toandtextfrom the JSON request body.vonage.messages.send()within anasyncfunction.message_type: 'text',channel: 'sms', along withto,from, andtext.try...catchfor error handling, logging errors and returning appropriate HTTP status codes (400 for bad input, 500 or Vonage's status for API errors).message_uuidon success, which is useful for tracking./webhooks/inboundEndpoint:POSTroute matching the Inbound URL configured in Vonage.req.body) received from Vonage. This contains details about the incoming SMS (sender number, message text, timestamp, etc.).200 OKresponse. Vonage expects this acknowledgment; otherwise, it will retry sending the webhook, leading to duplicate processing.TODO) for adding your application-specific logic (database saving, auto-replies)./webhooks/statusEndpoint:POSTroute matching the Status URL configured in Vonage.message_uuidof the original outbound message and its deliverystatus(e.g.,delivered,failed,rejected,accepted), along with timestamps and potential error details.200 OKresponse to acknowledge receipt.TODO) for updating your application's message records.GETroute for basic verification that the server is running.4. Test SMS delivery status webhooks and DLRs
Bring it all together and test the flow.
1. Ensure ngrok is Running: Verify that your
ngrok http 3000command is still running in its terminal window and that theForwardingURL matches the one configured in your Vonage Application's webhook settings. If it stopped, restart it and update the URLs in Vonage if the random subdomain changed (unless you have a paid ngrok plan with a static domain).2. Fill
.envFile: Make sure you have filled in all the values in your.envfile:VONAGE_API_KEY,VONAGE_API_SECRET,VONAGE_APPLICATION_ID,VONAGE_NUMBER, and a validTO_NUMBERfor testing. Remember to use the E.164 format for phone numbers (e.g.,14155552671).3. Start the Node.js Server: In your primary terminal window (the one where you created the files and installed dependencies), run:
You should see output like:
4. Test Sending an SMS: Open a third terminal window or use a tool like Postman or
curlto send a POST request to your/send-smsendpoint.Using
curl:curl -X POST \ http://localhost:3000/send-sms \ -H 'Content-Type: application/json' \ -d '{ "to": "YOUR_TEST_RECIPIENT_NUMBER", "text": "Hello from Vonage Node.js Application!" }'Replace
YOUR_TEST_RECIPIENT_NUMBERwith the number you set inTO_NUMBERor any other valid number.server.js, you should see:Attempting to send SMS from <VONAGE_NUMBER> to <TO_NUMBER>: "Hello from Vonage Node.js Application!"SMS submitted successfully: { message_uuid: '…' }curlOutput: You should receive a JSON response like:{"message":"SMS sent successfully!","message_uuid":"…"}.5. Test Delivery Status Webhook: Wait a few seconds after the message is delivered to your phone.
--- Delivery Status Update Received ---Request Body: { … "status": "delivered", "message_uuid": "…", … }(or potentiallyacceptedfirst, thendelivered). The exact status flow can vary slightly.UUID: …, Status: delivered, Timestamp: …6. Test Inbound SMS Webhook: Using the phone that received the test message (or any phone), send an SMS message to your Vonage virtual number (the one specified in
VONAGE_NUMBER).--- Inbound SMS Received ---Request Body: { "from": { "type": "sms", "number": "<SENDER_NUMBER>" }, "text": "<YOUR_MESSAGE_TEXT>", … }From: <SENDER_NUMBER>, Text: <YOUR_MESSAGE_TEXT>, UUID: …, Timestamp: …If all these steps work and you see the corresponding logs, your core functionality is correctly implemented!
5. Handle webhook errors and implement retry logic for production
While our basic example includes
try...catchandconsole.log/console.error, production applications need more robust strategies. This involves replacing the basic console logging with a structured logger like Winston for better analysis and handling errors gracefully.Error Handling Strategy:
vonage.messages.send()call. Inspecterr.response.dataorerr.messagefor details from the Vonage API. Return appropriate HTTP status codes (e.g., 400 for invalid input to Vonage, 401 for auth issues, 500/503 for Vonage server issues). Log these errors using a structured logger./webhooks/inbound,/webhooks/status) intry...catchblocks. Log any errors encountered during processing (e.g., database write failure) using the logger. Crucially, still return a200 OKresponse to Vonage unless the request itself is malformed (which is unlikely for valid Vonage webhooks). Acknowledging the webhook prevents Vonage retries for processing errors that Vonage can't fix. Handle the processing failure asynchronously (e.g., add to a retry queue)./send-sms) and webhook payloads (check for expected fields) early and return400 Bad Requestif invalid. Log validation failures.Logging:
Using a dedicated logging library like Winston or Pino is highly recommended over
console.log/console.errorfor production applications.console.*: The primary goal is to replace all instances ofconsole.logandconsole.errorin yourserver.js(specifically within the route handlers like/send-sms,/webhooks/inbound,/webhooks/status) with calls to a logger instance (e.g.,logger.info,logger.error).debug,info,warn,error. Log informational messages (like successful sends, received webhooks) atinfo, potential issues atwarn, and definite errors aterror. Usedebugfor verbose tracing during development.message_uuid,request_id,endpoint, etc.Example using Winston (Basic Setup):
npm install winstonserver.js:// server.js (add near the top, before route definitions) const winston = require('winston'); const logger = winston.createLogger({ level: process.env.LOG_LEVEL || 'info', // Default to 'info', use 'debug' for dev format: winston.format.combine( winston.format.timestamp(), winston.format.errors({ stack: true }), // Log stack traces for errors winston.format.json() // Log as JSON ), defaultMeta: { service: 'vonage-sms-service' }, // Add common metadata transports: [ // For production, configure transports to write to files or log aggregation services. // Example file transports (commented out): // new winston.transports.File({ filename: 'error.log', level: 'error' }), // new winston.transports.File({ filename: 'combined.log' }), // For development, log to the console with a more readable format: new winston.transports.Console({ format: winston.format.combine( winston.format.colorize(), winston.format.simple() ) }) ], }); // Now, replace all console.log and console.error calls throughout // your route handlers (/send-sms, /webhooks/inbound, /webhooks/status) // with logger calls, including relevant context. // Example replacement in /send-sms success: // Instead of: console.log('SMS submitted successfully:', resp); // Use: logger.info('SMS submitted successfully', { message_uuid: resp.message_uuid, to, from }); // Example replacement in /send-sms error handler: // Instead of: console.error('Error sending SMS:', err.response ? err.response.data : err.message); // Use: logger.error('Error sending SMS', { // errorMessage: err.message, // vonageResponse: err.response ? err.response.data : null, // to: to, // from: from, // stack: err.stack // Include stack trace if available // }); // Example replacement in /webhooks/inbound: // Instead of: console.log(`From: ${from?.number || 'Unknown'}, Text: ${text}, ...`); // Use: logger.info('Inbound SMS Received', { // from: req.body.from?.number, // text: req.body.text, // message_uuid: req.body.message_uuid // }); // Example replacement in /webhooks/status error log: // Instead of: console.error(`Error Code: ${error.code}, Reason: ${error.reason}`); // Use: logger.warn('Delivery status update contained error', { // Use warn or error based on severity // message_uuid: req.body.message_uuid, // status: req.body.status, // errorCode: error.code, // errorReason: error.reason // });Integrating the Logger:
Go back through the
server.jscode provided in Section 3 and systematically replace everyconsole.log(...)withlogger.info(...)and everyconsole.error(...)withlogger.error(...). Ensure you pass relevant contextual information as the second argument (metadata object) to the logger methods, as shown in the examples above. This provides much richer and more useful logs for debugging and monitoring.Retry Mechanisms (Webhook Processing):
Vonage automatically retries sending webhooks if it doesn't receive a
200 OKresponse within a certain timeout (usually a few seconds). However, this only handles network issues or your server being temporarily down. It doesn't handle errors within your webhook processing logic (like a database connection failure).For robust processing:
res.status(200).send('OK')immediately after receiving and basic validation of the webhook.async-retryor queue-specific features can help.This ensures your webhook endpoints remain responsive to Vonage while handling transient processing failures gracefully. For simpler applications, logging the error and potentially alerting might suffice, accepting that some webhook events might be lost if processing fails persistently.
6. Store SMS delivery status in a database for tracking
Storing message details and status is often necessary. Here's a conceptual schema and considerations.
Database Choice: PostgreSQL, MySQL, MongoDB, or others depending on needs. Relational databases (Postgres, MySQL) are often suitable for structured message data.
Conceptual Schema (Relational Example):
CREATE TABLE sms_messages ( id SERIAL PRIMARY KEY, -- Auto-incrementing ID message_uuid VARCHAR(255) UNIQUE, -- Vonage message UUID (use this for correlation) direction VARCHAR(10) NOT NULL, -- 'outbound' or 'inbound' sender_number VARCHAR(20), -- E.164 format recipient_number VARCHAR(20), -- E.164 format message_body TEXT, status VARCHAR(50) DEFAULT 'submitted', -- e.g., submitted, accepted, delivered, failed, read (for RCS/WhatsApp), rejected vonage_status_code VARCHAR(10) NULL, -- e.g., Vonage error code if status is 'failed' vonage_status_reason TEXT NULL, -- e.g., Vonage error reason if status is 'failed' submitted_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, status_updated_at TIMESTAMP WITH TIME ZONE, received_at TIMESTAMP WITH TIME ZONE -- For inbound messages -- Add foreign keys to users, orders, etc. as needed ); -- Index for efficient status updates and lookups CREATE INDEX idx_sms_messages_message_uuid ON sms_messages(message_uuid); CREATE INDEX idx_sms_messages_status ON sms_messages(status); CREATE INDEX idx_sms_messages_direction ON sms_messages(direction);Data Layer Implementation:
vonage.messages.send(), create a record insms_messageswithdirection = 'outbound',status = 'submitted',recipient_number,message_body, etc. Store the intendedmessage_uuidif you generate one client-side, or leave it NULL.vonage.messages.send()call, update the record with themessage_uuidreturned by Vonage./webhooks/status):message_uuid,status,timestamp, anderrordetails.sms_messagesusingmessage_uuid.status,status_updated_at,vonage_status_code, andvonage_status_reason. Handle potential race conditions if multiple status updates arrive for the same UUID (e.g., check timestamps)./webhooks/inbound):from.number,text,message_uuid,timestamp.sms_messageswithdirection = 'inbound',status = 'received',sender_number = from.number,message_body = text,received_at = timestamp, and themessage_uuid.Migrations: Use database migration tools (like Sequelize CLI, TypeORM CLI, Prisma Migrate) to manage schema changes version control.
Performance: Index columns frequently used in
WHEREclauses (message_uuid,status,direction, timestamps).Related SMS integration tutorials