Learn how to build a Node.js application using Express and the Vonage Messages API to send SMS and receive real-time delivery status updates via webhooks.
Tracking the delivery status of SMS messages is crucial for applications that rely on timely communication. Knowing whether a message reached the recipient's handset, was rejected by the carrier, or failed for other reasons enables developers to build more robust and reliable systems. This guide provides a step-by-step walkthrough for building a Node.js application using Express and the Vonage Messages API to send SMS messages and receive real-time delivery status updates via webhooks.
We will build a simple Node.js server that can send an SMS message and expose webhook endpoints to receive delivery status updates from Vonage. This solves the common problem of ""fire and forget"" SMS sending, providing visibility into the message lifecycle after it leaves the Vonage platform. We'll use the Vonage Node.js SDK for seamless API interaction and ngrok for local development testing.
System architecture
Here's a high-level overview of how the components interact:
User/Trigger: Initiates the SMS send request to the Node.js application.
Node.js Application (Express):
Receives the send request.
Uses the Vonage Node.js SDK to call the Vonage Messages API.
Listens for incoming webhook events (status updates) from Vonage.
Vonage Messages API:
Accepts the SMS send request.
Delivers the SMS message via carrier networks.
Sends status updates (e.g., submitted, delivered, failed) to the configured Status Webhook URL.
Carrier Network: Delivers the SMS to the recipient's handset.
Recipient: Receives the SMS message.
(Note: A sequence diagram illustrating the component interactions was present here in the original document.)
Prerequisites
Before you begin, ensure you have the following:
Node.js and npm: Installed on your system. Download from nodejs.org.
Vonage API Account: Sign up at Vonage API Dashboard. You'll need your API Key and API Secret.
Vonage Application: You'll create one using the Messages API capability. This provides an Application ID and allows generating a private key.
Vonage Virtual Number: Purchase a Vonage number capable of sending SMS messages from the dashboard (Numbers > Buy Numbers).
ngrok: A tool to expose your local server to the internet for webhook testing. Download from ngrok.com and create a free account.
Git: (Optional) For version control and cloning the example repository.
1. Setting up the project
Let's create the project directory, initialize Node.js, and install the necessary dependencies.
Create Project Directory:
Open your terminal or command prompt and run:
Bash
mkdir vonage-sms-status-guide
cd vonage-sms-status-guide
Initialize Node.js Project:
This creates a package.json file.
Bash
npm init -y
Install Dependencies:
We need express for the web server, @vonage/server-sdk to interact with the Vonage API, dotenv to manage environment variables, and jsonwebtoken to verify webhook signatures. We also recommend body-parser for reliable webhook verification.
Explanation: We ignore node_modules (can be reinstalled), .env (contains secrets), private.key (Vonage private key), and log files.
Set up Environment Variables:
Create a .env.example file to list the required variables. This serves as a template.
.env
# .env.example
VONAGE_API_KEY=YOUR_VONAGE_API_KEY
VONAGE_API_SECRET=YOUR_VONAGE_API_SECRET
VONAGE_APPLICATION_ID=YOUR_VONAGE_APPLICATION_ID
VONAGE_PRIVATE_KEY_PATH=./private.key
VONAGE_SIGNATURE_SECRET=YOUR_VONAGE_SIGNATURE_SECRET # From Application settings
VONAGE_NUMBER=YOUR_VONAGE_VIRTUAL_NUMBER # In E.164 format, e.g., +12015550123
PORT=3000
BASE_URL=http://localhost:3000 # Will be updated with ngrok URL
Now, create your actual .env file by copying .env.example and filling in your real credentials.
Bash
cp .env.example .env
Explanation:
VONAGE_API_KEY, VONAGE_API_SECRET: Found on the main page of your Vonage API Dashboard.
VONAGE_APPLICATION_ID: Obtained after creating a Vonage Application (next step).
VONAGE_PRIVATE_KEY_PATH: Path to the private key file downloaded when creating the application. We assume it's in the project root named private.key.
VONAGE_SIGNATURE_SECRET: Found in your Vonage Application settings under 'Webhook signature'. Used to verify incoming webhooks.
VONAGE_NUMBER: Your purchased Vonage virtual number capable of sending SMS (must be in E.164 format, e.g., +12015550123).
PORT: The port your local server will run on.
BASE_URL: The base URL for your webhook endpoints. We'll update this later with the ngrok URL.
2. Configuring Vonage
You need a Vonage Application configured for the Messages API to handle sending and status updates.
Navigate to Applications: Log in to the Vonage API Dashboard and go to ""Applications"" > ""Create a new application"".
Name Your Application: Enter a descriptive name (e.g., ""Node SMS Status Guide App"").
Generate Keys: Click ""Generate public and private key"". Immediately save the private.key file that downloads. Place this file in your project root directory (matching VONAGE_PRIVATE_KEY_PATH in .env). The public key is stored by Vonage.
Note Application ID: Copy the generated Application ID and paste it into your .env file for VONAGE_APPLICATION_ID.
Enable Capabilities: Find the ""Capabilities"" section.
Toggle on ""Messages"".
Enter placeholder URLs for now (we'll update these after starting ngrok):
Inbound URL: http://example.com/webhooks/inbound
Status URL: http://example.com/webhooks/status
Why: The Status URL is where Vonage will POST delivery status updates. The Inbound URL is for receiving SMS replies (optional for this guide but good practice to configure).
Find Signature Secret: Scroll down to the 'Webhook signature' section within the Messages capability settings. Copy the 'Secret' and paste it into your .env file for VONAGE_SIGNATURE_SECRET.
Link Virtual Number: Scroll down further to ""Link virtual numbers"". Find your purchased Vonage number and click ""Link"".
Save Changes: Click ""Save changes"" at the bottom of the page.
(Optional but Recommended) Set Messages API as Default: Navigate to your main account ""Settings"". Scroll to ""API Settings"" > ""SMS Settings"". Ensure ""Default SMS Setting"" is set to ""Messages API"". This ensures consistency if other parts of your account interact with SMS. Save changes if necessary.
3. Setting up the webhook server (Express)
Now, let's write the Node.js code using Express to handle incoming webhooks.
JavaScript
// index.jsrequire('dotenv').config(); // Load environment variables from .env fileconst express = require('express');
const bodyParser = require('body-parser'); // Import body-parserconst jwt = require('jsonwebtoken');
const { Vonage } = require('@vonage/server-sdk');
const app = express();
// --- Middleware ---// Capture raw body *before* JSON parsing for reliable signature verification
app.use(bodyParser.json({
verify: (req, res, buf) => {
// Save the raw buffer onto the request object
req.rawBody = buf;
}
}));
// Use express.urlencoded({ extended: true }) for URL-encoded data
app.use(express.urlencoded({ extended: true }));
// --- Vonage Initialization ---// Check for essential environment variablesif (!process.env.VONAGE_APPLICATION_ID || !process.env.VONAGE_PRIVATE_KEY_PATH || !process.env.VONAGE_SIGNATURE_SECRET) {
console.error('Error: VONAGE_APPLICATION_ID, VONAGE_PRIVATE_KEY_PATH, or VONAGE_SIGNATURE_SECRET not set in .env');
process.exit(1);
}
const vonage = newVonage({
apiKey: process.env.VONAGE_API_KEY, // Optional for Messages API JWT auth, but good practiceapiSecret: process.env.VONAGE_API_SECRET, // Optional for Messages API JWT authapplicationId: process.env.VONAGE_APPLICATION_ID,
privateKey: process.env.VONAGE_PRIVATE_KEY_PATH,
}, {
debug: true// Enable debug logging for the SDK
});
// --- Webhook Security Middleware ---// Verifies the JWT signature on incoming Vonage webhooks using the raw bodyconstverifyVonageSignature = (req, res, next) => {
try {
const authHeader = req.headers.authorization;
const token = authHeader?.split(' ')[1]; // Extract Bearer tokenif (!token) {
console.warn('Webhook received without Authorization header or token.');
return res.status(401).send('Unauthorized: Missing token');
}
// Verify the token using the raw body captured by bodyParser's verify function// Vonage signs the raw request body, not the parsed JSON object.if (!req.rawBody) {
console.error('Raw body buffer not available for verification. Ensure bodyParser.json() with verify is used before this middleware.');
return res.status(500).send('Internal Server Error: Cannot verify signature');
}
// jwt.verify throws if the signature is invalidconst decoded = jwt.verify(token, process.env.VONAGE_SIGNATURE_SECRET, {
algorithms: ['HS256'],
// IMPORTANT: Provide the raw body buffer for verification// This assumes the payload is the raw body itself for JWT verification context// Check Vonage docs if payload structure for signing differs
}); // Note: jwt.verify doesn't directly take the raw body for payload check,// it verifies the signature against the token structure. The key is using the// correct secret. Vonage includes payload claims *within* the signed JWT.// Optional: Check payload application_uuid matches your app ID// if (decoded.application_uuid !== process.env.VONAGE_APPLICATION_ID) {// console.warn(`Webhook received for unexpected application ID: ${decoded.application_uuid}`);// return res.status(401).send('Unauthorized: Invalid application ID');// }console.log('Webhook signature verified successfully.');
req.vonage_payload = decoded; // Attach decoded JWT payload if needed laternext(); // Signature is valid, proceed to the handler
} catch (error) {
console.error('Error verifying webhook signature:', error.message);
if (error instanceof jwt.TokenExpiredError) {
return res.status(401).send('Unauthorized: Token expired');
}
if (error instanceof jwt.JsonWebTokenError) {
return res.status(401).send('Unauthorized: Invalid signature');
}
// Log other unexpected errorsconsole.error('Unexpected error during signature verification:', error);
return res.status(500).send('Internal Server Error');
}
};
// --- Webhook Endpoints ---// Status Webhook: Receives delivery status updates// Apply the verification middleware *only* to Vonage webhooks
app.post('/webhooks/status', verifyVonageSignature, (req, res) => {
// The body is already parsed by bodyParser.json()const statusData = req.body;
console.log('--- Delivery Status Received ---');
console.log('Message UUID:', statusData.message_uuid);
console.log('Status:', statusData.status);
console.log('Timestamp:', statusData.timestamp);
if (statusData.error) {
console.error('Error Code:', statusData.error.code);
console.error('Error Reason:', statusData.error.reason);
}
console.log('Full Payload:', JSON.stringify(statusData, null, 2));
console.log('------------------------------');
// Vonage expects a 200 OK response to acknowledge receipt// Failure to send 200 OK will cause Vonage to retry the webhook
res.status(200).send('OK');
});
// Inbound Webhook: Receives incoming SMS messages (optional for this guide)// Apply the verification middleware here too for security
app.post('/webhooks/inbound', verifyVonageSignature, (req, res) => {
const inboundData = req.body;
console.log('--- Inbound SMS Received ---');
console.log('From:', inboundData.from?.number || 'Unknown');
console.log('To:', inboundData.to?.number || 'Unknown');
console.log('Text:', inboundData.message?.content?.text || 'N/A');
console.log('Full Payload:', JSON.stringify(inboundData, null, 2));
console.log('--------------------------');
res.status(200).send('OK');
});
// --- SMS Sending Function ---asyncfunctionsendSms(toNumber, text) {
console.log(`Attempting to send SMS to ${toNumber}`);
const fromNumber = process.env.VONAGE_NUMBER;
if (!fromNumber) {
console.error('Error: VONAGE_NUMBER not set in .env');
return;
}
// Validate recipient number format (strict E.164 check)if (!/^\+\d{10,15}$/.test(toNumber)) {
console.error(`Error: Invalid recipient number format: ${toNumber}. Must use E.164 format (e.g., +12015550123).`);
// Optionally throw an error or return a failure indicatorreturn;
}
try {
const resp = await vonage.messages.send({
message_type: ""text"",
to: toNumber, // E.164 format (e.g., +14155550100)from: fromNumber, // Your Vonage numberchannel: ""sms"",
text: text,
// client_ref: 'my-internal-tracking-id-123' // Optional client reference
});
console.log('SMS Submitted Successfully!');
console.log('Message UUID:', resp.messageUuid);
} catch (err) {
console.error('Error sending SMS:', err?.response?.data || err.message);
// Log detailed error if available from Vonage responseif (err?.response?.data) {
console.error('Vonage Error Details:', JSON.stringify(err.response.data, null, 2));
}
}
}
// --- Example Send Endpoint (for testing) ---
app.post('/send-sms', (req, res) => {
const { to, text } = req.body;
if (!to || !text) {
return res.status(400).send('Missing ""to"" or ""text"" in request body.');
}
// Strict E.164 format checkif (!/^\+\d{10,15}$/.test(to)) {
return res.status(400).send('Invalid ""to"" number format. Use E.164 (e.g., +12015550123).');
}
sendSms(to, text); // Fire and forget for this example
res.status(202).send('SMS send request accepted.');
});
// --- Start Server ---const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`Server listening at http://localhost:${port}`);
console.log(`Webhook URLs should be configured in Vonage based on your BASE_URL:`);
console.log(`Status URL: ${process.env.BASE_URL || `http://localhost:${port}`}/webhooks/status`);
console.log(`Inbound URL: ${process.env.BASE_URL || `http://localhost:${port}`}/webhooks/inbound`);
// Example: Send a test SMS on startup (remove in production)// Make sure to add a valid test number in E.164 format// const TEST_RECIPIENT = '+14155550101'; // Replace with your test phone number// if (process.env.NODE_ENV !== 'production' && TEST_RECIPIENT) {// console.log(`Sending initial test SMS to ${TEST_RECIPIENT}...`);// setTimeout(() => sendSms(TEST_RECIPIENT, 'Hello from Vonage Node Guide! Startup Test - ' + new Date().toLocaleTimeString()), 2000);// }
});
Explanation:
We initialize Express and load .env variables.
The Vonage SDK is initialized using the Application ID and private key path from .env. Debug mode is enabled for detailed SDK logs.
Webhook Security: body-parser middleware with a verify function is used to capture the raw request body before JSON parsing. The verifyVonageSignature middleware then uses jsonwebtoken and your VONAGE_SIGNATURE_SECRET to verify the JWT signature from the Authorization: Bearer <token> header. This ensures the request genuinely originated from Vonage. This raw body approach is crucial for reliable production verification.
/webhooks/status: This route handles POST requests from Vonage containing delivery status updates. It logs the received data and sends a 200 OK response. This acknowledgment is vital; otherwise, Vonage will retry sending the webhook.
sendSms function: Encapsulates the logic for sending an SMS using vonage.messages.send(). It takes the recipient number (validated for strict E.164 format) and text as arguments. It logs the messageUuid on successful submission.
/send-sms endpoint: A simple POST endpoint to trigger the sendSms function for testing purposes via cURL or Postman. Includes strict E.164 validation.
The server starts listening on the specified PORT.
4. Running and testing locally
To receive webhooks from Vonage on your local machine, you need ngrok.
Start ngrok: Open a new terminal window and run ngrok, pointing it to the port your Node.js app is running on (default is 3000).
Bash
ngrok http 3000
ngrok will display output similar to this:
Code
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 https://<random-id>.ngrok-free.app -> http://localhost:3000
Connections ttl opn rt1 rt5 p50 p90
0 0 0.00 0.00 0.00 0.00
Copy the https://<random-id>.ngrok-free.app URL. This is your public base URL.
Update .env: Open your .env file and set the BASE_URL to your ngrok Forwarding URL:
.env
# .env
# ... other variables
BASE_URL=https://<random-id>.ngrok-free.app
Update Vonage Application Webhooks: Go back to your application settings in the Vonage Dashboard. Update the Messages capability webhook URLs using your ngrok BASE_URL:
Status URL: https://<random-id>.ngrok-free.app/webhooks/status
Click ""Save changes"".
Run the Node.js Application: In your original terminal window (where your project code is), start the server:
Bash
node index.js
You should see output indicating the server is running and listening.
Test Sending SMS: Open another terminal or use a tool like Postman to send a POST request to your local /send-sms endpoint. Replace <your_test_phone> with your actual mobile number in E.164 format (e.g., +14155550101).
Logs in your Node.js application terminal showing the SMS submission attempt and the messageUuid.
Shortly after, you should receive the SMS on your test phone.
Observe Status Webhooks: Watch your Node.js application terminal. As the message progresses through the delivery lifecycle, Vonage will send POST requests to your /webhooks/status endpoint. You should see logs like:
Code
Webhook signature verified successfully.
--- Delivery Status Received ---
Message UUID: aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
Status: submitted
Timestamp: 2023-10-26T10:00:05.123Z
Full Payload: { ... }
------------------------------
Webhook signature verified successfully.
--- Delivery Status Received ---
Message UUID: aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
Status: delivered
Timestamp: 2023-10-26T10:00:07.456Z
Full Payload: { ... }
------------------------------
Common statuses include submitted, delivered, rejected, failed, accepted (intermediate carrier status), undeliverable.
Inspect with ngrok Web Interface: Open http://127.0.0.1:4040 in your browser. This interface shows all requests forwarded by ngrok, allowing you to inspect the exact headers (including Authorization) and payloads received from Vonage, which is invaluable for debugging.
5. Error handling and logging
Production applications require more robust error handling and logging.
Webhook Handler Reliability: Wrap the logic inside your webhook handlers (/webhooks/status, /webhooks/inbound) in try...catch blocks to prevent the server from crashing due to unexpected errors in processing the payload. Always ensure a 200 OK is sent back to Vonage unless there's a signature verification failure (which returns 401).
Logging: While console.log is used in this guide for simplicity, replace it with a structured logger like Winston or Pino in production. This allows for log levels (info, warn, error), formatting (JSON), and easier integration with log management systems.
Log critical information: message_uuid, status, timestamp, recipient/sender numbers, and any error codes/reasons.
Log errors during SMS sending (catch block in sendSms) and webhook processing.
Vonage Retry Mechanism: Remember that Vonage retries webhook delivery if it doesn't receive a 200 OK response within a short timeout (typically a few seconds). Ensure your processing is fast or happens asynchronously (e.g., push the payload to a queue) to avoid timeouts and duplicate processing from retries. Your endpoint must be idempotent if possible.
Detailed Send Errors: The catch block in sendSms should inspect the err.response.data object from the Vonage SDK for detailed error information provided by the API (e.g., invalid number format, insufficient funds).
JavaScript
// Example: Enhanced Status Webhook Handler (Conceptual)const pino = require('pino'); // Example using Pinoconst logger = pino({ level: process.env.LOG_LEVEL || 'info' });
app.post('/webhooks/status', verifyVonageSignature, async (req, res) => {
const statusData = req.body;
const logData = { // Structure your logsmessage_uuid: statusData.message_uuid,
status: statusData.status,
timestamp: statusData.timestamp,
error_code: statusData.error?.code,
error_reason: statusData.error?.reason,
webhook_type: 'status'
};
logger.info(logData, 'Processing status update for message');
try {
// --- Add your business logic here ---// Example: Update a database record based on status// await updateMessageStatusInDB(statusData.message_uuid, statusData.status, statusData.timestamp, statusData.error);if (statusData.error) {
logger.warn(logData, `Message status update indicates error`);
}
// ------------------------------------
res.status(200).send('OK'); // Acknowledge receipt *after* basic processing attempt
} catch (error) {
logger.error({ err: error, ...logData }, 'Error processing status webhook');
// Still send 200 OK if the error is in *your* processing logic,// unless you specifically want Vonage to retry (use with caution).// If the error indicates bad data you can't handle, 200 might still be appropriate// to prevent endless retries. Log the error thoroughly.// Consider sending 500 only for unexpected server errors preventing acknowledgement.
res.status(500).send('Internal Server Error during processing'); // Or potentially 200 OK depending on error strategy
}
});
6. Security considerations
Securing your application and webhooks is vital.
Webhook Signature Verification: Always verify the JWT signature using the VONAGE_SIGNATURE_SECRET and the raw request body, as implemented in the verifyVonageSignature middleware. This is the primary mechanism to ensure the request genuinely originated from Vonage and hasn't been tampered with.
API Credential Security:
Never commit your .env file or private.key to version control. Use .gitignore.
In production, use environment variables provided by your hosting platform or a dedicated secrets management service (like AWS Secrets Manager, HashiCorp Vault, etc.) instead of a .env file.
HTTPS: Always use HTTPS for your webhook URLs in production. ngrok provides HTTPS forwarding, and your deployment environment should also be configured for HTTPS.
Input Validation: Sanitize and validate any user-provided input, especially if exposing the /send-sms endpoint publicly. The E.164 check is a good start; also consider message lengths and potentially filter content.
Rate Limiting: If the /send-sms endpoint is exposed, implement rate limiting (e.g., using express-rate-limit) to prevent abuse.
7. Deployment
Deploying this application involves moving beyond ngrok.
Choose a Hosting Platform: Options include Heroku, AWS (EC2, Lambda, Elastic Beanstalk), Google Cloud (Cloud Run, App Engine), DigitalOcean, Vercel, Render, etc.
Environment Variables: Configure your production environment variables (VONAGE_API_KEY, VONAGE_API_SECRET, VONAGE_APPLICATION_ID, VONAGE_SIGNATURE_SECRET, VONAGE_NUMBER, PORT, VONAGE_PRIVATE_KEY_PATH) securely through your hosting provider's interface or secrets management. Ensure the private.key file is deployed securely to the location specified by VONAGE_PRIVATE_KEY_PATH.
Stable Public URL: Your deployed application will have a stable public URL (e.g., https://your-app-name.herokuapp.com). Update the webhook URLs in your Vonage Application settings to use this production URL (must be HTTPS).
Procfile/Dockerfile: Depending on the platform, you might need a Procfile (Heroku) or Dockerfile (container-based deployments) to define how to start your application (node index.js).
CI/CD: Set up a Continuous Integration/Continuous Deployment pipeline (e.g., GitHub Actions, GitLab CI, Jenkins) to automate testing and deployment.
8. Troubleshooting and Caveats
Webhook Not Received:
Check ngrok/server logs for errors (startup, request handling, signature verification).
Verify the Status URL in the Vonage Dashboard exactly matches your ngrok/production URL + /webhooks/status (case-sensitive, HTTPS in production).
Ensure your server is running and accessible from the internet (ngrok status should be ""online""; check production deployment status).
Check the Vonage Dashboard API Logs and Application event logs for errors reported by Vonage when trying to reach your webhook (e.g., 4xx/5xx responses, timeouts).
Firewall issues might block incoming requests to your server.
Invalid Signature (401 Unauthorized):
Double-check VONAGE_SIGNATURE_SECRET in your environment variables matches the secret in the Vonage Application settings exactly (no extra spaces, etc.).
Confirm that bodyParser.json({ verify: ... }) is correctly capturing the raw body (req.rawBody) before the verifyVonageSignature middleware runs. Use the ngrok web interface (http://127.0.0.1:4040) to inspect the Authorization header on incoming requests.
Ensure the system clocks on your server and Vonage's servers are reasonably synchronized (JWTs have expiration times).
SMS Not Sending:
Check Node.js logs for errors from the sendSms function. Look closely at err.response.data for specific Vonage error codes and descriptions.
Verify API Key/Secret/Application ID/Private Key path are correct in your environment variables.
Ensure your Vonage account has sufficient credit.
Check if the destination number (to) is valid and strictly in E.164 format (e.g., +14155550101).
Confirm the Vonage number (from) linked to the Application is SMS-capable for the destination country and correctly formatted in E.164.
Trial Account Limitations: New Vonage accounts might have restrictions (e.g., only sending to verified numbers listed in the dashboard) until topped up or fully verified.
Delayed Status Updates: Delivery receipts depend on downstream carriers; delays are possible. Not all carriers provide timely or reliable DLRs. submitted or accepted statuses usually appear quickly, but delivered or failed can take longer or sometimes not arrive at all for certain destinations/networks.
Payload Variations: While the general structure is consistent, webhook payloads might occasionally have minor variations or new fields added by Vonage. Rely on documented core fields like message_uuid, status, timestamp. Refer to the official Vonage Messages API documentation for the definitive schemas.
9. Verification checklist
Before considering the implementation complete, verify the following:
Vonage account created and API credentials noted.
Vonage Application created with Messages capability enabled.
Private key downloaded, stored securely, and path correctly set in environment variables.
Application ID and Signature Secret correctly set in environment variables.
Vonage SMS-capable number purchased, correctly formatted (E.164), and linked to the Application.
Status and Inbound webhook URLs correctly configured in Vonage Application settings (pointing to ngrok or production HTTPS URL).
.env file created (or environment variables set) with all necessary variables filled.
index.js contains Express server setup, Vonage SDK initialization, bodyParser with raw body capture, verifyVonageSignature middleware, and webhook handlers (/webhooks/status).
verifyVonageSignature middleware is applied to webhook routes and correctly uses the raw body for verification.
Webhook handlers log incoming data and return 200 OK upon successful processing acknowledgment.
sendSms function correctly uses vonage.messages.send and validates the to number format (strict E.164).
Application runs locally without errors (node index.js).
ngrok (or production deployment) correctly forwards requests to the Node.js application.
Sending a test SMS via the /send-sms endpoint (or other trigger) succeeds (check logs).
Test SMS is received on the target device.
Status webhook events (submitted, delivered, etc.) are received and logged by the /webhooks/status endpoint.
Frequently asked questions
Use Vonage's Messages API and webhooks. Set up a webhook endpoint in your application to receive real-time status updates like 'submitted,' 'delivered,' or 'failed' from Vonage after sending an SMS message via their API.
It's an API that lets you send and receive messages, including SMS, and track their delivery status. You integrate it into your application using the Vonage Node.js SDK or other language-specific libraries. It's more robust than 'fire and forget' methods.
Webhooks provide real-time delivery updates pushed directly to your application. This is more efficient than constantly polling the API and lets you react immediately to changes in message status, building more reliable systems.
Whenever your application needs to send SMS messages reliably and track their delivery status in real time. It's especially important for time-sensitive communications and two-factor authentication.
Use Express.js to create a server and the Vonage Node.js SDK to interact with the Messages API. You'll also need environment variables for your API credentials and webhook URLs. Ngrok helps during development.
A unique identifier for your application within the Vonage platform. It's required for using the Messages API. You create an application in the Vonage Dashboard and note its ID for configuration.
Ngrok creates a public, secure tunnel to your local development server, allowing Vonage to send webhooks to your machine during testing. This is necessary because local servers aren't publicly accessible on the internet.
It guarantees the integrity and authenticity of the webhook requests. By using a shared secret, you confirm requests genuinely originated from Vonage and haven't been tampered with. This is crucial for security.
Use the 'jsonwebtoken' library and the Vonage Signature Secret from your application dashboard. Importantly, use the raw request body, not the parsed JSON, when verifying the signature against the token from the 'Authorization' header.
Typical statuses include 'submitted' (sent to Vonage), 'delivered' (reached handset), 'failed,' 'rejected' (by the carrier), 'accepted' (intermediate carrier status), and 'undeliverable.' Not all statuses are guaranteed due to carrier limitations.
Wrap your webhook handler logic in 'try...catch' blocks to handle errors gracefully. Always acknowledge receipt with a 200 OK response, even if your internal processing fails, to prevent Vonage retries. Log errors thoroughly using a structured logger.
Check server and ngrok logs, verify the webhook URL in your Vonage application settings matches your server's URL, and check for firewall issues. Inspect the Vonage API logs for errors reported by Vonage while trying to reach your webhook.
Vonage uses the raw, unaltered request body to generate the JWT signature, not the parsed JSON. Therefore, you must capture the raw body using middleware like body-parserbefore any JSON parsing occurs to ensure accurate signature verification.
Use the + sign followed by the country code and phone number without any spaces or special characters. Example: +14155550101. Validate user-provided numbers strictly to avoid errors.
Tracking the delivery status of SMS messages is crucial for applications that rely on timely communication. Knowing whether a message reached the recipient's handset, was rejected by the carrier, or failed for other reasons enables developers to build more robust and reliable systems. This guide provides a step-by-step walkthrough for building a Node.js application using Express and the Vonage Messages API to send SMS messages and receive real-time delivery status updates via webhooks.
We will build a simple Node.js server that can send an SMS message and expose webhook endpoints to receive delivery status updates from Vonage. This solves the common problem of ""fire and forget"" SMS sending, providing visibility into the message lifecycle after it leaves the Vonage platform. We'll use the Vonage Node.js SDK for seamless API interaction and ngrok for local development testing.
System architecture
Here's a high-level overview of how the components interact:
submitted,delivered,failed) to the configured Status Webhook URL.(Note: A sequence diagram illustrating the component interactions was present here in the original document.)
Prerequisites
Before you begin, ensure you have the following:
1. Setting up the project
Let's create the project directory, initialize Node.js, and install the necessary dependencies.
Create Project Directory: Open your terminal or command prompt and run:
mkdir vonage-sms-status-guide cd vonage-sms-status-guideInitialize Node.js Project: This creates a
package.jsonfile.Install Dependencies: We need
expressfor the web server,@vonage/server-sdkto interact with the Vonage API,dotenvto manage environment variables, andjsonwebtokento verify webhook signatures. We also recommendbody-parserfor reliable webhook verification.# Note: Added body-parser for reliable signature verification npm install express @vonage/server-sdk dotenv jsonwebtoken body-parserCreate Project Files: Create the main application file and environment configuration files.
touch index.js .env .env.example .gitignoreConfigure
.gitignore: Add sensitive files and directories to.gitignoreto prevent committing them to version control.Explanation: We ignore
node_modules(can be reinstalled),.env(contains secrets),private.key(Vonage private key), and log files.Set up Environment Variables: Create a
.env.examplefile to list the required variables. This serves as a template.Now, create your actual
.envfile by copying.env.exampleand filling in your real credentials.cp .env.example .envExplanation:
VONAGE_API_KEY,VONAGE_API_SECRET: Found on the main page of your Vonage API Dashboard.VONAGE_APPLICATION_ID: Obtained after creating a Vonage Application (next step).VONAGE_PRIVATE_KEY_PATH: Path to the private key file downloaded when creating the application. We assume it's in the project root namedprivate.key.VONAGE_SIGNATURE_SECRET: Found in your Vonage Application settings under 'Webhook signature'. Used to verify incoming webhooks.VONAGE_NUMBER: Your purchased Vonage virtual number capable of sending SMS (must be in E.164 format, e.g.,+12015550123).PORT: The port your local server will run on.BASE_URL: The base URL for your webhook endpoints. We'll update this later with the ngrok URL.2. Configuring Vonage
You need a Vonage Application configured for the Messages API to handle sending and status updates.
private.keyfile that downloads. Place this file in your project root directory (matchingVONAGE_PRIVATE_KEY_PATHin.env). The public key is stored by Vonage..envfile forVONAGE_APPLICATION_ID.http://example.com/webhooks/inboundhttp://example.com/webhooks/status.envfile forVONAGE_SIGNATURE_SECRET.3. Setting up the webhook server (Express)
Now, let's write the Node.js code using Express to handle incoming webhooks.
// index.js require('dotenv').config(); // Load environment variables from .env file const express = require('express'); const bodyParser = require('body-parser'); // Import body-parser const jwt = require('jsonwebtoken'); const { Vonage } = require('@vonage/server-sdk'); const app = express(); // --- Middleware --- // Capture raw body *before* JSON parsing for reliable signature verification app.use(bodyParser.json({ verify: (req, res, buf) => { // Save the raw buffer onto the request object req.rawBody = buf; } })); // Use express.urlencoded({ extended: true }) for URL-encoded data app.use(express.urlencoded({ extended: true })); // --- Vonage Initialization --- // Check for essential environment variables if (!process.env.VONAGE_APPLICATION_ID || !process.env.VONAGE_PRIVATE_KEY_PATH || !process.env.VONAGE_SIGNATURE_SECRET) { console.error('Error: VONAGE_APPLICATION_ID, VONAGE_PRIVATE_KEY_PATH, or VONAGE_SIGNATURE_SECRET not set in .env'); process.exit(1); } const vonage = new Vonage({ apiKey: process.env.VONAGE_API_KEY, // Optional for Messages API JWT auth, but good practice apiSecret: process.env.VONAGE_API_SECRET, // Optional for Messages API JWT auth applicationId: process.env.VONAGE_APPLICATION_ID, privateKey: process.env.VONAGE_PRIVATE_KEY_PATH, }, { debug: true // Enable debug logging for the SDK }); // --- Webhook Security Middleware --- // Verifies the JWT signature on incoming Vonage webhooks using the raw body const verifyVonageSignature = (req, res, next) => { try { const authHeader = req.headers.authorization; const token = authHeader?.split(' ')[1]; // Extract Bearer token if (!token) { console.warn('Webhook received without Authorization header or token.'); return res.status(401).send('Unauthorized: Missing token'); } // Verify the token using the raw body captured by bodyParser's verify function // Vonage signs the raw request body, not the parsed JSON object. if (!req.rawBody) { console.error('Raw body buffer not available for verification. Ensure bodyParser.json() with verify is used before this middleware.'); return res.status(500).send('Internal Server Error: Cannot verify signature'); } // jwt.verify throws if the signature is invalid const decoded = jwt.verify(token, process.env.VONAGE_SIGNATURE_SECRET, { algorithms: ['HS256'], // IMPORTANT: Provide the raw body buffer for verification // This assumes the payload is the raw body itself for JWT verification context // Check Vonage docs if payload structure for signing differs }); // Note: jwt.verify doesn't directly take the raw body for payload check, // it verifies the signature against the token structure. The key is using the // correct secret. Vonage includes payload claims *within* the signed JWT. // Optional: Check payload application_uuid matches your app ID // if (decoded.application_uuid !== process.env.VONAGE_APPLICATION_ID) { // console.warn(`Webhook received for unexpected application ID: ${decoded.application_uuid}`); // return res.status(401).send('Unauthorized: Invalid application ID'); // } console.log('Webhook signature verified successfully.'); req.vonage_payload = decoded; // Attach decoded JWT payload if needed later next(); // Signature is valid, proceed to the handler } catch (error) { console.error('Error verifying webhook signature:', error.message); if (error instanceof jwt.TokenExpiredError) { return res.status(401).send('Unauthorized: Token expired'); } if (error instanceof jwt.JsonWebTokenError) { return res.status(401).send('Unauthorized: Invalid signature'); } // Log other unexpected errors console.error('Unexpected error during signature verification:', error); return res.status(500).send('Internal Server Error'); } }; // --- Webhook Endpoints --- // Status Webhook: Receives delivery status updates // Apply the verification middleware *only* to Vonage webhooks app.post('/webhooks/status', verifyVonageSignature, (req, res) => { // The body is already parsed by bodyParser.json() const statusData = req.body; console.log('--- Delivery Status Received ---'); console.log('Message UUID:', statusData.message_uuid); console.log('Status:', statusData.status); console.log('Timestamp:', statusData.timestamp); if (statusData.error) { console.error('Error Code:', statusData.error.code); console.error('Error Reason:', statusData.error.reason); } console.log('Full Payload:', JSON.stringify(statusData, null, 2)); console.log('------------------------------'); // Vonage expects a 200 OK response to acknowledge receipt // Failure to send 200 OK will cause Vonage to retry the webhook res.status(200).send('OK'); }); // Inbound Webhook: Receives incoming SMS messages (optional for this guide) // Apply the verification middleware here too for security app.post('/webhooks/inbound', verifyVonageSignature, (req, res) => { const inboundData = req.body; console.log('--- Inbound SMS Received ---'); console.log('From:', inboundData.from?.number || 'Unknown'); console.log('To:', inboundData.to?.number || 'Unknown'); console.log('Text:', inboundData.message?.content?.text || 'N/A'); console.log('Full Payload:', JSON.stringify(inboundData, null, 2)); console.log('--------------------------'); res.status(200).send('OK'); }); // --- SMS Sending Function --- async function sendSms(toNumber, text) { console.log(`Attempting to send SMS to ${toNumber}`); const fromNumber = process.env.VONAGE_NUMBER; if (!fromNumber) { console.error('Error: VONAGE_NUMBER not set in .env'); return; } // Validate recipient number format (strict E.164 check) if (!/^\+\d{10,15}$/.test(toNumber)) { console.error(`Error: Invalid recipient number format: ${toNumber}. Must use E.164 format (e.g., +12015550123).`); // Optionally throw an error or return a failure indicator return; } try { const resp = await vonage.messages.send({ message_type: ""text"", to: toNumber, // E.164 format (e.g., +14155550100) from: fromNumber, // Your Vonage number channel: ""sms"", text: text, // client_ref: 'my-internal-tracking-id-123' // Optional client reference }); console.log('SMS Submitted Successfully!'); console.log('Message UUID:', resp.messageUuid); } catch (err) { console.error('Error sending SMS:', err?.response?.data || err.message); // Log detailed error if available from Vonage response if (err?.response?.data) { console.error('Vonage Error Details:', JSON.stringify(err.response.data, null, 2)); } } } // --- Example Send Endpoint (for testing) --- app.post('/send-sms', (req, res) => { const { to, text } = req.body; if (!to || !text) { return res.status(400).send('Missing ""to"" or ""text"" in request body.'); } // Strict E.164 format check if (!/^\+\d{10,15}$/.test(to)) { return res.status(400).send('Invalid ""to"" number format. Use E.164 (e.g., +12015550123).'); } sendSms(to, text); // Fire and forget for this example res.status(202).send('SMS send request accepted.'); }); // --- Start Server --- const port = process.env.PORT || 3000; app.listen(port, () => { console.log(`Server listening at http://localhost:${port}`); console.log(`Webhook URLs should be configured in Vonage based on your BASE_URL:`); console.log(`Status URL: ${process.env.BASE_URL || `http://localhost:${port}`}/webhooks/status`); console.log(`Inbound URL: ${process.env.BASE_URL || `http://localhost:${port}`}/webhooks/inbound`); // Example: Send a test SMS on startup (remove in production) // Make sure to add a valid test number in E.164 format // const TEST_RECIPIENT = '+14155550101'; // Replace with your test phone number // if (process.env.NODE_ENV !== 'production' && TEST_RECIPIENT) { // console.log(`Sending initial test SMS to ${TEST_RECIPIENT}...`); // setTimeout(() => sendSms(TEST_RECIPIENT, 'Hello from Vonage Node Guide! Startup Test - ' + new Date().toLocaleTimeString()), 2000); // } });Explanation:
.envvariables..env. Debug mode is enabled for detailed SDK logs.body-parsermiddleware with averifyfunction is used to capture the raw request body before JSON parsing. TheverifyVonageSignaturemiddleware then usesjsonwebtokenand yourVONAGE_SIGNATURE_SECRETto verify the JWT signature from theAuthorization: Bearer <token>header. This ensures the request genuinely originated from Vonage. This raw body approach is crucial for reliable production verification./webhooks/status: This route handles POST requests from Vonage containing delivery status updates. It logs the received data and sends a200 OKresponse. This acknowledgment is vital; otherwise, Vonage will retry sending the webhook./webhooks/inbound: Handles incoming SMS messages (optional).sendSmsfunction: Encapsulates the logic for sending an SMS usingvonage.messages.send(). It takes the recipient number (validated for strict E.164 format) and text as arguments. It logs themessageUuidon successful submission./send-smsendpoint: A simple POST endpoint to trigger thesendSmsfunction for testing purposes via cURL or Postman. Includes strict E.164 validation.PORT.4. Running and testing locally
To receive webhooks from Vonage on your local machine, you need ngrok.
Start ngrok: Open a new terminal window and run ngrok, pointing it to the port your Node.js app is running on (default is 3000).
ngrok will display output similar to this:
Copy the
https://<random-id>.ngrok-free.appURL. This is your public base URL.Update
.env: Open your.envfile and set theBASE_URLto your ngrok Forwarding URL:Update Vonage Application Webhooks: Go back to your application settings in the Vonage Dashboard. Update the Messages capability webhook URLs using your ngrok
BASE_URL:https://<random-id>.ngrok-free.app/webhooks/inboundhttps://<random-id>.ngrok-free.app/webhooks/statusRun the Node.js Application: In your original terminal window (where your project code is), start the server:
You should see output indicating the server is running and listening.
Test Sending SMS: Open another terminal or use a tool like Postman to send a POST request to your local
/send-smsendpoint. Replace<your_test_phone>with your actual mobile number in E.164 format (e.g.,+14155550101).curl -X POST http://localhost:3000/send-sms \ -H ""Content-Type: application/json"" \ -d '{ ""to"": ""+14155550101"", ""text"": ""Testing Vonage Status Callbacks! Time: '\''$(date)'\''"" }'You should see:
messageUuid.Observe Status Webhooks: Watch your Node.js application terminal. As the message progresses through the delivery lifecycle, Vonage will send POST requests to your
/webhooks/statusendpoint. You should see logs like:Common statuses include
submitted,delivered,rejected,failed,accepted(intermediate carrier status),undeliverable.Inspect with ngrok Web Interface: Open
http://127.0.0.1:4040in your browser. This interface shows all requests forwarded by ngrok, allowing you to inspect the exact headers (includingAuthorization) and payloads received from Vonage, which is invaluable for debugging.5. Error handling and logging
Production applications require more robust error handling and logging.
/webhooks/status,/webhooks/inbound) intry...catchblocks to prevent the server from crashing due to unexpected errors in processing the payload. Always ensure a200 OKis sent back to Vonage unless there's a signature verification failure (which returns401).console.logis used in this guide for simplicity, replace it with a structured logger like Winston or Pino in production. This allows for log levels (info, warn, error), formatting (JSON), and easier integration with log management systems.message_uuid,status,timestamp, recipient/sender numbers, and any error codes/reasons.catchblock insendSms) and webhook processing.200 OKresponse within a short timeout (typically a few seconds). Ensure your processing is fast or happens asynchronously (e.g., push the payload to a queue) to avoid timeouts and duplicate processing from retries. Your endpoint must be idempotent if possible.catchblock insendSmsshould inspect theerr.response.dataobject from the Vonage SDK for detailed error information provided by the API (e.g., invalid number format, insufficient funds).// Example: Enhanced Status Webhook Handler (Conceptual) const pino = require('pino'); // Example using Pino const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); app.post('/webhooks/status', verifyVonageSignature, async (req, res) => { const statusData = req.body; const logData = { // Structure your logs message_uuid: statusData.message_uuid, status: statusData.status, timestamp: statusData.timestamp, error_code: statusData.error?.code, error_reason: statusData.error?.reason, webhook_type: 'status' }; logger.info(logData, 'Processing status update for message'); try { // --- Add your business logic here --- // Example: Update a database record based on status // await updateMessageStatusInDB(statusData.message_uuid, statusData.status, statusData.timestamp, statusData.error); if (statusData.error) { logger.warn(logData, `Message status update indicates error`); } // ------------------------------------ res.status(200).send('OK'); // Acknowledge receipt *after* basic processing attempt } catch (error) { logger.error({ err: error, ...logData }, 'Error processing status webhook'); // Still send 200 OK if the error is in *your* processing logic, // unless you specifically want Vonage to retry (use with caution). // If the error indicates bad data you can't handle, 200 might still be appropriate // to prevent endless retries. Log the error thoroughly. // Consider sending 500 only for unexpected server errors preventing acknowledgement. res.status(500).send('Internal Server Error during processing'); // Or potentially 200 OK depending on error strategy } });6. Security considerations
Securing your application and webhooks is vital.
VONAGE_SIGNATURE_SECRETand the raw request body, as implemented in theverifyVonageSignaturemiddleware. This is the primary mechanism to ensure the request genuinely originated from Vonage and hasn't been tampered with..envfile orprivate.keyto version control. Use.gitignore..envfile./send-smsendpoint publicly. The E.164 check is a good start; also consider message lengths and potentially filter content./send-smsendpoint is exposed, implement rate limiting (e.g., usingexpress-rate-limit) to prevent abuse.7. Deployment
Deploying this application involves moving beyond ngrok.
VONAGE_API_KEY,VONAGE_API_SECRET,VONAGE_APPLICATION_ID,VONAGE_SIGNATURE_SECRET,VONAGE_NUMBER,PORT,VONAGE_PRIVATE_KEY_PATH) securely through your hosting provider's interface or secrets management. Ensure theprivate.keyfile is deployed securely to the location specified byVONAGE_PRIVATE_KEY_PATH.https://your-app-name.herokuapp.com). Update the webhook URLs in your Vonage Application settings to use this production URL (must be HTTPS).Procfile(Heroku) orDockerfile(container-based deployments) to define how to start your application (node index.js).8. Troubleshooting and Caveats
/webhooks/status(case-sensitive, HTTPS in production).VONAGE_SIGNATURE_SECRETin your environment variables matches the secret in the Vonage Application settings exactly (no extra spaces, etc.).bodyParser.json({ verify: ... })is correctly capturing the raw body (req.rawBody) before theverifyVonageSignaturemiddleware runs. Use the ngrok web interface (http://127.0.0.1:4040) to inspect theAuthorizationheader on incoming requests.sendSmsfunction. Look closely aterr.response.datafor specific Vonage error codes and descriptions.to) is valid and strictly in E.164 format (e.g.,+14155550101).from) linked to the Application is SMS-capable for the destination country and correctly formatted in E.164.submittedoracceptedstatuses usually appear quickly, butdeliveredorfailedcan take longer or sometimes not arrive at all for certain destinations/networks.message_uuid,status,timestamp. Refer to the official Vonage Messages API documentation for the definitive schemas.9. Verification checklist
Before considering the implementation complete, verify the following:
express,@vonage/server-sdk,dotenv,jsonwebtoken,body-parser)..envfile created (or environment variables set) with all necessary variables filled.index.jscontains Express server setup, Vonage SDK initialization,bodyParserwith raw body capture,verifyVonageSignaturemiddleware, and webhook handlers (/webhooks/status).verifyVonageSignaturemiddleware is applied to webhook routes and correctly uses the raw body for verification.200 OKupon successful processing acknowledgment.sendSmsfunction correctly usesvonage.messages.sendand validates thetonumber format (strict E.164).node index.js)./send-smsendpoint (or other trigger) succeeds (check logs).submitted,delivered, etc.) are received and logged by the/webhooks/statusendpoint.