Build Production WhatsApp & SMS Integration with Vonage, Node.js & Express
Complete guide to building two-way WhatsApp and SMS messaging with Vonage Messages API, Node.js, Express, and PostgreSQL. Includes webhook security, database integration, and deployment.
Build a production-ready Node.js application using Express to send and receive both WhatsApp and SMS messages via the Vonage Messages API. This complete guide covers project setup, core messaging logic, database integration, webhook security with JWT verification, error handling, and deployment strategies.
What You'll Build:
Send SMS and WhatsApp messages programmatically through Vonage Messages API
Receive incoming SMS and WhatsApp messages via secure webhooks
Store message history in PostgreSQL with Prisma ORM
Verify incoming webhook requests using JWT signatures (HMAC-SHA256)
Handle errors gracefully with comprehensive logging
Target Audience: Developers familiar with Node.js and basic web concepts implementing reliable two-way messaging capabilities.
By the end of this tutorial, you will have a robust application capable of:
Sending SMS and WhatsApp messages programmatically.
Receiving incoming SMS and WhatsApp messages via webhooks.
Storing message history in a database.
Verifying incoming webhook requests for security.
Handling errors gracefully.
Technologies Used:
Node.js: JavaScript runtime environment. Minimum required: Node.js 16.13.0+ (as of October 2025, recommended: Node.js 18+ LTS for production use).
Express: Minimalist web framework for Node.js. Version 4.x stable.
Vonage Messages API: Unified API for sending and receiving messages across multiple channels (SMS, WhatsApp, MMS, etc.).
Vonage Node SDK (@vonage/server-sdk): Simplifies interaction with Vonage APIs. Current version: 3.24.1 (October 2025). Version 3.x uses TypeScript, Promises (no callbacks), and requires async/await or .then/.catch patterns. Official Documentation
PostgreSQL: Robust open-source relational database. Version 9.6+ supported by Prisma.
Prisma: Modern ORM for Node.js and TypeScript. Version 5.x (minimum Node.js 16.13.0, TypeScript 4.7+). System Requirements
ngrok: Tool to expose local servers to the internet for webhook testing.
dotenv: Module to load environment variables from a .env file. Version 16.x.
Important Version Compatibility Notes (October 2025):
Node.js 16.x reached end-of-life on September 11, 2023. Upgrade to Node.js 18+ LTS for production.
Vonage SDK v3.x requires Promise-based code patterns. Version 2.x (callback-based) is deprecated.
Prisma 5.x is the last major version to support Node.js 16. Prisma 6+ requires Node.js 18+.
System Architecture
The basic flow of information is as follows:
Outbound Messages: Your Node.js/Express application uses the Vonage Node SDK to send an API request to the Vonage Messages API, specifying the channel (SMS or WhatsApp), recipient, sender ID/number, and message content. Vonage handles the delivery to the end user.
Inbound Messages: A user sends an SMS or WhatsApp message to your Vonage provisioned number/WhatsApp sender. Vonage receives the message and sends an HTTP POST request (webhook) to a predefined endpoint in your Express application (/webhooks/inbound).
Message Status Updates: Vonage sends status updates (e.g., delivered, read, failed) about outbound messages to another predefined webhook endpoint in your application (/webhooks/status).
Database Interaction: Your application interacts with a PostgreSQL database (via Prisma) to store records of inbound and outbound messages, along with their statuses.
Text
[ User ] <---- SMS/WhatsApp ----> [ Vonage Platform ] <---- API Calls/Webhooks ----> [ Your Node.js/Express App ] <---- Prisma ----> [ PostgreSQL DB ]
^ |
|--- ( ngrok tunnel during dev ) ----|
Prerequisites
Vonage API Account: Sign up for free at Vonage. You'll get free credit to start.
Node.js: Version 16.13.0 or higher required (18+ LTS recommended for production). Install from nodejs.org.
npm or yarn: Package manager for Node.js (comes with Node.js).
ngrok: Install from ngrok.com. A free account is sufficient for this guide.
PostgreSQL Database: A running instance accessible to your application. You will need to set this up yourself, common methods include running it locally, using Docker, or utilizing a cloud database provider.
Vonage CLI (Optional but Recommended): Install via npm: npm install -g @vonage/cli. Useful for managing applications and numbers.
Initialize Prisma in your project. This creates a prisma directory with a schema.prisma file and a .env file (if one doesn't exist).
Bash
npx prisma init --datasource-provider postgresql
1.4 Configure Environment Variables (.env):
Prisma creates a basic .env file. Open it and add the necessary variables for Vonage and your database. Crucially, obtain these values as described below the code block.
.env
# .env
# Database Connection (Adjust based on your PostgreSQL setup)
# Example: postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public
DATABASE_URL="postgresql://your_db_user:your_db_password@localhost:5432/vonage_messages?schema=public"
# Vonage API Credentials
VONAGE_API_KEY="YOUR_VONAGE_API_KEY"
VONAGE_API_SECRET="YOUR_VONAGE_API_SECRET"
# Vonage Application Credentials
VONAGE_APPLICATION_ID="YOUR_VONAGE_APPLICATION_ID"
# Path relative to your project root where you save the private key
VONAGE_PRIVATE_KEY_PATH="./private.key"
# Vonage Numbers / Senders
# Your Vonage virtual number capable of sending SMS
VONAGE_SMS_FROM_NUMBER="YOUR_VONAGE_SMS_NUMBER"
# Your Vonage WhatsApp Sender ID (often the same as SMS number or a dedicated one)
VONAGE_WHATSAPP_FROM_NUMBER="YOUR_VONAGE_WHATSAPP_NUMBER" # Use the sandbox number initially (e.g., 14157386102)
# Vonage Webhook Security
# Generate a secure random string for this
VONAGE_SIGNATURE_SECRET="YOUR_WEBHOOK_SIGNATURE_SECRET"
# Server Configuration
PORT=3000 # Or any port you prefer
How to Obtain Environment Variable Values:
DATABASE_URL: Construct this based on your PostgreSQL setup (username, password, host, port, database name).
VONAGE_API_KEY, VONAGE_API_SECRET: Found at the top of your Vonage API Dashboard after logging in.
VONAGE_APPLICATION_ID, VONAGE_PRIVATE_KEY_PATH: You will generate these when creating a Vonage Application (Section 4). The path should point to where you save the downloaded private.key file within your project.
VONAGE_SMS_FROM_NUMBER: A virtual number you purchase or rent from Vonage (Section 4). Must be SMS-capable.
VONAGE_WHATSAPP_FROM_NUMBER: For initial testing, use the number provided by the Vonage Messages API Sandbox (Section 4). For production, this will be your approved WhatsApp Business number linked to Vonage.
VONAGE_SIGNATURE_SECRET: Critical for webhook security. Go to your Vonage Dashboard Settings. Under "API settings", find the "Signature secret" for your API key. This is used to verify JWT signatures in webhook requests. If none exists, you might need to generate one. Webhook Security (October 2025): Vonage uses JWT (JSON Web Token) Bearer Authorization with HMAC-SHA256 signatures in the Vonage-Signature or Authorization header. The signature expires 5 minutes after issuance. Webhook Security Guide
PORT: The local port your Express server will listen on.
Webhook Security Requirements (October 2025):
JWT Verification: All webhook requests include a JWT signature that must be verified
Signature Location: Check Vonage-Signature or Authorization header (format: "Bearer ")
Algorithm: HMAC-SHA256
Expiration: Signatures expire 5 minutes after issuance
Payload Hash: Optionally verify payload integrity by comparing SHA-256 hash to payload_hash claim in JWT
Production Requirement: Always verify signatures in production to prevent unauthorized webhook submissions
1.5 Configure .gitignore:
Ensure sensitive files and generated files are not committed to version control. Create a .gitignore file:
Modify the scripts section in your package.json for easier development and running:
JSON
{"name":"vonage-messaging-app","version":"1.0.0","description":"","main":"src/server.js","scripts":{"start":"node src/server.js","dev":"nodemon src/server.js","db:migrate":"npx prisma migrate dev","db:generate":"npx prisma generate","test":"echo \"Error: no test specified\" && exit 1"},"keywords":[],"author":"","license":"ISC","dependencies":{"@prisma/client":"^5.x.x","@vonage/server-sdk":"^3.x.x","dotenv":"^16.x.x","express":"^4.x.x","pg":"^8.x.x"},"devDependencies":{"nodemon":"^3.x.x","prisma":"^5.x.x"}}
(Note: Replace x.x.x placeholders in package.json with actual installed versions if needed, or remove the version numbers entirely if managing via package-lock.json)
Now you can run npm run dev to start the server with auto-reloading via nodemon, and npm run db:migrate to apply database schema changes.
// src/services/vonage.service.jsrequire('dotenv').config(); // Ensure env vars are loadedconst { Vonage } = require('@vonage/server-sdk');
const { Message } = require('@vonage/server-sdk/dist/messages/message'); // Adjusted path if neededconst logger = require('../utils/logger'); // Import the loggerconst prisma = require('../db');
// Initialize Vonage client with v3.x syntax (Promise-based)const vonage = newVonage({
apiKey: process.env.VONAGE_API_KEY,
apiSecret: process.env.VONAGE_API_SECRET,
applicationId: process.env.VONAGE_APPLICATION_ID,
privateKey: process.env.VONAGE_PRIVATE_KEY_PATH,
});
/**
* Sends a message via the Vonage Messages API.
* @param {string} to - Recipient phone number (E.164 format).
* @param {string} text - Message content.
* @param {'sms' | 'whatsapp'} channel - Messaging channel.
* @returns {Promise<object>} - The Vonage API response.
*/asyncfunctionsendMessage(to, text, channel) {
constfrom = channel === 'sms'
? process.env.VONAGE_SMS_FROM_NUMBER
: process.env.VONAGE_WHATSAPP_FROM_NUMBER;
if (!from) {
logger.error(`Vonage 'from' number/ID not configured for channel: ${channel}`);
thrownewError(`Sender ID not configured for ${channel}`);
}
logger.info(`Attempting to send ${channel} message from ${from} to ${to}`);
try {
// Use the Message builder for flexibilityconst message = newMessage(
{ type: 'text', text: text }, // Content
{ number: to }, // To
{ type: channel, number: from } // From
);
const response = await vonage.messages.send(message);
logger.info('Message sent successfully:', response);
// Store outbound message record (async, don't block response)
prisma.message.create({
data: {
vonageMessageId: response.message_uuid,
direction: 'outbound',
channel: channel,
to: to,
from: from,
text: text,
status: 'submitted', // Initial status from Vonagetimestamp: newDate(), // Use current time as submission time
}
}).catch(dbError => logger.error('Failed to save outbound message to DB:', dbError));
return response; // Contains message_uuid
} catch (error) {
const errorData = error?.response?.data || { message: error.message };
logger.error('Error sending Vonage message:', errorData);
// Attempt to store failed submission
prisma.message.create({
data: {
vonageMessageId: `failed_${Date.now()}`, // Placeholder IDdirection: 'outbound',
channel: channel,
to: to,
from: from,
text: text,
status: 'failed_submission',
timestamp: newDate(),
errorDetails: JSON.stringify(errorData),
}
}).catch(dbError => logger.error('Failed to save FAILED outbound message to DB:', dbError));
throw error; // Re-throw for the controller to handle
}
}
module.exports = {
sendMessage,
vonage // Export instance if needed elsewhere (e.g., for signature verification)
};
2.4 Basic Express Server Setup:
Create the main server file src/server.js.
JavaScript
// src/server.jsrequire('dotenv').config();
const express = require('express');
const logger = require('./utils/logger');
const apiRoutes = require('./routes/api.routes');
const webhookRoutes = require('./routes/webhooks.routes');
const prisma = require('./db'); // Import to ensure connection pool is ready (optional)const app = express();
constPORT = process.env.PORT || 3000;
// --- IMPORTANT: Raw Body Middleware for Webhook Verification ---// This MUST come BEFORE the webhook routes are defined and BEFORE express.json()// if you want the default JSON parser to run afterwards on those routes.// However, it's safer to put it just before the webhook router is used.// We will configure it more precisely in section 7.1.// For now, let's use the standard JSON parser first.// Standard Middleware// Note: We will modify express.json() later in Section 7.1 for webhook signature verification
app.use(express.json()); // Parse JSON bodies
app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies// Simple Request Logger Middleware
app.use((req, res, next) => {
logger.info(`${req.method}${req.url}`);
next();
});
// Routes
app.use('/api', apiRoutes);
app.use('/webhooks', webhookRoutes); // Webhook routes will include specific body parsing/verification// Basic Root Route
app.get('/', (req, res) => {
res.send('Vonage Messaging App is running!');
});
// Global Error Handler (Basic)
app.use((err, req, res, next) => {
logger.error('Unhandled Error:', err);
res.status(500).json({ error: 'Internal Server Error' });
});
// Start Server and store the server instanceconst server = app.listen(PORT, () => {
logger.log(`Server listening on port ${PORT}`);
logger.log(`Local URL: http://localhost:${PORT}`);
// Remind about ngrok for webhooks
logger.warn('Remember to run ngrok and update Vonage webhook URLs if testing locally!');
logger.warn(`Example ngrok command: ngrok http ${PORT}`);
});
// Graceful Shutdown (Optional but Recommended)
process.on('SIGTERM', async () => {
logger.info('SIGTERM signal received. Closing HTTP server.');
// Close the server (requires the 'server' variable assigned above)
server.close(async () => {
logger.info('HTTP server closed.');
// Disconnect Prisma Clienttry {
await prisma.$disconnect();
logger.info('Prisma Client disconnected.');
} catch (e) {
logger.error('Error disconnecting Prisma:', e);
} finally {
process.exit(0);
}
});
});
3. Building the API Layer (Sending Messages)
Let's create an API endpoint to trigger sending messages.
3.1 Create Message Controller:
Handles the logic for the send message route.
JavaScript
// src/controllers/message.controller.jsconst vonageService = require('../services/vonage.service');
const logger = require('../utils/logger');
asyncfunctionhandleSendMessage(req, res) {
const { to, text, channel } = req.body;
// Basic Validationif (!to || !text || !channel) {
return res.status(400).json({ error: 'Missing required fields: to, text, channel' });
}
if (channel !== 'sms' && channel !== 'whatsapp') {
return res.status(400).json({ error: 'Invalid channel. Must be "sms" or "whatsapp".' });
}
// Decide whether to reject or attempt anyway based on format// Stricter E.164 format check (requires '+', country code, and subscriber number)if (!/^\+\d{10,15}$/.test(to)) {
logger.error(`Invalid 'to' number format received: ${to}`);
return res.status(400).json({ error: 'Invalid "to" number format. Use E.164 standard (e.g., "+15551234567").' });
}
try {
const response = await vonageService.sendMessage(to, text, channel);
res.status(202).json({ // 202 Accepted - request initiatedmessage: `Message sending initiated via ${channel}.`,
message_uuid: response.message_uuid
});
} catch (error) {
logger.error(`Failed to send message via API: ${error.message}`);
// Determine appropriate status code based on error type if possibleconst statusCode = error.response?.status || 500;
const errorDetails = error.response?.data?.title || error.message;
res.status(statusCode).json({
error: 'Failed to send message',
details: errorDetails
});
}
}
module.exports = {
handleSendMessage,
};
Once the server is running (npm run dev), you can test this endpoint using curl or Postman.
Curl Example (SMS):
Bash
curl -X POST http://localhost:3000/api/send-message \
-H "Content-Type: application/json" \
-d '{
"to": "+15551234567",
"text": "Hello from Node.js via Vonage SMS!",
"channel": "sms"
}'
Curl Example (WhatsApp):
Remember: WhatsApp sending via Sandbox requires the recipient to have first messaged the Sandbox number.
Bash
curl -X POST http://localhost:3000/api/send-message \
-H "Content-Type: application/json" \
-d '{
"to": "+15551234567",
"text": "Hello from Node.js via Vonage WhatsApp Sandbox!",
"channel": "whatsapp"
}'
Expected JSON Response (Success):
JSON
{"message":"Message sending initiated via sms.","message_uuid":"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"}
Expected JSON Response (Validation Error):
JSON
{"error":"Invalid \"to\" number format. Use E.164 standard (e.g., \"+15551234567\")."}
Expected JSON Response (Vonage API Error):
JSON
{"error":"Failed to send message","details":"Authentication failure"}
4. Integrating with Vonage (Dashboard & ngrok Setup)
This section details the crucial steps within the Vonage Dashboard and using ngrok.
4.1 Start ngrok:
Before configuring Vonage webhooks, you need a publicly accessible URL for your local server.
Open a new terminal window in your project directory and run:
Bash
# Replace 3000 if you used a different PORT in .env
ngrok http 3000
ngrok will output something like 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://xxxxxxxxxxxx.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://xxxxxxxxxxxx.ngrok-free.app URL. This is your public base URL. Keep this terminal running.
4.2 Create a Vonage Application:
Vonage Applications act as containers for your configurations (like webhook URLs) and link your virtual numbers.
Navigate to "Applications" -> "Create a new application".
Name: Give it a descriptive name (e.g., "Node Express Messenger").
Generate Public/Private Key: Click "Generate public and private key". Immediately save the private.key file that downloads into the root of your project directory (or wherever VONAGE_PRIVATE_KEY_PATH points). Vonage does not store this key, so save it securely.
Capabilities:
Toggle Messages ON.
Inbound URL: Paste your ngrok Forwarding URL and append /webhooks/inbound. Example: https://xxxxxxxxxxxx.ngrok-free.app/webhooks/inbound
Status URL: Paste your ngrok Forwarding URL and append /webhooks/status. Example: https://xxxxxxxxxxxx.ngrok-free.app/webhooks/status
(Optional) Enable other capabilities like Voice if needed later.
Click "Create application".
On the next page, you'll see the Application ID. Copy this value and paste it into your .env file for VONAGE_APPLICATION_ID.
4.3 Link a Virtual Number (for SMS):
You need a Vonage number to send/receive SMS messages.
If you don't have one, go to "Numbers" -> "Buy numbers". Search for a number with SMS capability in your desired country and purchase it.
Go back to "Applications", find the application you just created, and click its name.
Scroll down to the "Link numbers" section.
Find your purchased number and click the "Link" button next to it.
Copy this phone number (in E.164 format, e.g., 12015550123) and paste it into your .env file for VONAGE_SMS_FROM_NUMBER.
4.4 Set Up Messages API Sandbox (for WhatsApp Testing):
The Sandbox provides a shared number for testing WhatsApp integration without needing your own approved WhatsApp Business number initially.
In the Vonage Dashboard, navigate to "Developer Tools" -> "Messages API Sandbox".
Whitelist your number: Follow the instructions to send a specific WhatsApp message from your personal phone number to the provided Sandbox number (e.g., +14157386102). This allows the Sandbox to send messages to you.
Configure Webhooks: In the Sandbox page, find the "Webhooks" section.
Inbound URL: Enter your ngrok URL + /webhooks/inbound (e.g., https://xxxxxxxxxxxx.ngrok-free.app/webhooks/inbound).
Status URL: Enter your ngrok URL + /webhooks/status (e.g., https://xxxxxxxxxxxx.ngrok-free.app/webhooks/status).
Click "Save webhooks".
Sandbox Number: Note the Sandbox WhatsApp number (e.g., 14157386102). Use this value in your .env file for VONAGE_WHATSAPP_FROM_NUMBER during testing.
4.5 Ensure Correct Vonage API Settings:
Vonage has older APIs (like the SMS API). Ensure your account is set to use the Messages API as the default for SMS to receive webhooks in the correct format expected by this guide.
Under "Default SMS Setting", ensure "Messages API" is selected. If not, change it and click "Save changes".
5. Implementing Error Handling, Logging, and Retry Mechanisms
Robust applications need proper error handling and logging.
5.1 Consistent Error Handling Strategy:
Service Layer: Catch specific Vonage API errors in vonage.service.js. Log detailed errors. Re-throw generic or transformed errors for controllers.
Controller Layer: Catch errors from the service layer. Log context-specific errors. Send appropriate HTTP status codes (4xx for client errors, 5xx for server errors) and JSON error responses to the client.
Webhook Handlers: Use try...catch extensively. Always respond with 200 OK quickly to Vonage, even if processing fails internally (log the failure). Vonage retries webhooks if it doesn't receive a 2xx response, which can cause duplicate processing.
Global Error Handler: Use the Express global error handler (app.use((err, req, res, next) => {...})) in server.js as a final catch-all for unexpected errors.
5.2 Logging:
The simple logger.js provided is basic. For production, consider a more structured logging library like Winston or Pino.
Log Levels: Use different levels (INFO, WARN, ERROR, DEBUG) appropriately.
Structured Logging: Log objects (JSON format) instead of just strings. Include timestamps, request IDs (if using), error details, and relevant context. This makes logs easier to parse and analyze.
Log Destinations: Configure logging to output to console during development and to files or a log aggregation service (like Datadog, Logstash, Splunk, Sentry) in production.
Example using basic logger (already integrated):
JavaScript
// In vonage.service.js (already added)
logger.info('Message sent successfully:', response);
logger.error('Error sending Vonage message:', error?.response?.data || error.message);
// In message.controller.js (already added)
logger.error(`Failed to send message via API: ${error.message}`);
// In webhook controller (add in Section 6)
logger.info('Received inbound message webhook:', JSON.stringify(req.body, null, 2));
logger.error('Error processing inbound webhook:', error);
5.3 Retry Mechanisms (Vonage Webhooks):
Vonage handles retries for webhooks automatically if it doesn't receive a 200 OK response within a certain timeout.
Your Responsibility: Ensure your webhook endpoints respond quickly with 200 OK. Perform time-consuming processing (like complex database operations, external API calls) asynchronously after sending the response.
Idempotency: Design your webhook handlers to be idempotent. This means processing the same webhook request multiple times should not cause unintended side effects (e.g., creating duplicate database entries). Check if a message ID already exists before creating a new record.
5.4 Testing Error Scenarios:
Invalid Credentials: Temporarily change VONAGE_API_KEY or VONAGE_PRIVATE_KEY_PATH in .env and try sending a message.
Invalid Recipient: Send a message to a known invalid number format (e.g., missing '+').
Network Issues: Simulate network failure (e.g., disconnect your machine briefly) while ngrok is running and try sending/receiving.
Webhook Errors: Introduce an error in your webhook processing logic (e.g., throw new Error('Test webhook error')) and observe logs. Ensure a 200 OK is still sent if the error is caught correctly after the response.
Signature Verification Failure: Send a manual POST request to your webhook endpoint without the correct Vonage signature (Section 7).
6. Creating a Database Schema and Data Layer (Prisma)
Let's define the database schema and integrate it into our webhook handlers.
6.1 Define Prisma Schema:
Open prisma/schema.prisma and define the model for storing messages.
Prisma
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Message {
id String @id @default(cuid()) // Unique ID for the DB record
vonageMessageId String @unique // The UUID from Vonage (important for idempotency)
direction String // 'inbound' or 'outbound'
channel String // 'sms', 'whatsapp', etc.
from String // Sender number/ID (E.164 or WhatsApp ID)
to String // Recipient number/ID (E.164 or WhatsApp ID)
text String? // Message content (nullable if it's not text)
status String // Vonage status (e.g., 'submitted', 'delivered', 'read', 'inbound', 'failed')
timestamp DateTime // Timestamp from Vonage webhook or submission time
errorDetails Json? // Store error info if status is 'failed' or 'rejected'
imageUrl String? // Store image URL for MMS/WhatsApp images
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
6.2 Create and Apply Migration:
Run the Prisma migrate command to generate SQL migration files and apply them to your database.
Bash
# Ensure your DATABASE_URL in .env is correct and the DB is running
npm run db:migrate -- --name init-messages-table
This creates a migrations folder and updates your database schema. You also need to generate the Prisma Client code:
Bash
npm run db:generate
6.3 Create Webhook Controller:
Handles the logic for processing incoming Vonage webhooks.
JavaScript
// src/controllers/webhook.controller.jsconst prisma = require('../db');
const logger = require('../utils/logger');
/**
* Handles inbound message webhooks (/webhooks/inbound)
*/asyncfunctionhandleInboundMessage(req, res) {
const payload = req.body;
logger.info('Received /inbound webhook:', JSON.stringify(payload, null, 2));
// Respond quickly to Vonage
res.status(200).send('OK');
// --- Asynchronous Processing ---
(async () => {
try {
// Basic validation - ensure essential fields existif (!payload.message_uuid || !payload.from || !payload.to || !payload.channel) {
logger.warn('Inbound webhook missing essential fields.', payload);
return; // Don't process incomplete data
}
// Idempotency Check: See if we already processed this message UUIDconst existingMessage = await prisma.message.findUnique({
where: { vonageMessageId: payload.message_uuid },
});
if (existingMessage) {
logger.warn(`Duplicate inbound message webhook received: ${payload.message_uuid}. Ignoring.`);
return;
}
// Extract sender/recipient numbers correctly (can be object or string)const fromNumber = typeof payload.from === 'object' ? payload.from.number : payload.from;
const toNumber = typeof payload.to === 'object' ? payload.to.number : payload.to;
// Determine text content, handling different payload structureslet textContent = null;
if (payload.message?.content?.type === 'text') {
textContent = payload.message.content.text;
} elseif (payload.text) { // Fallback for older/different structures
textContent = payload.text;
}
// Determine image URL, handling different payload structureslet imageUrlContent = null;
if (payload.message?.content?.type === 'image') {
imageUrlContent = payload.message.content.url;
} elseif (payload.image?.[0]?.url) { // Fallback for potential alternative structure
imageUrlContent = payload.image[0].url;
}
// Store the inbound messageawait prisma.message.create({
data: {
vonageMessageId: payload.message_uuid,
direction: 'inbound',
channel: payload.channel,
from: fromNumber,
to: toNumber,
text: textContent,
status: 'inbound', // Custom status for received messagestimestamp: payload.timestamp ? newDate(payload.timestamp) : newDate(),
imageUrl: imageUrlContent,
// Add more fields from the payload if needed (e.g., payload.message.content for various types)
},
});
logger.info(`Inbound message ${payload.message_uuid} saved to DB.`);
} catch (error) {
logger.error(`Error processing inbound webhook ${payload.message_uuid}:`, error);
// Consider sending an alert here (e.g., to Sentry)
}
})(); // Immediately invoke the async function
}
/**
* Handles message status webhooks (/webhooks/status)
*/asyncfunctionhandleMessageStatus(req, res) {
const payload = req.body;
logger.info('Received /status webhook:', JSON.stringify(payload, null, 2));
// Respond quickly to Vonage
res.status(200).send('OK');
// --- Asynchronous Processing ---
(async () => {
try {
if (!payload.message_uuid || !payload.status) {
logger.warn('Status webhook missing essential fields.', payload);
return;
}
// Find the original outbound message by Vonage UUIDconst message = await prisma.message.findUnique({
where: { vonageMessageId: payload.message_uuid },
});
if (!message) {
// This can happen if the webhook arrives before the initial send record is saved,// or if it's a status for an inbound message (which we might not track status for).
logger.warn(`Received status update for unknown message UUID: ${payload.message_uuid}. Status: ${payload.status}`);
return;
}
// Update the message statusawait prisma.message.update({
where: { vonageMessageId: payload.message_uuid },
data: {
status: payload.status.toLowerCase(), // Normalize statustimestamp: payload.timestamp ? newDate(payload.timestamp) : newDate(), // Update timestamp to status time// Optionally store error details if the status indicates failureerrorDetails: (payload.status === 'failed' || payload.status === 'rejected') ? payload.error || payload : undefined,
updatedAt: newDate(), // Explicitly set updatedAt
},
});
logger.info(`Updated status for message ${payload.message_uuid} to ${payload.status}.`);
} catch (error) {
logger.error(`Error processing status webhook ${payload.message_uuid}:`, error);
// Consider sending an alert here
}
})(); // Immediately invoke the async function
}
module.exports = {
handleInboundMessage,
handleMessageStatus,
};
6.4 Create Webhook Routes:
Define the routes that use the webhook controller.
JavaScript
// src/routes/webhooks.routes.jsconst express = require('express');
const webhookController = require('../controllers/webhook.controller');
// Webhook signature verification middleware will be added in Section 7const router = express.Router();
// POST /webhooks/inbound - Receive incoming messages
router.post('/inbound', webhookController.handleInboundMessage);
// POST /webhooks/status - Receive message status updates
router.post('/status', webhookController.handleMessageStatus);
module.exports = router;
Frequently Asked Questions
What Node.js version is required for Vonage SDK v3.x?
Minimum requirement: Node.js 16.13.0+
Recommended for production: Node.js 18+ LTS
Note that Node.js 16.x reached end-of-life on September 11, 2023. Vonage SDK v3.x uses TypeScript and Promises (no callbacks), requiring async/await or .then/.catch patterns. For complete compatibility information, see the Vonage Node SDK documentation.
What are the Vonage Messages API Sandbox rate limits?
The Messages API Sandbox has these limitations:
1 message per second across all channels (SMS, WhatsApp, etc.)
100 messages per month total (all channels combined)
Limits apply to the entire sandbox, not per user
For production use, you need an approved WhatsApp Business Account (WABA) with your own verified phone number. Learn more about sandbox limitations.
How do I verify Vonage webhook signatures?
Vonage uses JWT (JSON Web Token) Bearer Authorization with HMAC-SHA256 signatures:
Extract JWT: Check the Vonage-Signature or Authorization header (format: "Bearer ")
Verify signature: Use your signature secret from Vonage Dashboard Settings
Check expiration: Signatures expire 5 minutes after issuance
Verify payload (optional): Compare SHA-256 hash of payload to payload_hash claim in JWT
Critical: Always verify signatures in production to prevent unauthorized webhook submissions. See the official webhook security guide.
Can I use Vonage SDK v2.x with this guide?
No. This guide uses Vonage SDK v3.x (current version: 3.24.1 as of October 2025), which has breaking changes:
No callbacks: Version 3.x removed all callback functions
Promises required: Must use async/await or .then/.catch patterns
TypeScript native: Full TypeScript support with improved IDE completion
What are the WhatsApp Business requirements for production?
For production WhatsApp messaging (January 2024 policy):
Approved WhatsApp Business Account (WABA) required
Your own verified phone number (cannot use shared accounts)
Phone number must not be registered elsewhere on WhatsApp
Phone number must accept VOICE calls or SMS for verification
For testing: Use the Messages API Sandbox with provided number (typically +14157386102). Recipients must opt-in by sending a specific message to the Sandbox number. WhatsApp Business API Guide.
How do I handle duplicate webhook deliveries?
Implement idempotency checks in your webhook handlers:
JavaScript
// Check if message UUID already exists before creatingconst existingMessage = await prisma.message.findUnique({
where: { vonageMessageId: payload.message_uuid },
});
if (existingMessage) {
logger.warn(`Duplicate webhook: ${payload.message_uuid}. Ignoring.`);
return;
}
Key principles:
Always respond with 200 OK quickly (within timeout)
Process time-consuming operations asynchronously after sending response
Check for existing records using Vonage's message_uuid
Design handlers to be idempotent (safe to process multiple times)
What database versions are compatible with Prisma 5.x?
Prisma 5.x supports:
PostgreSQL: 9.6+
Node.js: 16.13.0+ (minimum)
TypeScript: 4.7+ (if using TypeScript)
Important: Prisma 5.x is the last major version to support Node.js 16. Prisma 6+ requires Node.js 18+. For complete system requirements, see the Prisma documentation.
How do I transition from sandbox to production WhatsApp?
Steps to move from sandbox to production:
Apply for WhatsApp Business Account: Use Vonage's Embedded Sign-Up (~10 minutes) or managed onboarding
Verify your business: Provide business information and Facebook Business Manager access
Verify phone number: Submit phone number that accepts VOICE or SMS calls
Wait for approval: Typically 1-3 business days
Update environment variables: Replace sandbox number with your approved WABA number
Update webhook URLs: Configure production webhook URLs in Vonage Application settings
Test thoroughly: Verify message sending/receiving before going live
Use the Vonage Messages API and Node SDK. After setting up a Vonage application and linking a WhatsApp sender, your Express app can send messages programmatically via API calls, handling responses and status updates through webhooks. The Node SDK simplifies these interactions, ensuring smooth message delivery.
It's a unified API from Vonage that enables sending and receiving messages over multiple channels including SMS, WhatsApp, and MMS. This simplifies messaging integration by providing a single interface to handle different message types, statuses, and delivery mechanisms.
Vonage uses webhooks to asynchronously deliver real-time notifications about incoming messages to your Node.js application. When a user sends a message to your Vonage number, the platform sends an HTTP POST request to your configured webhook endpoint, allowing immediate processing and response.
ngrok is essential during local development. Because Vonage webhooks require a publicly accessible URL, ngrok creates a tunnel that exposes your local Express server, enabling you to test webhook functionality before deploying your application live.
Yes, the guide demonstrates integration with PostgreSQL through the Prisma ORM. Inbound and outbound messages, along with delivery status updates, are saved in the database, ensuring complete message tracking and enabling historical analysis.
Initialize a Node.js project, install Express, the Vonage SDK, Prisma, and other necessary modules. Configure your server file to handle API routes, webhook endpoints, and error handling, while leveraging environment variables for secure credential management.
The Vonage Application ID is a unique identifier for your Vonage application. It's essential for authenticating your application's interactions with the Vonage APIs and linking your application to virtual numbers and webhook configurations.
Set up a specific webhook endpoint (/webhooks/inbound) in your Express app. When a user sends a WhatsApp message to your Vonage number, Vonage forwards it as an HTTP POST request to this endpoint, allowing you to process the message content and respond appropriately.
Prisma is an ORM (Object-Relational Mapper) used to interact with the PostgreSQL database efficiently in the Node.js application. It simplifies database operations, such as creating, reading, updating, and deleting message records.
Vonage provides a signature with each webhook request for security. Your app should verify this signature to ensure the request originates from Vonage and hasn't been tampered with, adding an extra layer of protection against malicious actors.
dotenv allows you to manage environment variables easily. It loads values from a .env file, which is excluded from version control, ensuring that sensitive data like API keys and database credentials are kept secure.
Implement robust error handling within your Node.js application, catching errors during API interactions and in webhook processing. Log errors with sufficient detail, notify administrators, and provide appropriate responses to the client or Vonage.
The Vonage Messages API Sandbox is extremely valuable for testing WhatsApp integration during development. It allows you to send and receive WhatsApp messages through a shared number without having a dedicated business number, simplifying initial setup and experimentation.
A Vonage virtual number acts as your sender ID for SMS messages and a connection point for incoming messages. You'll need an SMS-capable virtual number linked to your Vonage application to send and receive SMS messages programmatically.
Use npm (or yarn) to install the Vonage Node SDK: npm install @vonage/server-sdk. This provides convenient methods and functions to simplify interactions with Vonage APIs, including sending messages and managing applications.
Build a production-ready Node.js application using Express to send and receive both WhatsApp and SMS messages via the Vonage Messages API. This complete guide covers project setup, core messaging logic, database integration, webhook security with JWT verification, error handling, and deployment strategies.
What You'll Build:
Target Audience: Developers familiar with Node.js and basic web concepts implementing reliable two-way messaging capabilities.
Table of Contents
By the end of this tutorial, you will have a robust application capable of:
Technologies Used:
@vonage/server-sdk): Simplifies interaction with Vonage APIs. Current version: 3.24.1 (October 2025). Version 3.x uses TypeScript, Promises (no callbacks), and requires async/await or .then/.catch patterns. Official Documentation.envfile. Version 16.x.Important Version Compatibility Notes (October 2025):
System Architecture
The basic flow of information is as follows:
/webhooks/inbound).delivered,read,failed) about outbound messages to another predefined webhook endpoint in your application (/webhooks/status).Prerequisites
npm install -g @vonage/cli. Useful for managing applications and numbers.Critical WhatsApp Sandbox Limitations (October 2025):
⚠️ Rate Limits:
⚠️ WhatsApp Business Requirements:
⚠️ Sandbox Registration:
+14157386102(may vary by region)Vonage Messages API Sandbox Documentation | WhatsApp Business API Guide
1. Setting Up the Project
Let's initialize the project, install dependencies, and configure the basic structure.
1.1 Create Project Directory & Initialize:
Open your terminal and run the following commands:
# Create a new directory for your project mkdir vonage-messaging-app cd vonage-messaging-app # Initialize a new Node.js project npm init -y1.2 Install Dependencies:
We need Express for the server, the Vonage SDK, Prisma for database interaction,
dotenvfor environment variables, and the PostgreSQL driver.# Install runtime dependencies npm install express @vonage/server-sdk dotenv pg @prisma/client # Install development dependencies (Prisma CLI, nodemon for auto-restarts) npm install --save-dev prisma nodemon1.3 Configure Prisma:
Initialize Prisma in your project. This creates a
prismadirectory with aschema.prismafile and a.envfile (if one doesn't exist).1.4 Configure Environment Variables (
.env):Prisma creates a basic
.envfile. Open it and add the necessary variables for Vonage and your database. Crucially, obtain these values as described below the code block.How to Obtain Environment Variable Values:
DATABASE_URL: Construct this based on your PostgreSQL setup (username, password, host, port, database name).VONAGE_API_KEY,VONAGE_API_SECRET: Found at the top of your Vonage API Dashboard after logging in.VONAGE_APPLICATION_ID,VONAGE_PRIVATE_KEY_PATH: You will generate these when creating a Vonage Application (Section 4). The path should point to where you save the downloadedprivate.keyfile within your project.VONAGE_SMS_FROM_NUMBER: A virtual number you purchase or rent from Vonage (Section 4). Must be SMS-capable.VONAGE_WHATSAPP_FROM_NUMBER: For initial testing, use the number provided by the Vonage Messages API Sandbox (Section 4). For production, this will be your approved WhatsApp Business number linked to Vonage.VONAGE_SIGNATURE_SECRET: Critical for webhook security. Go to your Vonage Dashboard Settings. Under "API settings", find the "Signature secret" for your API key. This is used to verify JWT signatures in webhook requests. If none exists, you might need to generate one. Webhook Security (October 2025): Vonage uses JWT (JSON Web Token) Bearer Authorization with HMAC-SHA256 signatures in theVonage-SignatureorAuthorizationheader. The signature expires 5 minutes after issuance. Webhook Security GuidePORT: The local port your Express server will listen on.Webhook Security Requirements (October 2025):
Vonage-SignatureorAuthorizationheader (format: "Bearer ")payload_hashclaim in JWT1.5 Configure
.gitignore:Ensure sensitive files and generated files are not committed to version control. Create a
.gitignorefile:1.6 Project Structure:
Organize your code for clarity. A possible structure:
1.7 Add npm Scripts:
Modify the
scriptssection in yourpackage.jsonfor easier development and running:{ "name": "vonage-messaging-app", "version": "1.0.0", "description": "", "main": "src/server.js", "scripts": { "start": "node src/server.js", "dev": "nodemon src/server.js", "db:migrate": "npx prisma migrate dev", "db:generate": "npx prisma generate", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "@prisma/client": "^5.x.x", "@vonage/server-sdk": "^3.x.x", "dotenv": "^16.x.x", "express": "^4.x.x", "pg": "^8.x.x" }, "devDependencies": { "nodemon": "^3.x.x", "prisma": "^5.x.x" } }(Note: Replace
x.x.xplaceholders inpackage.jsonwith actual installed versions if needed, or remove the version numbers entirely if managing viapackage-lock.json)Now you can run
npm run devto start the server with auto-reloading vianodemon, andnpm run db:migrateto apply database schema changes.2. Implementing Core Functionality (Sending & Receiving Logic)
Let's write the code to interact with Vonage and handle basic server setup.
2.1 Initialize Prisma Client:
Create
src/db.jsto export a singleton instance of the Prisma client.// src/db.js const { PrismaClient } = require('@prisma/client'); const prisma = new PrismaClient(); module.exports = prisma;2.2 Create Utility Logger:
Create a simple logger utility in
src/utils/logger.js. You can replace this with a more robust library like Winston later.// src/utils/logger.js const logger = { log: (...args) => console.log('[LOG]', ...args), error: (...args) => console.error('[ERROR]', ...args), warn: (...args) => console.warn('[WARN]', ...args), info: (...args) => console.info('[INFO]', ...args), }; module.exports = logger;2.3 Create Vonage Service:
This service will encapsulate interactions with the Vonage SDK.
Important: Vonage SDK v3.x Changes (October 2025):
async/awaitor.then/.catch// src/services/vonage.service.js require('dotenv').config(); // Ensure env vars are loaded const { Vonage } = require('@vonage/server-sdk'); const { Message } = require('@vonage/server-sdk/dist/messages/message'); // Adjusted path if needed const logger = require('../utils/logger'); // Import the logger const prisma = require('../db'); // Initialize Vonage client with v3.x syntax (Promise-based) const vonage = new Vonage({ apiKey: process.env.VONAGE_API_KEY, apiSecret: process.env.VONAGE_API_SECRET, applicationId: process.env.VONAGE_APPLICATION_ID, privateKey: process.env.VONAGE_PRIVATE_KEY_PATH, }); /** * Sends a message via the Vonage Messages API. * @param {string} to - Recipient phone number (E.164 format). * @param {string} text - Message content. * @param {'sms' | 'whatsapp'} channel - Messaging channel. * @returns {Promise<object>} - The Vonage API response. */ async function sendMessage(to, text, channel) { const from = channel === 'sms' ? process.env.VONAGE_SMS_FROM_NUMBER : process.env.VONAGE_WHATSAPP_FROM_NUMBER; if (!from) { logger.error(`Vonage 'from' number/ID not configured for channel: ${channel}`); throw new Error(`Sender ID not configured for ${channel}`); } logger.info(`Attempting to send ${channel} message from ${from} to ${to}`); try { // Use the Message builder for flexibility const message = new Message( { type: 'text', text: text }, // Content { number: to }, // To { type: channel, number: from } // From ); const response = await vonage.messages.send(message); logger.info('Message sent successfully:', response); // Store outbound message record (async, don't block response) prisma.message.create({ data: { vonageMessageId: response.message_uuid, direction: 'outbound', channel: channel, to: to, from: from, text: text, status: 'submitted', // Initial status from Vonage timestamp: new Date(), // Use current time as submission time } }).catch(dbError => logger.error('Failed to save outbound message to DB:', dbError)); return response; // Contains message_uuid } catch (error) { const errorData = error?.response?.data || { message: error.message }; logger.error('Error sending Vonage message:', errorData); // Attempt to store failed submission prisma.message.create({ data: { vonageMessageId: `failed_${Date.now()}`, // Placeholder ID direction: 'outbound', channel: channel, to: to, from: from, text: text, status: 'failed_submission', timestamp: new Date(), errorDetails: JSON.stringify(errorData), } }).catch(dbError => logger.error('Failed to save FAILED outbound message to DB:', dbError)); throw error; // Re-throw for the controller to handle } } module.exports = { sendMessage, vonage // Export instance if needed elsewhere (e.g., for signature verification) };2.4 Basic Express Server Setup:
Create the main server file
src/server.js.// src/server.js require('dotenv').config(); const express = require('express'); const logger = require('./utils/logger'); const apiRoutes = require('./routes/api.routes'); const webhookRoutes = require('./routes/webhooks.routes'); const prisma = require('./db'); // Import to ensure connection pool is ready (optional) const app = express(); const PORT = process.env.PORT || 3000; // --- IMPORTANT: Raw Body Middleware for Webhook Verification --- // This MUST come BEFORE the webhook routes are defined and BEFORE express.json() // if you want the default JSON parser to run afterwards on those routes. // However, it's safer to put it just before the webhook router is used. // We will configure it more precisely in section 7.1. // For now, let's use the standard JSON parser first. // Standard Middleware // Note: We will modify express.json() later in Section 7.1 for webhook signature verification app.use(express.json()); // Parse JSON bodies app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies // Simple Request Logger Middleware app.use((req, res, next) => { logger.info(`${req.method} ${req.url}`); next(); }); // Routes app.use('/api', apiRoutes); app.use('/webhooks', webhookRoutes); // Webhook routes will include specific body parsing/verification // Basic Root Route app.get('/', (req, res) => { res.send('Vonage Messaging App is running!'); }); // Global Error Handler (Basic) app.use((err, req, res, next) => { logger.error('Unhandled Error:', err); res.status(500).json({ error: 'Internal Server Error' }); }); // Start Server and store the server instance const server = app.listen(PORT, () => { logger.log(`Server listening on port ${PORT}`); logger.log(`Local URL: http://localhost:${PORT}`); // Remind about ngrok for webhooks logger.warn('Remember to run ngrok and update Vonage webhook URLs if testing locally!'); logger.warn(`Example ngrok command: ngrok http ${PORT}`); }); // Graceful Shutdown (Optional but Recommended) process.on('SIGTERM', async () => { logger.info('SIGTERM signal received. Closing HTTP server.'); // Close the server (requires the 'server' variable assigned above) server.close(async () => { logger.info('HTTP server closed.'); // Disconnect Prisma Client try { await prisma.$disconnect(); logger.info('Prisma Client disconnected.'); } catch (e) { logger.error('Error disconnecting Prisma:', e); } finally { process.exit(0); } }); });3. Building the API Layer (Sending Messages)
Let's create an API endpoint to trigger sending messages.
3.1 Create Message Controller:
Handles the logic for the send message route.
// src/controllers/message.controller.js const vonageService = require('../services/vonage.service'); const logger = require('../utils/logger'); async function handleSendMessage(req, res) { const { to, text, channel } = req.body; // Basic Validation if (!to || !text || !channel) { return res.status(400).json({ error: 'Missing required fields: to, text, channel' }); } if (channel !== 'sms' && channel !== 'whatsapp') { return res.status(400).json({ error: 'Invalid channel. Must be "sms" or "whatsapp".' }); } // Decide whether to reject or attempt anyway based on format // Stricter E.164 format check (requires '+', country code, and subscriber number) if (!/^\+\d{10,15}$/.test(to)) { logger.error(`Invalid 'to' number format received: ${to}`); return res.status(400).json({ error: 'Invalid "to" number format. Use E.164 standard (e.g., "+15551234567").' }); } try { const response = await vonageService.sendMessage(to, text, channel); res.status(202).json({ // 202 Accepted - request initiated message: `Message sending initiated via ${channel}.`, message_uuid: response.message_uuid }); } catch (error) { logger.error(`Failed to send message via API: ${error.message}`); // Determine appropriate status code based on error type if possible const statusCode = error.response?.status || 500; const errorDetails = error.response?.data?.title || error.message; res.status(statusCode).json({ error: 'Failed to send message', details: errorDetails }); } } module.exports = { handleSendMessage, };3.2 Create API Routes:
Define the route that uses the controller.
// src/routes/api.routes.js const express = require('express'); const messageController = require('../controllers/message.controller'); // Add rate limiting later (Section 7) const router = express.Router(); // POST /api/send-message router.post('/send-message', messageController.handleSendMessage); // Add a simple health check endpoint router.get('/health', (req, res) => { res.status(200).json({ status: 'UP' }); }); module.exports = router;3.3 Testing the API Endpoint:
Once the server is running (
npm run dev), you can test this endpoint usingcurlor Postman.Curl Example (SMS):
curl -X POST http://localhost:3000/api/send-message \ -H "Content-Type: application/json" \ -d '{ "to": "+15551234567", "text": "Hello from Node.js via Vonage SMS!", "channel": "sms" }'Curl Example (WhatsApp):
Remember: WhatsApp sending via Sandbox requires the recipient to have first messaged the Sandbox number.
curl -X POST http://localhost:3000/api/send-message \ -H "Content-Type: application/json" \ -d '{ "to": "+15551234567", "text": "Hello from Node.js via Vonage WhatsApp Sandbox!", "channel": "whatsapp" }'Expected JSON Response (Success):
{ "message": "Message sending initiated via sms.", "message_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" }Expected JSON Response (Validation Error):
{ "error": "Invalid \"to\" number format. Use E.164 standard (e.g., \"+15551234567\")." }Expected JSON Response (Vonage API Error):
{ "error": "Failed to send message", "details": "Authentication failure" }4. Integrating with Vonage (Dashboard & ngrok Setup)
This section details the crucial steps within the Vonage Dashboard and using
ngrok.4.1 Start ngrok:
Before configuring Vonage webhooks, you need a publicly accessible URL for your local server.
Open a new terminal window in your project directory and run:
# Replace 3000 if you used a different PORT in .env ngrok http 3000ngrokwill output something like this:Copy the
https://xxxxxxxxxxxx.ngrok-free.appURL. This is your public base URL. Keep this terminal running.4.2 Create a Vonage Application:
Vonage Applications act as containers for your configurations (like webhook URLs) and link your virtual numbers.
private.keyfile that downloads into the root of your project directory (or whereverVONAGE_PRIVATE_KEY_PATHpoints). Vonage does not store this key, so save it securely./webhooks/inbound. Example:https://xxxxxxxxxxxx.ngrok-free.app/webhooks/inbound/webhooks/status. Example:https://xxxxxxxxxxxx.ngrok-free.app/webhooks/status.envfile forVONAGE_APPLICATION_ID.4.3 Link a Virtual Number (for SMS):
You need a Vonage number to send/receive SMS messages.
12015550123) and paste it into your.envfile forVONAGE_SMS_FROM_NUMBER.4.4 Set Up Messages API Sandbox (for WhatsApp Testing):
The Sandbox provides a shared number for testing WhatsApp integration without needing your own approved WhatsApp Business number initially.
+14157386102). This allows the Sandbox to send messages to you./webhooks/inbound(e.g.,https://xxxxxxxxxxxx.ngrok-free.app/webhooks/inbound)./webhooks/status(e.g.,https://xxxxxxxxxxxx.ngrok-free.app/webhooks/status).14157386102). Use this value in your.envfile forVONAGE_WHATSAPP_FROM_NUMBERduring testing.4.5 Ensure Correct Vonage API Settings:
Vonage has older APIs (like the SMS API). Ensure your account is set to use the Messages API as the default for SMS to receive webhooks in the correct format expected by this guide.
5. Implementing Error Handling, Logging, and Retry Mechanisms
Robust applications need proper error handling and logging.
5.1 Consistent Error Handling Strategy:
vonage.service.js. Log detailed errors. Re-throw generic or transformed errors for controllers.try...catchextensively. Always respond with200 OKquickly to Vonage, even if processing fails internally (log the failure). Vonage retries webhooks if it doesn't receive a 2xx response, which can cause duplicate processing.app.use((err, req, res, next) => {...})) inserver.jsas a final catch-all for unexpected errors.5.2 Logging:
The simple
logger.jsprovided is basic. For production, consider a more structured logging library like Winston or Pino.Example using basic logger (already integrated):
// In vonage.service.js (already added) logger.info('Message sent successfully:', response); logger.error('Error sending Vonage message:', error?.response?.data || error.message); // In message.controller.js (already added) logger.error(`Failed to send message via API: ${error.message}`); // In webhook controller (add in Section 6) logger.info('Received inbound message webhook:', JSON.stringify(req.body, null, 2)); logger.error('Error processing inbound webhook:', error);5.3 Retry Mechanisms (Vonage Webhooks):
Vonage handles retries for webhooks automatically if it doesn't receive a
200 OKresponse within a certain timeout.200 OK. Perform time-consuming processing (like complex database operations, external API calls) asynchronously after sending the response.5.4 Testing Error Scenarios:
VONAGE_API_KEYorVONAGE_PRIVATE_KEY_PATHin.envand try sending a message.ngrokis running and try sending/receiving.throw new Error('Test webhook error')) and observe logs. Ensure a200 OKis still sent if the error is caught correctly after the response.6. Creating a Database Schema and Data Layer (Prisma)
Let's define the database schema and integrate it into our webhook handlers.
6.1 Define Prisma Schema:
Open
prisma/schema.prismaand define the model for storing messages.6.2 Create and Apply Migration:
Run the Prisma migrate command to generate SQL migration files and apply them to your database.
# Ensure your DATABASE_URL in .env is correct and the DB is running npm run db:migrate -- --name init-messages-tableThis creates a
migrationsfolder and updates your database schema. You also need to generate the Prisma Client code:6.3 Create Webhook Controller:
Handles the logic for processing incoming Vonage webhooks.
// src/controllers/webhook.controller.js const prisma = require('../db'); const logger = require('../utils/logger'); /** * Handles inbound message webhooks (/webhooks/inbound) */ async function handleInboundMessage(req, res) { const payload = req.body; logger.info('Received /inbound webhook:', JSON.stringify(payload, null, 2)); // Respond quickly to Vonage res.status(200).send('OK'); // --- Asynchronous Processing --- (async () => { try { // Basic validation - ensure essential fields exist if (!payload.message_uuid || !payload.from || !payload.to || !payload.channel) { logger.warn('Inbound webhook missing essential fields.', payload); return; // Don't process incomplete data } // Idempotency Check: See if we already processed this message UUID const existingMessage = await prisma.message.findUnique({ where: { vonageMessageId: payload.message_uuid }, }); if (existingMessage) { logger.warn(`Duplicate inbound message webhook received: ${payload.message_uuid}. Ignoring.`); return; } // Extract sender/recipient numbers correctly (can be object or string) const fromNumber = typeof payload.from === 'object' ? payload.from.number : payload.from; const toNumber = typeof payload.to === 'object' ? payload.to.number : payload.to; // Determine text content, handling different payload structures let textContent = null; if (payload.message?.content?.type === 'text') { textContent = payload.message.content.text; } else if (payload.text) { // Fallback for older/different structures textContent = payload.text; } // Determine image URL, handling different payload structures let imageUrlContent = null; if (payload.message?.content?.type === 'image') { imageUrlContent = payload.message.content.url; } else if (payload.image?.[0]?.url) { // Fallback for potential alternative structure imageUrlContent = payload.image[0].url; } // Store the inbound message await prisma.message.create({ data: { vonageMessageId: payload.message_uuid, direction: 'inbound', channel: payload.channel, from: fromNumber, to: toNumber, text: textContent, status: 'inbound', // Custom status for received messages timestamp: payload.timestamp ? new Date(payload.timestamp) : new Date(), imageUrl: imageUrlContent, // Add more fields from the payload if needed (e.g., payload.message.content for various types) }, }); logger.info(`Inbound message ${payload.message_uuid} saved to DB.`); } catch (error) { logger.error(`Error processing inbound webhook ${payload.message_uuid}:`, error); // Consider sending an alert here (e.g., to Sentry) } })(); // Immediately invoke the async function } /** * Handles message status webhooks (/webhooks/status) */ async function handleMessageStatus(req, res) { const payload = req.body; logger.info('Received /status webhook:', JSON.stringify(payload, null, 2)); // Respond quickly to Vonage res.status(200).send('OK'); // --- Asynchronous Processing --- (async () => { try { if (!payload.message_uuid || !payload.status) { logger.warn('Status webhook missing essential fields.', payload); return; } // Find the original outbound message by Vonage UUID const message = await prisma.message.findUnique({ where: { vonageMessageId: payload.message_uuid }, }); if (!message) { // This can happen if the webhook arrives before the initial send record is saved, // or if it's a status for an inbound message (which we might not track status for). logger.warn(`Received status update for unknown message UUID: ${payload.message_uuid}. Status: ${payload.status}`); return; } // Update the message status await prisma.message.update({ where: { vonageMessageId: payload.message_uuid }, data: { status: payload.status.toLowerCase(), // Normalize status timestamp: payload.timestamp ? new Date(payload.timestamp) : new Date(), // Update timestamp to status time // Optionally store error details if the status indicates failure errorDetails: (payload.status === 'failed' || payload.status === 'rejected') ? payload.error || payload : undefined, updatedAt: new Date(), // Explicitly set updatedAt }, }); logger.info(`Updated status for message ${payload.message_uuid} to ${payload.status}.`); } catch (error) { logger.error(`Error processing status webhook ${payload.message_uuid}:`, error); // Consider sending an alert here } })(); // Immediately invoke the async function } module.exports = { handleInboundMessage, handleMessageStatus, };6.4 Create Webhook Routes:
Define the routes that use the webhook controller.
// src/routes/webhooks.routes.js const express = require('express'); const webhookController = require('../controllers/webhook.controller'); // Webhook signature verification middleware will be added in Section 7 const router = express.Router(); // POST /webhooks/inbound - Receive incoming messages router.post('/inbound', webhookController.handleInboundMessage); // POST /webhooks/status - Receive message status updates router.post('/status', webhookController.handleMessageStatus); module.exports = router;Frequently Asked Questions
What Node.js version is required for Vonage SDK v3.x?
Minimum requirement: Node.js 16.13.0+ Recommended for production: Node.js 18+ LTS
Note that Node.js 16.x reached end-of-life on September 11, 2023. Vonage SDK v3.x uses TypeScript and Promises (no callbacks), requiring async/await or .then/.catch patterns. For complete compatibility information, see the Vonage Node SDK documentation.
What are the Vonage Messages API Sandbox rate limits?
The Messages API Sandbox has these limitations:
For production use, you need an approved WhatsApp Business Account (WABA) with your own verified phone number. Learn more about sandbox limitations.
How do I verify Vonage webhook signatures?
Vonage uses JWT (JSON Web Token) Bearer Authorization with HMAC-SHA256 signatures:
Vonage-SignatureorAuthorizationheader (format: "Bearer ")payload_hashclaim in JWTCritical: Always verify signatures in production to prevent unauthorized webhook submissions. See the official webhook security guide.
Can I use Vonage SDK v2.x with this guide?
No. This guide uses Vonage SDK v3.x (current version: 3.24.1 as of October 2025), which has breaking changes:
If you're upgrading from v2.x, see the v2 to v3 Migration Guide.
What are the WhatsApp Business requirements for production?
For production WhatsApp messaging (January 2024 policy):
For testing: Use the Messages API Sandbox with provided number (typically
+14157386102). Recipients must opt-in by sending a specific message to the Sandbox number. WhatsApp Business API Guide.How do I handle duplicate webhook deliveries?
Implement idempotency checks in your webhook handlers:
// Check if message UUID already exists before creating const existingMessage = await prisma.message.findUnique({ where: { vonageMessageId: payload.message_uuid }, }); if (existingMessage) { logger.warn(`Duplicate webhook: ${payload.message_uuid}. Ignoring.`); return; }Key principles:
200 OKquickly (within timeout)message_uuidWhat database versions are compatible with Prisma 5.x?
Prisma 5.x supports:
Important: Prisma 5.x is the last major version to support Node.js 16. Prisma 6+ requires Node.js 18+. For complete system requirements, see the Prisma documentation.
How do I transition from sandbox to production WhatsApp?
Steps to move from sandbox to production:
Getting Started with WhatsApp Guide