Send and Receive WhatsApp Messages with Node.js and Vonage
A guide to building a Node.js/Express application using the Vonage Messages API for WhatsApp communication, covering setup, webhooks, security, and deployment.
This guide provides a complete walkthrough for building a Node.js and Express application capable of sending and receiving WhatsApp messages using the Vonage Messages API. We will cover everything from initial project setup and core messaging logic to security, error handling, and deployment considerations.
By the end of this tutorial, you will have a functional application that can:
Receive incoming WhatsApp messages via a webhook.
Verify the authenticity of incoming messages using JWT signatures.
Send WhatsApp message replies back to the user.
Handle message status updates via a separate webhook.
This enables developers to integrate WhatsApp communication directly into their applications for notifications, customer support, chatbots, and more.
Project Overview and Goals
What We're Building: A Node.js server using the Express framework that listens for incoming WhatsApp messages and message status updates sent from the Vonage platform via webhooks. When a message arrives, the server verifies it, logs it, and sends a confirmation reply back to the sender's WhatsApp number.
Problem Solved: This provides a foundational backend service for two-way WhatsApp communication, enabling applications to interact with users on a widely adopted messaging platform without needing to manage the underlying complexities of the WhatsApp Business API directly.
Technologies Used:
Node.js: A JavaScript runtime environment for building the server-side application. (Version 20 or higher recommended).
Express: A minimal and flexible Node.js web application framework used to handle HTTP requests and define webhook endpoints.
Vonage Messages API: A unified API from Vonage for sending and receiving messages across various channels, including WhatsApp, SMS, MMS, and more.
Vonage Node SDK (@vonage/server-sdk, @vonage/messages, @vonage/jwt): Simplifies interaction with the Vonage APIs, including sending messages and verifying signatures.
ngrok: A tool to expose your local development server to the internet, making it reachable by Vonage webhooks during testing.
dotenv: A module to load environment variables from a .env file into process.env.
express: Web framework for handling HTTP requests (webhooks).
dotenv: Loads environment variables from a .env file.
@vonage/server-sdk: Core Vonage SDK.
@vonage/messages: Specific helpers for the Messages API.
@vonage/jwt: Utilities for handling JWTs, specifically for signature verification.
Note: npm install without flags like --save or --save-dev defaults to adding packages as production dependencies in recent npm versions. This also generates/updates package-lock.json, which is important for deterministic installs, especially when using npm ci later in CI/CD pipelines.
Create Project Structure:
Create a source directory and the main server file.
Bash
mkdir src
touch src/server.js
Create .env File:
This file will store sensitive credentials and configuration. Never commit this file to version control.
Bash
touch .env
We will populate this file later in the guide.
Create .gitignore File:
Prevent sensitive files and unnecessary directories from being committed to Git.
This setup provides a clean separation of source code, dependencies, and configuration.
2. Implementing Core Functionality
Now, let's write the core logic in src/server.js to handle incoming messages and send replies.
src/server.js
JavaScript
// src/server.jsrequire('dotenv').config(); // Load environment variables from .env fileconst express = require('express');
const { Vonage } = require('@vonage/server-sdk');
const { WhatsAppText } = require('@vonage/messages');
const { verifySignature } = require('@vonage/jwt');
const app = express();
app.use(express.json()); // Middleware to parse JSON request bodies
app.use(express.urlencoded({ extended: true })); // Middleware to parse URL-encoded bodies// --- Configuration ---constPORT = process.env.PORT || 8000; // Use port from .env or default to 8000constVONAGE_API_KEY = process.env.VONAGE_API_KEY;
constVONAGE_API_SECRET = process.env.VONAGE_API_SECRET;
constVONAGE_APPLICATION_ID = process.env.VONAGE_APPLICATION_ID;
constVONAGE_PRIVATE_KEY = process.env.VONAGE_PRIVATE_KEY; // Path to your private.key fileconstVONAGE_WHATSAPP_NUMBER = process.env.VONAGE_WHATSAPP_NUMBER; // Vonage WhatsApp Sandbox numberconstVONAGE_API_SIGNATURE_SECRET = process.env.VONAGE_API_SIGNATURE_SECRET;
// --- Input Validation (Basic Checks) ---if (!VONAGE_API_KEY || !VONAGE_API_SECRET || !VONAGE_APPLICATION_ID || !VONAGE_PRIVATE_KEY || !VONAGE_WHATSAPP_NUMBER || !VONAGE_API_SIGNATURE_SECRET) {
console.error('[ERROR] Missing required Vonage environment variables. Please check your .env file.');
process.exit(1); // Exit if essential config is missing
}
// --- Initialize Vonage Client ---const vonage = newVonage(
{
apiKey: VONAGE_API_KEY,
apiSecret: VONAGE_API_SECRET,
applicationId: VONAGE_APPLICATION_ID,
privateKey: VONAGE_PRIVATE_KEY,
},
{
apiHost: 'https://messages-sandbox.nexmo.com'// Use Sandbox URL for testing// For production: Remove the 'apiHost' option.// The SDK defaults to the production Messages API endpoint (e.g., 'https://api.nexmo.com/v1/messages').// Explicitly setting it to 'https://api.nexmo.com' is also possible if needed.
}
);
// --- JWT Signature Verification Middleware ---// Verifies that incoming webhooks genuinely originated from VonageconstverifyVonageSignature = (req, res, next) => {
try {
// Get JWT from Authorization header (format: Bearer <token>)const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
console.warn('[WARN] Missing or invalid Authorization header.');
return res.status(401).send('Unauthorized: Missing Bearer token.');
}
const jwtToken = authHeader.split(' ')[1];
// Verify the signature using the secret from .envif (!verifySignature(jwtToken, VONAGE_API_SIGNATURE_SECRET)) {
console.error('[ERROR] Invalid JWT signature.');
return res.status(401).send('Unauthorized: Invalid signature.');
}
console.log('[INFO] JWT Signature Verified');
next(); // Proceed to the next middleware/handler if signature is valid
} catch (error) {
console.error('[ERROR] Error verifying JWT:', error);
res.status(500).send('Internal Server Error during signature verification.');
}
};
// --- Function to Send WhatsApp Message ---constsendMessage = async (to_number, text = 'Message received.') => {
console.log(`[INFO] Attempting to send message to ${to_number}...`);
try {
const { message_uuid } = await vonage.messages.send(
newWhatsAppText({
from: VONAGE_WHATSAPP_NUMBER, // Must be the Vonage Sandbox number or your provisioned WA numberto: to_number, // The recipient's phone numbertext: text, // The message contentclient_ref: `msg-to-${to_number}-${Date.now()}`// Optional client reference
})
);
console.log(`[SUCCESS] Message sent successfully to ${to_number}. Message UUID: ${message_uuid}`);
return message_uuid; // Return the UUID for tracking
} catch (error) {
console.error(`[ERROR] Error sending message to ${to_number}:`, error.response ? error.response.data : error.message);
// Log detailed Vonage API error if availableif (error.response && error.response.data) {
console.error('[ERROR] Vonage API Error Details:', JSON.stringify(error.response.data, null, 2));
}
// Re-throw the error or handle it as neededthrow error;
}
};
// --- Webhook Endpoints ---// 1. Inbound Message Webhook (/inbound)// Handles messages sent *from* WhatsApp users *to* your Vonage number
app.post('/inbound', verifyVonageSignature, async (req, res) => {
console.log('[INFO] --- Received Inbound Message ---');
console.log('[INFO] Request Body:', JSON.stringify(req.body, null, 2));
const { from: requesterNumber, message } = req.body;
// Basic validation of incoming payloadif (!requesterNumber || !message || !message.content || message.content.type !== 'text') {
console.warn('[WARN] Received incomplete or non-text message payload.');
// Acknowledge receipt even if we don't process it fully herereturn res.status(200).send('Payload received but not processed (expected text message).');
}
const incomingText = message.content.text;
console.log(`[INFO] Received text message ""${incomingText}"" from ${requesterNumber}`);
try {
// Send a reply message back to the userawaitsendMessage(requesterNumber, `[REPLY] Got it! You said: ""${incomingText}""`);
res.status(200).send('Message processed and reply sent.'); // Acknowledge successful processing
} catch (error) {
console.error('[ERROR] Error handling incoming message or sending reply:', error);
// Send 500 but Vonage might retry. Log carefully.
res.status(500).send('An error occurred while processing the message.');
}
});
// 2. Status Update Webhook (/status)// Handles delivery receipts and status updates for messages *sent from* your application
app.post('/status', verifyVonageSignature, (req, res) => {
console.log('[INFO] --- Received Status Update ---');
console.log('[INFO] Request Body:', JSON.stringify(req.body, null, 2));
// Process the status update (e.g., update message status in a database)const { message_uuid, status, timestamp, to, error } = req.body;
console.log(`[INFO] Status update for message ${message_uuid} to ${to}: ${status} at ${timestamp}`);
if (error) {
console.error(`[ERROR] Message delivery error: ${error.type} - ${error.reason}`);
}
// Acknowledge receipt to Vonage
res.status(200).send('Status received.');
});
// 3. Health Check Endpoint (/health)// Simple endpoint for monitoring services to check if the app is running
app.get('/health', (req, res) => {
res.status(200).json({ status: 'UP', timestamp: newDate().toISOString() });
});
// --- Start the Server ---const server = app.listen(PORT, () => { // Assign server to variable for graceful shutdownconsole.log(`[INFO] Server is running and listening on http://localhost:${PORT}`);
console.log(`[INFO] Webhook endpoints:`);
console.log(` POST /inbound (for incoming messages)`);
console.log(` POST /status (for message status updates)`);
console.log(`[INFO] Ensure ngrok exposes port ${PORT} and webhooks are configured in Vonage.`);
});
// --- Graceful Shutdown Handling (Optional but Recommended) ---constcleanup = (signal) => {
console.info(`[INFO] ${signal} signal received: closing HTTP server`);
server.close(() => {
console.log('[INFO] HTTP server closed.');
// Add other cleanup logic here (e.g., close database connections)
process.exit(0);
});
};
process.on('SIGTERM', () =>cleanup('SIGTERM'));
process.on('SIGINT', () =>cleanup('SIGINT'));
// Export app for testing purposesmodule.exports = { app, sendMessage }; // Export app and potentially other functions if needed for testing
Explanation:
Dependencies & Config: Imports necessary modules, loads .env variables, and sets up basic configuration constants. Includes checks for essential variables.
Vonage Client: Initializes the Vonage client with credentials from .env. The apiHost option directs requests to the Sandbox environment. Removed or changed for production use.
verifyVonageSignature Middleware: This crucial function intercepts requests to webhook endpoints. It extracts the JWT from the Authorization: Bearer <token> header and uses verifySignature from @vonage/jwt along with your VONAGE_API_SIGNATURE_SECRET to confirm the request genuinely came from Vonage. Unauthorized requests are rejected with a 401 status.
sendMessage Function: Encapsulates the logic for sending a WhatsApp message using vonage.messages.send. It constructs a WhatsAppText object and handles potential errors, logging success or failure details.
/inbound Webhook:
Listens for POST requests on /inbound.
Runs the verifyVonageSignature middleware first.
Parses the request body (req.body) to get the sender's number (from) and message content.
Logs the incoming message details.
Calls sendMessage to send a reply.
Responds with HTTP 200 OK to Vonage to acknowledge receipt and prevent retries. Error handling returns 500.
/status Webhook:
Listens for POST requests on /status.
Runs the verifyVonageSignature middleware.
Logs the received status update details (e.g., delivered, read, failed).
Responds with 200 OK. In a real application, you'd update your database here.
/health Endpoint: A simple GET endpoint useful for monitoring tools to check if the server is alive.
Server Start: Starts the Express server, listening on the configured PORT.
Graceful Shutdown: Basic signal handling for SIGTERM and SIGINT (common signals for stopping processes) ensures cleaner shutdowns by closing the server before exiting.
Exports: The app instance and sendMessage function are exported at the bottom. This is a common pattern to allow importing them into test files.
3. API Layer Details (Webhooks)
In this application, the ""API layer"" consists of the webhook endpoints that Vonage interacts with. There isn't a separate user-facing REST API being built here.
Authentication & Authorization: Handled exclusively by the verifyVonageSignature middleware, ensuring only legitimate requests from Vonage are processed.
Request Validation: Basic validation is included in the /inbound handler to check for necessary fields (from, message.content.type === 'text'). For production, use libraries like express-validator for more robust schema validation.
Endpoints:
POST /inbound
Purpose: Receives messages sent from users to your Vonage WhatsApp number.
Authentication:Authorization: Bearer <JWT_TOKEN> (Verified by middleware).
401 Unauthorized: If JWT signature verification fails.
Testing with curl (Simulating Vonage):
Directly testing webhooks that require valid, signed JWTs with curl is difficult. You would need to generate a valid JWT signed with the VONAGE_API_SIGNATURE_SECRET. For local testing, triggering the flow by sending a real WhatsApp message (see Section 5) is the most practical approach. Automated integration tests (also Section 5) can mock the signature verification.
4. Integrating with Vonage (Setup & Configuration)
This section details the necessary steps within the Vonage Dashboard and your local environment.
Sign Up/In to Vonage:
If you haven't already, create an account at vonage.com.
Create a Vonage Application:
Navigate to Applications in the Vonage API Dashboard.
Click Create a new application.
Give it a name (e.g., ""Node WhatsApp Demo"").
Under Capabilities, toggle Messages ON.
You'll see fields for Inbound URL and Status URL. We'll fill these after setting up ngrok.
Click Generate public and private key. A private.key file will download.
Security Warning: The guide uses ./private.key in .env for simplicity, assuming you save the key in your project root. This is NOT recommended for production. Storing private keys directly in your application directory increases risk if your code repository or server filesystem is compromised.
Recommendation: For production, store the private.key file outside your project directory (e.g., in a secure parent directory inaccessible to the web server) and use an absolute path or path relative to the user's home directory in .env. Alternatively, use a dedicated secrets management system (like HashiCorp Vault, AWS Secrets Manager, Google Secret Manager) to inject the key content as an environment variable or mount it securely. Ensure the key file has strict permissions (read-only for the application user). Remember to add private.key to your .gitignore if storing it locally during development.
Click Generate new application.
Note down the Application ID displayed.
Start ngrok:
Open a new terminal window (keep your Node server terminal running later). Run ngrok to expose the port your Express server will listen on (default is 8000, or whatever you set in .env).
Bash
# Make sure you are logged into ngrok (ngrok config add-authtoken YOUR_TOKEN)
ngrok http 8000
ngrok will display output similar to this:
Text
Session Status online
Account Your Name (Plan: Free)
Version x.x.x
Region United States (us)
Forwarding https://<UNIQUE_SUBDOMAIN>.ngrok-free.app -> http://localhost:8000
Copy the https://<UNIQUE_SUBDOMAIN>.ngrok-free.app URL. This is your public base URL. Keep this ngrok terminal running.
Configure Webhooks in Vonage Application:
Go back to the Vonage Application you created.
In the Messages capability section:
Set Inbound URL to: YOUR_NGROK_URL/inbound (e.g., https://<UNIQUE_SUBDOMAIN>.ngrok-free.app/inbound)
Set Status URL to: YOUR_NGROK_URL/status (e.g., https://<UNIQUE_SUBDOMAIN>.ngrok-free.app/status)
Scroll down and click Save changes.
Set up Messages API Sandbox for WhatsApp:
In the Vonage Dashboard sidebar, go to Developer Tools -> Messages API Sandbox.
Click the WhatsApp tab.
Scan the QR code with your phone's WhatsApp application or send the specified phrase (e.g., ""JOIN "") to the provided Vonage Sandbox phone number. This allowlists your WhatsApp number to interact with the Sandbox.
Configure Webhooks for the Sandbox: Click the ""Webhooks"" tab within the Sandbox page.
Set Inbound URL to: YOUR_NGROK_URL/inbound
Set Status URL to: YOUR_NGROK_URL/status
Click Save webhooks. This ensures messages to the Sandbox number and status updates from it are routed to your local server via ngrok.
Populate the .env File:
Open the .env file in your project and add the following variables, replacing the placeholder values with your actual credentials:
.env
# .env
# Vonage API Credentials (Found on Vonage Dashboard homepage)
VONAGE_API_KEY=""YOUR_API_KEY""
VONAGE_API_SECRET=""YOUR_API_SECRET""
# Vonage Application ID (From the application you created)
VONAGE_APPLICATION_ID=""YOUR_APPLICATION_ID""
# Path to your downloaded private key file
# IMPORTANT: See security warning in Section 4. For development, if the key is
# in the project root, this path is correct. For production, use a secure location.
VONAGE_PRIVATE_KEY=""./private.key""
# Vonage WhatsApp Sandbox Number (From Messages API Sandbox page)
# Use the format without '+' or leading '00', e.g., 14157386102
VONAGE_WHATSAPP_NUMBER=""14157386102""
# Vonage Signature Secret (Found in Vonage Dashboard -> Settings -> API settings)
# Used to verify webhook signatures. Use the 'Primary' or 'Secondary' secret.
VONAGE_API_SIGNATURE_SECRET=""YOUR_SIGNATURE_SECRET""
# Server Port (Optional, defaults to 8000 in server.js if not set)
PORT=8000
VONAGE_API_KEY / VONAGE_API_SECRET: Found on the main page of your Vonage API Dashboard.
VONAGE_APPLICATION_ID: Found on the specific application page you created in step 2.
VONAGE_PRIVATE_KEY: The path to the private.key file you downloaded. Adjust if you saved it elsewhere, following security best practices.
VONAGE_WHATSAPP_NUMBER: The dedicated phone number shown on the Messages API Sandbox page for WhatsApp.
VONAGE_API_SIGNATURE_SECRET: Go to Settings in the Vonage Dashboard. Under API settings, find your Signature secret. Use either the primary or secondary secret here. This is crucial for webhook security.
5. Error Handling, Logging, and Retry Mechanisms
Error Handling Strategy:
Use try...catch blocks around asynchronous operations, especially Vonage API calls (sendMessage) and within webhook handlers (/inbound).
Log errors using console.error with detailed messages, including stack traces where relevant. Use structured prefixes like [ERROR].
Distinguish between expected errors (e.g., invalid input from user) and unexpected server/API errors.
Respond to Vonage webhooks appropriately: 200 OK for success, 4xx for client errors (like bad signature), 5xx for server errors. Returning non-200 for inbound messages might cause Vonage to retry.
Logging:
Current implementation uses console.log, console.warn, console.error with text prefixes. This is suitable for development.
Production Logging: Use a dedicated logging library like Pino or Winston.
Configure structured logging (JSON format) for easier parsing by log aggregation tools (e.g., Datadog, Splunk, ELK stack).
Set appropriate log levels (e.g., INFO, WARN, ERROR).
Include context in logs (e.g., message UUID, user number, request ID).
Vonage Webhook Retries: Vonage automatically retries sending webhooks (inbound/status) if your server doesn't respond with 200 OK within a timeout period. It uses an exponential backoff strategy. Ensure your endpoints respond quickly or handle potential duplicate deliveries (idempotency).
Outbound Message Retries: The current sendMessage function doesn't implement retries. For critical messages, you could wrap the vonage.messages.send call in a retry loop (e.g., using libraries like async-retry) with exponential backoff for transient network or API errors (e.g., 5xx responses from Vonage). Be cautious not to retry indefinitely or for non-recoverable errors (e.g., 4xx responses like invalid number).
6. Database Schema and Data Layer
This simple example application is stateless—it receives a message and sends an immediate reply without storing conversation history.
For Stateful Applications:
If you need to track conversations, user state, message history, or integrate with other user data, you'll need a database.
Database Choice: PostgreSQL, MongoDB, MySQL, etc.
Schema Considerations (Example - PostgreSQL):
SQL
CREATE TABLE users (
phone_number VARCHAR(20) PRIMARY KEY, -- User's WhatsApp number
whatsapp_name VARCHAR(255),
created_at TIMESTAMPWITHTIME ZONE DEFAULTCURRENT_TIMESTAMP,
last_seen_at TIMESTAMPWITHTIME ZONE
);
CREATE TABLE messages (
message_uuid UUID PRIMARY KEY, -- Internal message ID (optional, can use vonage_message_uuid)
vonage_message_uuid VARCHAR(50) UNIQUENOT NULL, -- Vonage's UUID for linking inbound/status
user_phone_number VARCHAR(20) REFERENCES users(phone_number),
direction VARCHAR(10) NOT NULL, -- 'inbound' or 'outbound'
channel VARCHAR(20) DEFAULT'whatsapp',
message_type VARCHAR(20) NOT NULL, -- 'text', 'image', 'audio', etc.
content TEXT, -- Text content or reference to media
media_url VARCHAR(1024), -- URL for images, audio, etc.
status VARCHAR(20), -- 'submitted', 'delivered', 'read', 'failed'
vonage_status_timestamp TIMESTAMPWITHTIME ZONE,
error_code VARCHAR(50),
error_reason TEXT,
created_at TIMESTAMPWITHTIME ZONE DEFAULTCURRENT_TIMESTAMP,
client_ref VARCHAR(255) -- Optional client reference
);
CREATE INDEX idx_messages_user_phone ON messages(user_phone_number);
CREATE INDEX idx_messages_status ON messages(status);
CREATE INDEX idx_messages_created_at ON messages(created_at);
Data Access Layer: Use an ORM (Object-Relational Mapper) like Prisma or Sequelize (for SQL databases) or an ODM (Object-Document Mapper) like Mongoose (for MongoDB) to interact with the database from your Node.js code. This simplifies queries, migrations, and data modeling.
Migrations: Use the migration tools provided by your ORM (e.g., prisma migrate dev, sequelize db:migrate) to manage schema changes over time.
Implementation:
In /inbound, you would query the users table (create if not exists) and insert a new record into messages with direction='inbound' and the vonage_message_uuid from the request.
Before calling sendMessage, you would insert a record into messages with direction='outbound', status='submitted', and the vonage_message_uuid returned by the API call.
In /status, you would query the messages table using message_uuid (the Vonage UUID) and update the status, vonage_status_timestamp, and error fields based on the webhook payload.
7. Security Features
Webhook Signature Verification (CRITICAL): Already implemented via verifyVonageSignature. This prevents attackers from sending fake webhook requests to your endpoints. Ensure VONAGE_API_SIGNATURE_SECRET is kept confidential and managed securely.
Private Key Security (CRITICAL): As mentioned in Section 4, do not store your private.key file within your web root or commit it to version control. Use secure storage outside the project directory or a secrets management system.
Input Validation and Sanitization:
Validate incoming webhook payloads against expected schemas
Frequently asked questions
Use the Vonage Messages API with the Node.js SDK. The sendMessage function in the provided example demonstrates how to use vonage.messages.send() with a WhatsAppText object to send messages. This handles constructing the message payload and sending it via the Vonage platform.
The Vonage Messages API is a unified API that allows developers to send and receive messages across various channels, including WhatsApp, SMS, and MMS. It simplifies the process of integrating messaging into applications and handles the underlying complexities of each channel.
Vonage uses JSON Web Tokens (JWT) for webhook security to ensure that incoming webhook requests genuinely originate from Vonage. This prevents malicious actors from spoofing webhook requests and triggering unintended actions on your server.
Use the Vonage Messages API Sandbox during development and testing to avoid incurring charges while experimenting with the API. This allows you to test your integration thoroughly before deploying it to production.
Storing private keys directly in your project directory is highly discouraged for security reasons. If compromised, this would allow access to credentials. Use secure external storage or a dedicated secrets management system instead.
Set up a webhook endpoint (e.g., /inbound) on your Node.js server that the Vonage Messages API will send incoming WhatsApp messages to. Ensure the endpoint verifies the JWT signature to validate message authenticity.
The Vonage Node.js SDK (@vonage/server-sdk, @vonage/messages, @vonage/jwt) simplifies interaction with the Vonage APIs. This includes functionality for sending messages, verifying signatures, and managing other aspects of the communication flow.
ngrok creates a public tunnel to your locally running server, making it accessible from the internet. This allows Vonage webhooks to reach your local development environment during testing.
If your application needs to store conversation history, user data, or manage message status beyond immediate replies, you'll need a database. The article provides an example database schema and implementation guidance.
Use the @vonage/jwt library's verifySignature function along with your VONAGE_API_SIGNATURE_SECRET from the Vonage dashboard to verify the JWT signature of webhook requests. The provided example code includes a middleware function to handle this.
Vonage automatically retries webhook deliveries if your server doesn't respond with a 200 OK. Be mindful of this when implementing error handling and consider idempotent designs for webhook endpoints.
Implement a webhook endpoint (e.g., /status) in your Node.js application to receive message status updates. This endpoint receives delivery, read, or failed status updates, allowing you to track message delivery in your system.
A health check endpoint (e.g., /health) provides a simple way to check the status of your application. Monitoring systems and load balancers can use it to ensure that your server is running and accessible.
Use try-catch blocks around Vonage API calls (e.g., sendMessage) and within webhook handlers to catch potential errors. Log errors with details and consider implementing retry logic for transient errors (e.g., network issues).
This guide provides a complete walkthrough for building a Node.js and Express application capable of sending and receiving WhatsApp messages using the Vonage Messages API. We will cover everything from initial project setup and core messaging logic to security, error handling, and deployment considerations.
By the end of this tutorial, you will have a functional application that can:
This enables developers to integrate WhatsApp communication directly into their applications for notifications, customer support, chatbots, and more.
Project Overview and Goals
What We're Building: A Node.js server using the Express framework that listens for incoming WhatsApp messages and message status updates sent from the Vonage platform via webhooks. When a message arrives, the server verifies it, logs it, and sends a confirmation reply back to the sender's WhatsApp number.
Problem Solved: This provides a foundational backend service for two-way WhatsApp communication, enabling applications to interact with users on a widely adopted messaging platform without needing to manage the underlying complexities of the WhatsApp Business API directly.
Technologies Used:
@vonage/server-sdk,@vonage/messages,@vonage/jwt): Simplifies interaction with the Vonage APIs, including sending messages and verifying signatures..envfile intoprocess.env.System Architecture:
Note: ASCII diagrams render best with fixed-width fonts. A graphical diagram might be preferable for final publication.
Prerequisites:
1. Setting up the Project
Let's initialize our Node.js project and install the necessary dependencies.
Create Project Directory: Open your terminal and create a new directory for the project, then navigate into it.
mkdir vonage-whatsapp-app cd vonage-whatsapp-appInitialize npm: Initialize the project using npm, accepting the defaults. This creates a
package.jsonfile.Install Dependencies: Install Express for the web server, the Vonage SDKs for interacting with the API, and dotenv for managing environment variables.
express: Web framework for handling HTTP requests (webhooks).dotenv: Loads environment variables from a.envfile.@vonage/server-sdk: Core Vonage SDK.@vonage/messages: Specific helpers for the Messages API.@vonage/jwt: Utilities for handling JWTs, specifically for signature verification.Note:
npm installwithout flags like--saveor--save-devdefaults to adding packages as production dependencies in recent npm versions. This also generates/updatespackage-lock.json, which is important for deterministic installs, especially when usingnpm cilater in CI/CD pipelines.Create Project Structure: Create a source directory and the main server file.
mkdir src touch src/server.jsCreate
.envFile: This file will store sensitive credentials and configuration. Never commit this file to version control.touch .envWe will populate this file later in the guide.
Create
.gitignoreFile: Prevent sensitive files and unnecessary directories from being committed to Git.touch .gitignoreAdd the following lines to your
.gitignorefile:Your basic project structure should now look like this:
This setup provides a clean separation of source code, dependencies, and configuration.
2. Implementing Core Functionality
Now, let's write the core logic in
src/server.jsto handle incoming messages and send replies.src/server.js// src/server.js require('dotenv').config(); // Load environment variables from .env file const express = require('express'); const { Vonage } = require('@vonage/server-sdk'); const { WhatsAppText } = require('@vonage/messages'); const { verifySignature } = require('@vonage/jwt'); const app = express(); app.use(express.json()); // Middleware to parse JSON request bodies app.use(express.urlencoded({ extended: true })); // Middleware to parse URL-encoded bodies // --- Configuration --- const PORT = process.env.PORT || 8000; // Use port from .env or default to 8000 const VONAGE_API_KEY = process.env.VONAGE_API_KEY; const VONAGE_API_SECRET = process.env.VONAGE_API_SECRET; const VONAGE_APPLICATION_ID = process.env.VONAGE_APPLICATION_ID; const VONAGE_PRIVATE_KEY = process.env.VONAGE_PRIVATE_KEY; // Path to your private.key file const VONAGE_WHATSAPP_NUMBER = process.env.VONAGE_WHATSAPP_NUMBER; // Vonage WhatsApp Sandbox number const VONAGE_API_SIGNATURE_SECRET = process.env.VONAGE_API_SIGNATURE_SECRET; // --- Input Validation (Basic Checks) --- if (!VONAGE_API_KEY || !VONAGE_API_SECRET || !VONAGE_APPLICATION_ID || !VONAGE_PRIVATE_KEY || !VONAGE_WHATSAPP_NUMBER || !VONAGE_API_SIGNATURE_SECRET) { console.error('[ERROR] Missing required Vonage environment variables. Please check your .env file.'); process.exit(1); // Exit if essential config is missing } // --- Initialize Vonage Client --- const vonage = new Vonage( { apiKey: VONAGE_API_KEY, apiSecret: VONAGE_API_SECRET, applicationId: VONAGE_APPLICATION_ID, privateKey: VONAGE_PRIVATE_KEY, }, { apiHost: 'https://messages-sandbox.nexmo.com' // Use Sandbox URL for testing // For production: Remove the 'apiHost' option. // The SDK defaults to the production Messages API endpoint (e.g., 'https://api.nexmo.com/v1/messages'). // Explicitly setting it to 'https://api.nexmo.com' is also possible if needed. } ); // --- JWT Signature Verification Middleware --- // Verifies that incoming webhooks genuinely originated from Vonage const verifyVonageSignature = (req, res, next) => { try { // Get JWT from Authorization header (format: Bearer <token>) const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { console.warn('[WARN] Missing or invalid Authorization header.'); return res.status(401).send('Unauthorized: Missing Bearer token.'); } const jwtToken = authHeader.split(' ')[1]; // Verify the signature using the secret from .env if (!verifySignature(jwtToken, VONAGE_API_SIGNATURE_SECRET)) { console.error('[ERROR] Invalid JWT signature.'); return res.status(401).send('Unauthorized: Invalid signature.'); } console.log('[INFO] JWT Signature Verified'); next(); // Proceed to the next middleware/handler if signature is valid } catch (error) { console.error('[ERROR] Error verifying JWT:', error); res.status(500).send('Internal Server Error during signature verification.'); } }; // --- Function to Send WhatsApp Message --- const sendMessage = async (to_number, text = 'Message received.') => { console.log(`[INFO] Attempting to send message to ${to_number}...`); try { const { message_uuid } = await vonage.messages.send( new WhatsAppText({ from: VONAGE_WHATSAPP_NUMBER, // Must be the Vonage Sandbox number or your provisioned WA number to: to_number, // The recipient's phone number text: text, // The message content client_ref: `msg-to-${to_number}-${Date.now()}` // Optional client reference }) ); console.log(`[SUCCESS] Message sent successfully to ${to_number}. Message UUID: ${message_uuid}`); return message_uuid; // Return the UUID for tracking } catch (error) { console.error(`[ERROR] Error sending message to ${to_number}:`, error.response ? error.response.data : error.message); // Log detailed Vonage API error if available if (error.response && error.response.data) { console.error('[ERROR] Vonage API Error Details:', JSON.stringify(error.response.data, null, 2)); } // Re-throw the error or handle it as needed throw error; } }; // --- Webhook Endpoints --- // 1. Inbound Message Webhook (/inbound) // Handles messages sent *from* WhatsApp users *to* your Vonage number app.post('/inbound', verifyVonageSignature, async (req, res) => { console.log('[INFO] --- Received Inbound Message ---'); console.log('[INFO] Request Body:', JSON.stringify(req.body, null, 2)); const { from: requesterNumber, message } = req.body; // Basic validation of incoming payload if (!requesterNumber || !message || !message.content || message.content.type !== 'text') { console.warn('[WARN] Received incomplete or non-text message payload.'); // Acknowledge receipt even if we don't process it fully here return res.status(200).send('Payload received but not processed (expected text message).'); } const incomingText = message.content.text; console.log(`[INFO] Received text message ""${incomingText}"" from ${requesterNumber}`); try { // Send a reply message back to the user await sendMessage(requesterNumber, `[REPLY] Got it! You said: ""${incomingText}""`); res.status(200).send('Message processed and reply sent.'); // Acknowledge successful processing } catch (error) { console.error('[ERROR] Error handling incoming message or sending reply:', error); // Send 500 but Vonage might retry. Log carefully. res.status(500).send('An error occurred while processing the message.'); } }); // 2. Status Update Webhook (/status) // Handles delivery receipts and status updates for messages *sent from* your application app.post('/status', verifyVonageSignature, (req, res) => { console.log('[INFO] --- Received Status Update ---'); console.log('[INFO] Request Body:', JSON.stringify(req.body, null, 2)); // Process the status update (e.g., update message status in a database) const { message_uuid, status, timestamp, to, error } = req.body; console.log(`[INFO] Status update for message ${message_uuid} to ${to}: ${status} at ${timestamp}`); if (error) { console.error(`[ERROR] Message delivery error: ${error.type} - ${error.reason}`); } // Acknowledge receipt to Vonage res.status(200).send('Status received.'); }); // 3. Health Check Endpoint (/health) // Simple endpoint for monitoring services to check if the app is running app.get('/health', (req, res) => { res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() }); }); // --- Start the Server --- const server = app.listen(PORT, () => { // Assign server to variable for graceful shutdown console.log(`[INFO] Server is running and listening on http://localhost:${PORT}`); console.log(`[INFO] Webhook endpoints:`); console.log(` POST /inbound (for incoming messages)`); console.log(` POST /status (for message status updates)`); console.log(`[INFO] Ensure ngrok exposes port ${PORT} and webhooks are configured in Vonage.`); }); // --- Graceful Shutdown Handling (Optional but Recommended) --- const cleanup = (signal) => { console.info(`[INFO] ${signal} signal received: closing HTTP server`); server.close(() => { console.log('[INFO] HTTP server closed.'); // Add other cleanup logic here (e.g., close database connections) process.exit(0); }); }; process.on('SIGTERM', () => cleanup('SIGTERM')); process.on('SIGINT', () => cleanup('SIGINT')); // Export app for testing purposes module.exports = { app, sendMessage }; // Export app and potentially other functions if needed for testingExplanation:
.envvariables, and sets up basic configuration constants. Includes checks for essential variables.Vonageclient with credentials from.env. TheapiHostoption directs requests to the Sandbox environment. Removed or changed for production use.verifyVonageSignatureMiddleware: This crucial function intercepts requests to webhook endpoints. It extracts the JWT from theAuthorization: Bearer <token>header and usesverifySignaturefrom@vonage/jwtalong with yourVONAGE_API_SIGNATURE_SECRETto confirm the request genuinely came from Vonage. Unauthorized requests are rejected with a 401 status.sendMessageFunction: Encapsulates the logic for sending a WhatsApp message usingvonage.messages.send. It constructs aWhatsAppTextobject and handles potential errors, logging success or failure details./inboundWebhook:/inbound.verifyVonageSignaturemiddleware first.req.body) to get the sender's number (from) and message content.sendMessageto send a reply.200 OKto Vonage to acknowledge receipt and prevent retries. Error handling returns500./statusWebhook:/status.verifyVonageSignaturemiddleware.delivered,read,failed).200 OK. In a real application, you'd update your database here./healthEndpoint: A simple GET endpoint useful for monitoring tools to check if the server is alive.PORT.SIGTERMandSIGINT(common signals for stopping processes) ensures cleaner shutdowns by closing the server before exiting.appinstance andsendMessagefunction are exported at the bottom. This is a common pattern to allow importing them into test files.3. API Layer Details (Webhooks)
In this application, the ""API layer"" consists of the webhook endpoints that Vonage interacts with. There isn't a separate user-facing REST API being built here.
Authentication & Authorization: Handled exclusively by the
verifyVonageSignaturemiddleware, ensuring only legitimate requests from Vonage are processed.Request Validation: Basic validation is included in the
/inboundhandler to check for necessary fields (from,message.content.type === 'text'). For production, use libraries likeexpress-validatorfor more robust schema validation.Endpoints:
POST /inboundAuthorization: Bearer <JWT_TOKEN>(Verified by middleware).{ ""message_uuid"": ""a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8"", ""to"": ""14157386102"", ""from"": ""1XXXXXXXXXX"", ""timestamp"": ""2023-10-27T10:30:00.123Z"", ""channel"": ""whatsapp"", ""message"": { ""content"": { ""type"": ""text"", ""text"": ""Hello Vonage!"" } }, ""context"": { ""message_uuid"": ""..."", ""message_from"": ""..."" }, ""profile"": { ""name"": ""Jane Doe"" } }200 OK(Plain text or JSON): Indicates successful receipt and processing.401 Unauthorized: If JWT signature verification fails.500 Internal Server Error: If an error occurs during processing.POST /statusAuthorization: Bearer <JWT_TOKEN>(Verified by middleware).{ ""message_uuid"": ""e295613c-a2fc-40f4-a9ae-fa16f5c685cf"", ""to"": ""1XXXXXXXXXX"", ""from"": ""14157386102"", ""timestamp"": ""2023-10-27T10:30:05.456Z"", ""status"": ""delivered"", ""channel"": ""whatsapp"", ""usage"": { ""currency"": ""EUR"", ""price"": ""0.0075"" }, ""client_ref"": ""msg-to-1XXXXXXXXXX-1678886400000"" }200 OK: Acknowledges receipt of the status.401 Unauthorized: If JWT signature verification fails.Testing with
curl(Simulating Vonage): Directly testing webhooks that require valid, signed JWTs withcurlis difficult. You would need to generate a valid JWT signed with theVONAGE_API_SIGNATURE_SECRET. For local testing, triggering the flow by sending a real WhatsApp message (see Section 5) is the most practical approach. Automated integration tests (also Section 5) can mock the signature verification.4. Integrating with Vonage (Setup & Configuration)
This section details the necessary steps within the Vonage Dashboard and your local environment.
Sign Up/In to Vonage: If you haven't already, create an account at vonage.com.
Create a Vonage Application:
private.keyfile will download../private.keyin.envfor simplicity, assuming you save the key in your project root. This is NOT recommended for production. Storing private keys directly in your application directory increases risk if your code repository or server filesystem is compromised.private.keyfile outside your project directory (e.g., in a secure parent directory inaccessible to the web server) and use an absolute path or path relative to the user's home directory in.env. Alternatively, use a dedicated secrets management system (like HashiCorp Vault, AWS Secrets Manager, Google Secret Manager) to inject the key content as an environment variable or mount it securely. Ensure the key file has strict permissions (read-only for the application user). Remember to addprivate.keyto your.gitignoreif storing it locally during development.Start ngrok: Open a new terminal window (keep your Node server terminal running later). Run ngrok to expose the port your Express server will listen on (default is 8000, or whatever you set in
.env).# Make sure you are logged into ngrok (ngrok config add-authtoken YOUR_TOKEN) ngrok http 8000ngrok will display output similar to this:
Copy the
https://<UNIQUE_SUBDOMAIN>.ngrok-free.appURL. This is your public base URL. Keep this ngrok terminal running.Configure Webhooks in Vonage Application:
YOUR_NGROK_URL/inbound(e.g.,https://<UNIQUE_SUBDOMAIN>.ngrok-free.app/inbound)YOUR_NGROK_URL/status(e.g.,https://<UNIQUE_SUBDOMAIN>.ngrok-free.app/status)Set up Messages API Sandbox for WhatsApp:
YOUR_NGROK_URL/inboundYOUR_NGROK_URL/statusPopulate the
.envFile: Open the.envfile in your project and add the following variables, replacing the placeholder values with your actual credentials:VONAGE_API_KEY/VONAGE_API_SECRET: Found on the main page of your Vonage API Dashboard.VONAGE_APPLICATION_ID: Found on the specific application page you created in step 2.VONAGE_PRIVATE_KEY: The path to theprivate.keyfile you downloaded. Adjust if you saved it elsewhere, following security best practices.VONAGE_WHATSAPP_NUMBER: The dedicated phone number shown on the Messages API Sandbox page for WhatsApp.VONAGE_API_SIGNATURE_SECRET: Go to Settings in the Vonage Dashboard. Under API settings, find your Signature secret. Use either the primary or secondary secret here. This is crucial for webhook security.5. Error Handling, Logging, and Retry Mechanisms
try...catchblocks around asynchronous operations, especially Vonage API calls (sendMessage) and within webhook handlers (/inbound).console.errorwith detailed messages, including stack traces where relevant. Use structured prefixes like[ERROR].200 OKfor success,4xxfor client errors (like bad signature),5xxfor server errors. Returning non-200for inbound messages might cause Vonage to retry.console.log,console.warn,console.errorwith text prefixes. This is suitable for development.PinoorWinston.// const pino = require('pino')(); // pino.info({ msgUuid: message_uuid, status: status }, 'Received status update'); // pino.error({ err: error, msgUuid: message_uuid }, 'Error sending message');200 OKwithin a timeout period. It uses an exponential backoff strategy. Ensure your endpoints respond quickly or handle potential duplicate deliveries (idempotency).sendMessagefunction doesn't implement retries. For critical messages, you could wrap thevonage.messages.sendcall in a retry loop (e.g., using libraries likeasync-retry) with exponential backoff for transient network or API errors (e.g., 5xx responses from Vonage). Be cautious not to retry indefinitely or for non-recoverable errors (e.g., 4xx responses like invalid number).6. Database Schema and Data Layer
This simple example application is stateless—it receives a message and sends an immediate reply without storing conversation history.
For Stateful Applications:
If you need to track conversations, user state, message history, or integrate with other user data, you'll need a database.
CREATE TABLE users ( phone_number VARCHAR(20) PRIMARY KEY, -- User's WhatsApp number whatsapp_name VARCHAR(255), created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, last_seen_at TIMESTAMP WITH TIME ZONE ); CREATE TABLE messages ( message_uuid UUID PRIMARY KEY, -- Internal message ID (optional, can use vonage_message_uuid) vonage_message_uuid VARCHAR(50) UNIQUE NOT NULL, -- Vonage's UUID for linking inbound/status user_phone_number VARCHAR(20) REFERENCES users(phone_number), direction VARCHAR(10) NOT NULL, -- 'inbound' or 'outbound' channel VARCHAR(20) DEFAULT 'whatsapp', message_type VARCHAR(20) NOT NULL, -- 'text', 'image', 'audio', etc. content TEXT, -- Text content or reference to media media_url VARCHAR(1024), -- URL for images, audio, etc. status VARCHAR(20), -- 'submitted', 'delivered', 'read', 'failed' vonage_status_timestamp TIMESTAMP WITH TIME ZONE, error_code VARCHAR(50), error_reason TEXT, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, client_ref VARCHAR(255) -- Optional client reference ); CREATE INDEX idx_messages_user_phone ON messages(user_phone_number); CREATE INDEX idx_messages_status ON messages(status); CREATE INDEX idx_messages_created_at ON messages(created_at);prisma migrate dev,sequelize db:migrate) to manage schema changes over time./inbound, you would query theuserstable (create if not exists) and insert a new record intomessageswithdirection='inbound'and thevonage_message_uuidfrom the request.sendMessage, you would insert a record intomessageswithdirection='outbound',status='submitted', and thevonage_message_uuidreturned by the API call./status, you would query themessagestable usingmessage_uuid(the Vonage UUID) and update thestatus,vonage_status_timestamp, and error fields based on the webhook payload.7. Security Features
verifyVonageSignature. This prevents attackers from sending fake webhook requests to your endpoints. EnsureVONAGE_API_SIGNATURE_SECRETis kept confidential and managed securely.private.keyfile within your web root or commit it to version control. Use secure storage outside the project directory or a secrets management system.