How to Send WhatsApp Messages with Node.js and Fastify | Vonage API Tutorial
Learn how to build WhatsApp messaging into your Node.js app using Vonage Messages API and Fastify. Step-by-step guide with webhook authentication, message sending/receiving, and production-ready code examples.
Note: This article covers Vonage Messages API integration. The filename references Twilio, but the content is specifically for Vonage's WhatsApp Business API.
Learn how to send and receive WhatsApp messages programmatically using the Vonage Messages API with Node.js v22 and Fastify v5. This step-by-step tutorial shows you how to build a production-ready WhatsApp integration, from initial setup to secure webhook handling with JWT authentication, complete with working code examples you can deploy today.
This integration enables real-world use cases like customer support automation, appointment reminders, order notifications, two-factor authentication (2FA), and interactive chatbots—all through WhatsApp Business API.
By the end of this tutorial, you'll have a functional Fastify application capable of:
Sending WhatsApp messages via the Vonage Messages API
Receiving incoming WhatsApp messages through secure webhooks
Validating webhook signatures using JWT Bearer tokens to ensure requests originate from Vonage
Implementing production-grade logging and error handling
This guide assumes you have foundational knowledge of Node.js, asynchronous programming (async/await), and REST APIs.
What You'll Build: WhatsApp Messaging API Integration
Problem: Businesses need reliable ways to communicate with customers on preferred channels like WhatsApp. Building this integration from scratch involves handling API authentication, message sending protocols, receiving incoming messages (webhooks), and securing these endpoints.
Solution: Build a Node.js backend using the Fastify framework to interact with the Vonage Messages API. This provides a structured way to send messages and expose secure HTTP endpoints (webhooks) for receiving messages and delivery status updates from WhatsApp via Vonage.
Technologies:
Node.js: JavaScript runtime environment for server-side development
Fastify: High-performance, low-overhead web framework for Node.js, known for its speed and developer experience
Vonage Messages API: Unified API for sending and receiving messages across various channels, including WhatsApp Business API, SMS, MMS, and Facebook Messenger
Vonage Node SDK (@vonage/server-sdk): Simplifies interaction with Vonage APIs in Node.js applications
dotenv: Manages environment variables securely
ngrok (for development): Exposes local development servers to the internet for webhook testing
jsonwebtoken: Validates JWT tokens for webhook authentication
A publicly accessible URL for webhook testing (use ngrok for local development)
Basic familiarity with terminal/command line usage
A WhatsApp-enabled mobile number for testing
Version Compatibility (2025):
Vonage Node SDK: v3.24.1 or later (@vonage/server-sdk)
Fastify: v5.x (requires Node.js v20+)
Node.js: v22.x LTS recommended, v20.x minimum
jsonwebtoken: Latest version for JWT validation
Final Outcome: A Fastify application with endpoints to send WhatsApp messages and receive/validate incoming message webhooks from Vonage with production-ready security.
Step 1: Set Up Your Node.js Development Environment
Ensure Node.js and npm/yarn are installed. Verify by opening your terminal and running:
Bash
node -v
npm -v
# or# yarn -v
If not installed, download and install Node.js from nodejs.org. For additional setup guidance, check our Node.js SMS tutorial with Express which covers similar environment configuration.
Step 2: Create Your Fastify Project Structure
Create your project directory and initialize it with npm (or yarn).
Create Project Directory:
Bash
mkdir fastify-vonage-whatsapp
cd fastify-vonage-whatsapp
Initialize Node.js Project:
Bash
npm init -y
# or# yarn init -y
This creates a package.json file.
Install Dependencies: Install production dependencies first:
Configure your Vonage account and the WhatsApp Sandbox before writing code.
Sign Up/Log In: Go to the Vonage Dashboard and create an account or log in.
API Credentials: Navigate to the API settings section (usually under your profile name or "API Settings"). Note down your API Key and API Secret. You'll need these shortly.
Set up WhatsApp Sandbox:
In the Vonage Dashboard sidebar, find "Messages and Dispatch" > "Sandbox"
Activate the WhatsApp Sandbox by scanning the QR code with your WhatsApp app or sending the specified message to the provided Vonage sandbox number
Keep this Sandbox page open; you'll need the Vonage sandbox number and to configure webhooks later. The Sandbox allows testing without needing a dedicated WhatsApp Business number initially.
Generate Signature Secret: In the Vonage Dashboard, navigate to the webhook settings for your Messages API Sandbox. Generate or note the Signature Secret used for JWT webhook validation. This is a shared secret between you and Vonage used to verify webhook authenticity.
Step 4: Set Up API Credentials and Environment Variables
Use environment variables to store sensitive credentials and configuration details.
Create .env.example: This file serves as a template for required variables.
.env
# .env.example
# Vonage Credentials
VONAGE_API_KEY=YOUR_VONAGE_API_KEY
VONAGE_API_SECRET=YOUR_VONAGE_API_SECRET
VONAGE_APPLICATION_ID=OPTIONAL_VONAGE_APPLICATION_ID # Needed for JWT-based authentication
VONAGE_PRIVATE_KEY_PATH=OPTIONAL_PATH_TO_PRIVATE_KEY # Needed for JWT-based authentication
VONAGE_SIGNATURE_SECRET=YOUR_VONAGE_WEBHOOK_SIGNATURE_SECRET # Get from Vonage Dashboard for JWT webhook validation
# Vonage Numbers
VONAGE_WHATSAPP_NUMBER=VONAGE_SANDBOX_NUMBER # Get this from the Vonage Sandbox page
# Application Settings
PORT=3000
HOST=0.0.0.0
LOG_LEVEL=info
# Webhook Base URL (for ngrok or deployed env)
WEBHOOK_BASE_URL=YOUR_NGROK_OR_DEPLOYED_URL
Create .env: Duplicate .env.example, rename it to .env, and fill in your actual values.
VONAGE_API_KEY, VONAGE_API_SECRET: From your Vonage dashboard
VONAGE_WHATSAPP_NUMBER: The phone number provided on the Vonage WhatsApp Sandbox page
VONAGE_SIGNATURE_SECRET: Obtain this from your Vonage Dashboard under the webhook settings for Messages API. This is the shared secret used to validate JWT tokens in webhook requests.
WEBHOOK_BASE_URL: Leave this blank for now; fill it when running ngrok
VONAGE_APPLICATION_ID, VONAGE_PRIVATE_KEY_PATH: Not required for the basic authentication method (API Key/Secret) used in this guide but needed for advanced JWT-based authentication features
Load Environment Variables: Modify your package.json scripts to use dotenv (for loading .env files) and pino-pretty (for readable logs) during development:
The start script runs the server directly (suitable for production where env vars are set externally)
The dev script uses -r dotenv/config to preload environment variables from your .env file before the application starts. It then pipes the JSON output logs through pino-pretty for better readability in the terminal
5. Installing Vonage SDK (Already Done)
We installed @vonage/server-sdk and jsonwebtoken in Step 2. These packages provide convenient methods for interacting with the Vonage API and validating webhook JWTs.
Step 6: How to Send WhatsApp Messages Programmatically
Create the Fastify application structure and add a route to send messages.
Set up Fastify App (src/app.js):
JavaScript
// src/app.js'use strict';
constFastify = require('fastify');
const vonageRoutes = require('./routes/vonage');
const { Vonage } = require('@vonage/server-sdk');
functionbuild(opts = {}) {
const app = Fastify(opts);
// Initialize Vonage Client// Note: Ensure VONAGE_API_KEY and VONAGE_API_SECRET are set in .envconst vonage = newVonage({
apiKey: process.env.VONAGE_API_KEY,
apiSecret: process.env.VONAGE_API_SECRET,
// Note: applicationId and privateKey are not needed for Key/Secret auth
});
// Make Vonage client accessible in routes
app.decorate('vonage', vonage);
// Register routes
app.register(vonageRoutes, { prefix: '/api/vonage' });
// Basic health check route
app.get('/health', async (request, reply) => {
return { status: 'ok', timestamp: newDate().toISOString() };
});
return app;
}
module.exports = build;
Initialize Fastify
Create a Vonage client instance using API Key and Secret from environment variables
Use app.decorate to make the vonage client easily available within route handlers (request.server.vonage)
Register specific Vonage routes under the /api/vonage prefix
Include a simple /health check endpoint
Create Server Entry Point (src/server.js):
JavaScript
// src/server.js'use strict';
// Read .env file if not preloaded (e.g., via `node -r dotenv/config`)// This ensures it works if started with just `node src/server.js` in devif (process.env.NODE_ENV !== 'production' && !process.env.DOTENV_CONFIG_PATH && require('fs').existsSync('.env')) {
console.log('Loading .env file for non-production environment.');
require('dotenv').config();
}
const server = require('./app')({
logger: {
level: process.env.LOG_LEVEL || 'info',
// pino-pretty is handled by the `npm run dev` script pipe, not here
},
});
conststart = async () => {
try {
const port = parseInt(process.env.PORT || '3000', 10);
const host = process.env.HOST || '0.0.0.0';
await server.listen({ port: port, host: host });
// Note: Fastify logs the listening address automatically with default logger settings.
server.log.info(`Access health check at http://localhost:${port}/health`);
server.log.info(`API prefix: /api/vonage`);
} catch (err) {
server.log.error(err);
process.exit(1);
}
};
start();
Configure the Fastify logger based on the environment variable
Start the server, listening on the configured host and port
Include a check to load .env if not preloaded via the script (useful for some debugging scenarios)
Create Send Message Route (src/routes/vonage.js):
JavaScript
// src/routes/vonage.js'use strict';
const jwt = require('jsonwebtoken');
const sendMessageSchema = {
body: {
type: 'object',
required: ['to', 'text'],
properties: {
to: { type: 'string', description: 'Recipient WhatsApp number (E.164 format)', pattern: '^\\+?[1-9]\\d{1,14}' }, // Basic E.164 patterntext: { type: 'string', description: 'Message content', minLength: 1 },
},
},
response: {
200: {
type: 'object',
properties: {
message_uuid: { type: 'string' },
detail: { type: 'string' }
}
},
// Define common error responses for better OpenAPI/Swagger documentation400: { $ref: 'http://example.com/schemas/error#/definitions/badRequest' }, // Placeholder500: { $ref: 'http://example.com/schemas/error#/definitions/serverError' } // Placeholder
}
};
// --- Helper function for JWT Signature Validation ---// Vonage Messages API uses JWT Bearer tokens in the Authorization headerfunctionvalidateVonageJWT(request, secret, log) {
try {
// Extract the JWT from the Authorization headerconst authHeader = request.headers['authorization'];
if (!authHeader) {
log.warn('Missing Authorization header in webhook request.');
returnfalse;
}
// Authorization header format: "Bearer <jwt_token>"const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
log.warn('Invalid Authorization header format. Expected "Bearer <token>".');
returnfalse;
}
const token = parts[1];
// Verify and decode the JWT using the signature secretconst decoded = jwt.verify(token, secret, {
algorithms: ['HS256'] // Vonage uses HMAC-SHA256
});
log.info('JWT signature validated successfully.');
// Optional: Verify payload_hash to ensure payload hasn't been tampered withif (decoded.payload_hash && request.body) {
const crypto = require('crypto');
const bodyString = JSON.stringify(request.body);
const calculatedHash = crypto.createHash('sha256').update(bodyString).digest('hex');
if (decoded.payload_hash !== calculatedHash) {
log.warn('Payload hash mismatch. Possible tampering detected.');
returnfalse;
}
log.info('Payload hash verified successfully.');
}
// Optional: Check token age using 'iat' (issued at) claimif (decoded.iat) {
const tokenAge = Date.now() / 1000 - decoded.iat;
constMAX_TOKEN_AGE = 300; // 5 minutesif (tokenAge > MAX_TOKEN_AGE) {
log.warn(`Token is stale (age: ${tokenAge}s). Possible replay attack.`);
returnfalse;
}
}
returntrue; // JWT is valid
} catch (error) {
if (error.name === 'JsonWebTokenError') {
log.warn('Invalid JWT signature.');
} elseif (error.name === 'TokenExpiredError') {
log.warn('JWT token has expired.');
} else {
log.error({ msg: 'Error during JWT validation', error: error.message, stack: error.stack });
}
returnfalse;
}
} // End of validateVonageJWTasyncfunctionvonageRoutes(fastify, options) {
const vonage = fastify.vonage; // Access decorated Vonage clientconst signatureSecret = process.env.VONAGE_SIGNATURE_SECRET;
// --- Send WhatsApp Message Route ---
fastify.post('/send-whatsapp', { schema: sendMessageSchema }, async (request, reply) => {
const { to, text } = request.body;
constfrom = process.env.VONAGE_WHATSAPP_NUMBER;
if (!from) {
fastify.log.error('VONAGE_WHATSAPP_NUMBER environment variable not set.');
return reply.status(500).send({ error: 'Server configuration error: Missing sender number.' });
}
fastify.log.info(`Attempting to send WhatsApp message from ${from} to ${to}`);
try {
const resp = await vonage.messages.send({
message_type: "text",
text: text,
to: to,
from: from,
channel: "whatsapp"
});
fastify.log.info(`Message sent successfully. UUID: ${resp.messageUuid}`);
return reply.send({
message_uuid: resp.messageUuid,
detail: `Message sent to ${to}`
});
} catch (error) {
fastify.log.error({ msg: 'Error sending WhatsApp message', error: error?.response?.data || error.message, statusCode: error?.response?.status });
// Provide more specific error feedback if possiblelet statusCode = 500;
let errorMessage = 'Failed to send message due to an internal server error.';
if (error.response?.data) {
// Use Vonage's error details if available
errorMessage = error.response.data.title || error.response.data.detail || errorMessage;
if (error.response.status >= 400 && error.response.status < 500) {
statusCode = error.response.status;
}
} elseif (error.message && error.message.toLowerCase().includes('number')) { // Basic check for common number format issues
statusCode = 400;
errorMessage = 'Invalid recipient number format. Ensure it uses E.164 format (e.g., +14155552671).';
}
// Return a structured errorreturn reply.status(statusCode).send({
error: {
message: errorMessage,
type: error.response?.data?.type, // Include Vonage error type if availabledetails: error.response?.data?.invalid_parameters// Include specific invalid params if available
}
});
}
});
// --- Inbound Message Webhook ---
fastify.post('/webhooks/inbound', {
// Optional: Add schema validation for expected inbound webhook structure if needed
}, async (request, reply) => {
fastify.log.info('Received inbound webhook');
// 1. JWT Signature Validation (CRITICAL for Security)if (signatureSecret) {
if (!validateVonageJWT(request, signatureSecret, fastify.log)) {
fastify.log.warn('Invalid webhook JWT signature received.');
return reply.status(401).send({ error: 'Invalid signature' });
}
} else {
// WARNING: Only skip validation in very specific, controlled non-production scenarios.
fastify.log.warn('Webhook signature validation skipped (VONAGE_SIGNATURE_SECRET not set). THIS IS INSECURE.');
}
// 2. Process the messageconst inboundData = request.body;
fastify.log.info({ msg: 'Inbound message data:', data: inboundData });
// Example: Log sender and message textif (inboundData?.from?.type === 'whatsapp' && inboundData?.message?.content?.text) {
fastify.log.info(`Message from ${inboundData.from.number}: ${inboundData.message.content.text}`);
// TODO: Add your business logic here (e.g., reply, store in DB, queue for processing)
} else {
fastify.log.warn('Received inbound webhook with unexpected structure or non-text message.');
}
// 3. Acknowledge receipt immediately// Vonage requires a 200 OK response quickly. Defer long processing.
reply.status(200).send('OK');
});
// --- Message Status Webhook ---
fastify.post('/webhooks/status', {
// Optional: Add schema validation for expected status webhook structure if needed
}, async (request, reply) => {
fastify.log.info('Received status webhook');
// 1. JWT Signature Validation (CRITICAL for Security)if (signatureSecret) {
if (!validateVonageJWT(request, signatureSecret, fastify.log)) {
fastify.log.warn('Invalid webhook JWT signature received.');
return reply.status(401).send({ error: 'Invalid signature' });
}
} else {
fastify.log.warn('Webhook signature validation skipped (VONAGE_SIGNATURE_SECRET not set). THIS IS INSECURE.');
}
// 2. Process the status updateconst statusData = request.body;
fastify.log.info({ msg: 'Message status update:', data: statusData });
// Example: Log message UUID and statusif (statusData?.message_uuid && statusData?.status) {
fastify.log.info(`Status for message ${statusData.message_uuid}: ${statusData.status} (Timestamp: ${statusData.timestamp})`);
// TODO: Update message status in your database if needed
} else {
fastify.log.warn('Received status webhook with unexpected structure.');
}
// 3. Acknowledge receipt immediately
reply.status(200).send('OK');
});
} // End of vonageRoutes plugin functionmodule.exports = vonageRoutes;
Define a sendMessageSchema for request body validation using Fastify's built-in capabilities, including a basic E.164 pattern check for the to number
The route handler /api/vonage/send-whatsapp takes the to number and text from the request body
Retrieve the VONAGE_WHATSAPP_NUMBER from environment variables to use as the from number
Call vonage.messages.send() with the required parameters for a text message via WhatsApp
Enhanced logging and error handling included, attempting to extract and return specific error details from the Vonage API response
The validateVonageJWT helper function implements JWT validation using the jsonwebtoken library
Webhook routes (/webhooks/inbound, /webhooks/status) use the helper for signature validation and log incoming data, responding quickly with 200 OK
Test Sending:
Run the development server: npm run dev (or yarn dev)
Open a new terminal and use curl (or a tool like Postman) to send a request. Replace YOUR_WHATSAPP_NUMBER with your actual number linked to the Vonage Sandbox (in E.164 format, e.g., +14155552671)
Bash
curl -X POST http://localhost:3000/api/vonage/send-whatsapp \
-H "Content-Type: application/json" \
-d '{
"to": "YOUR_WHATSAPP_NUMBER",
"text": "Hello from Fastify and Vonage!"
}'
You should receive the WhatsApp message on your phone and see a success response in the terminal (like {"message_uuid":"...","detail":"Message sent to YOUR_WHATSAPP_NUMBER"}). Check the server logs as well.
Step 7: How to Receive WhatsApp Messages via Webhooks
Vonage uses webhooks to notify your application about incoming WhatsApp messages and message status updates. The routes for this (/api/vonage/webhooks/inbound and /api/vonage/webhooks/status) are already included in src/routes/vonage.js from the previous step.
Step 8: Expose Your Local Server for Webhook Testing
Allow Vonage's servers to reach your local development machine using ngrok.
Install ngrok: Follow the instructions at ngrok.com. Sign up for a free account and install an auth token.
Start Your Fastify App: If it's not running, start it: npm run dev. Note the port (default is 3000).
Start ngrok: Open a new terminal window and run:
Bash
ngrok http 3000
(Replace 3000 if your app uses a different port).
Copy the ngrok URL: ngrok will display forwarding URLs. Copy the https URL (e.g., https://random-string.ngrok-free.app).
Update .env: Set the WEBHOOK_BASE_URL in your .env file to this ngrok URL.
.env
# .env
# ... other vars
WEBHOOK_BASE_URL=https://<your-random-string>.ngrok-free.app # Use the actual URL from ngrok output
Restart Fastify App: Stop (Ctrl+C) and restart your Fastify app (npm run dev) to pick up the WEBHOOK_BASE_URL if your code uses it (though it's primarily for configuring Vonage).
Configure Vonage Webhooks:
Go back to your Vonage Dashboard's Sandbox page
Find the "Webhooks" section
Enter your Inbound URL: YOUR_NGROK_HTTPS_URL/api/vonage/webhooks/inbound (e.g., https://<your-random-string>.ngrok-free.app/api/vonage/webhooks/inbound)
Enter your Status URL: YOUR_NGROK_HTTPS_URL/api/vonage/webhooks/status (e.g., https://<your-random-string>.ngrok-free.app/api/vonage/webhooks/status)
Important: Ensure the signature secret in your Vonage Dashboard matches the VONAGE_SIGNATURE_SECRET in your .env file exactly
Click "Save webhooks"
Step 9: Test Your WhatsApp Message Receiving
Test receiving messages now.
Send a WhatsApp Message: From the phone number linked to your Vonage Sandbox, send a message to the Vonage Sandbox number.
Check Logs: Observe the terminal running your Fastify application (npm run dev). You should see logs indicating:
Inbound message data: { ... } (showing the message payload)
Message from <your_number>: <your_message_text>
Check ngrok Console: The terminal running ngrok (ngrok http 3000) will also show incoming POST requests to your webhook URLs (e.g., POST /api/vonage/webhooks/inbound 200 OK).
If you see errors, especially related to signature validation (401 Unauthorized response in ngrok, validation failure logs in Fastify):
Verify the VONAGE_SIGNATURE_SECRET in your .envexactly matches the secret configured in the Vonage Dashboard. Even a single character difference or whitespace will cause failure.
Confirm the webhook URLs in the Vonage Dashboard are correct (using https and the full path /api/vonage/webhooks/...).
Ensure your Fastify app and ngrok are running and connected.
Check that the jsonwebtoken package is installed.
Review Fastify logs for specific JWT validation error messages.
Step 10: Secure Your WhatsApp Webhooks with JWT Authentication
Validating webhook signatures is critical to ensure requests genuinely come from Vonage and haven't been tampered with or forged.
Vonage Messages API Webhook Security:
Vonage uses JSON Web Token (JWT) Bearer Authorization for webhooks sent from the Messages API. This provides robust authentication different from HMAC signature validation.
How JWT Webhook Authentication Works:
Authorization Header: Vonage includes a JWT in the Authorization header of each webhook request in the format: Authorization: Bearer <jwt_token>
JWT Contents: The JWT is signed using HMAC-SHA256 with your signature secret and contains:
Standard JWT claims (iat – issued at timestamp)
payload_hash – SHA-256 hash of the webhook payload for tamper detection
Other Vonage-specific claims
Validation Process:
Extract the JWT from the Authorization header
Verify the signature using your VONAGE_SIGNATURE_SECRET
Optionally verify the payload_hash matches the actual payload
Optionally check the iat timestamp to reject stale tokens
Implementation:
The validateVonageJWT function in src/routes/vonage.js implements this validation using the jsonwebtoken library. Ensure you have installed it:
Bash
npm install jsonwebtoken
# or
yarn add jsonwebtoken
Security Best Practices for WhatsApp Webhooks:
Always validate signatures in production – Never skip JWT validation
Keep your signature secret secure – Store it in environment variables, never commit to version control
Use the same secret in both places – The secret in your .env must exactly match the one in your Vonage Dashboard
Implement payload hash verification – Adds an extra layer of security against tampering
Check token age – Reject tokens older than a few minutes to prevent replay attacks
Log validation failures – Monitor for potential security issues
Troubleshooting Webhook Authentication:
If webhook validation fails:
Verify VONAGE_SIGNATURE_SECRET matches exactly in both .env and Vonage Dashboard
Check that webhook URLs in Vonage Dashboard use https and the correct paths
Ensure the jsonwebtoken package is installed
Review Fastify logs for specific JWT validation error messages
Check for whitespace or encoding issues in the secret
Frequently asked questions
Use the Vonage Messages API with Node.js and a framework like Fastify. The Vonage Node.js SDK simplifies sending messages via WhatsApp. Create a route in your Fastify app that takes the recipient's number and message text and calls vonage.messages.send() with the appropriate parameters, including channel: "whatsapp".
The Vonage Messages API is a unified API that allows you to send and receive messages across multiple channels, including WhatsApp, SMS, MMS, and Facebook Messenger. This tutorial focuses on its WhatsApp capabilities for two-way communication.
Fastify is a high-performance Node.js web framework known for its speed and ease of use. It's ideal for building robust and efficient applications that interact with APIs like the Vonage Messages API, especially for real-time or high-throughput scenarios.
Vonage uses webhooks to deliver incoming WhatsApp messages and status updates to your application. You'll need to configure URLs in your Vonage dashboard that point to specific routes in your Fastify app. These routes will then process the incoming webhook data, such as sender number and message content. Secure these webhooks using the HMAC-SHA256 signature validation mechanism described in the article to ensure authenticity.
Webhook signature validation is essential for ensuring requests originate from Vonage and haven't been tampered with. It is critically important to do this verification on the raw, unparsed request body to prevent vulnerabilities. You should validate signatures on every webhook request received.
Capture the raw request body using Fastify's app.addContentTypeParserbefore the body is parsed as JSON. Then, use the crypto module in Node.js, along with your unique VONAGE_SIGNATURE_SECRET (configured in both the Vonage dashboard and your .env file), to calculate an HMAC-SHA256 signature. Compare this calculated signature with the one provided in the x-vonage-hmac-sha256 (or similarly named) header from Vonage.
ngrok creates a secure tunnel that allows Vonage webhooks to reach your locally hosted application during development. It provides a publicly accessible URL that forwards requests to your localhost server, essential for testing interactions with Vonage during local development before deploying to a public server.
Yes, you can use Express.js, NestJS, or other Node.js frameworks, but you'll need to adapt the specific setup and routing to your framework's way of creating HTTP endpoints and handling middleware. The Vonage SDK will work with any Node.js compatible framework.
The @vonage/server-sdk package is a Node.js library provided by Vonage to streamline interactions with Vonage APIs. It provides convenient methods for sending SMS messages, making voice calls, managing users, and other Vonage API functionalities including working with the messages API.
In your Vonage Dashboard, navigate to "Messages and Dispatch" > "Sandbox." Activate the WhatsApp Sandbox either by scanning the QR code with your WhatsApp app or by sending a designated text message to the Vonage sandbox number provided in the dashboard. The Sandbox allows you to test receiving WhatsApp messages without a dedicated WhatsApp Business account. This is critical for initial development and testing.
Use a .env file to store sensitive information like API keys and secrets. You can use the dotenv package in Node.js to load these environment variables into your application. Create a .env.example file with placeholders for required environment variables to act as a template.
The project demonstrates how to create a production-ready Node.js application, using Fastify, that integrates with the Vonage Messages API to send and receive WhatsApp messages. It covers aspects such as API setup, sending messages, handling incoming webhooks securely, and development setup using tools like ngrok.
Note: This article covers Vonage Messages API integration. The filename references Twilio, but the content is specifically for Vonage's WhatsApp Business API.
Learn how to send and receive WhatsApp messages programmatically using the Vonage Messages API with Node.js v22 and Fastify v5. This step-by-step tutorial shows you how to build a production-ready WhatsApp integration, from initial setup to secure webhook handling with JWT authentication, complete with working code examples you can deploy today.
This integration enables real-world use cases like customer support automation, appointment reminders, order notifications, two-factor authentication (2FA), and interactive chatbots—all through WhatsApp Business API.
By the end of this tutorial, you'll have a functional Fastify application capable of:
This guide assumes you have foundational knowledge of Node.js, asynchronous programming (
async/await), and REST APIs.What You'll Build: WhatsApp Messaging API Integration
Problem: Businesses need reliable ways to communicate with customers on preferred channels like WhatsApp. Building this integration from scratch involves handling API authentication, message sending protocols, receiving incoming messages (webhooks), and securing these endpoints.
Solution: Build a Node.js backend using the Fastify framework to interact with the Vonage Messages API. This provides a structured way to send messages and expose secure HTTP endpoints (webhooks) for receiving messages and delivery status updates from WhatsApp via Vonage.
Technologies:
@vonage/server-sdk): Simplifies interaction with Vonage APIs in Node.js applicationsdotenv: Manages environment variables securelyngrok(for development): Exposes local development servers to the internet for webhook testingjsonwebtoken: Validates JWT tokens for webhook authenticationFor additional messaging options, see our guides on SMS integration with E.164 formatting and 10DLC registration for US phone numbers.
System Architecture:
(Note: Ensure your publishing platform supports Mermaid diagram rendering.)
Prerequisites:
Version Compatibility (2025):
@vonage/server-sdk)Final Outcome: A Fastify application with endpoints to send WhatsApp messages and receive/validate incoming message webhooks from Vonage with production-ready security.
Step 1: Set Up Your Node.js Development Environment
Ensure Node.js and npm/yarn are installed. Verify by opening your terminal and running:
node -v npm -v # or # yarn -vIf not installed, download and install Node.js from nodejs.org. For additional setup guidance, check our Node.js SMS tutorial with Express which covers similar environment configuration.
Step 2: Create Your Fastify Project Structure
Create your project directory and initialize it with npm (or yarn).
Create Project Directory:
mkdir fastify-vonage-whatsapp cd fastify-vonage-whatsappInitialize Node.js Project:
npm init -y # or # yarn init -yThis creates a
package.jsonfile.Install Dependencies: Install production dependencies first:
npm install fastify @vonage/server-sdk dotenv jsonwebtoken # or # yarn add fastify @vonage/server-sdk dotenv jsonwebtokenThen, install development dependencies like
pino-pretty:npm install -D pino-pretty # or # yarn add -D pino-prettyfastify: The web framework@vonage/server-sdk: The official Vonage Node SDK for WhatsApp messagingdotenv: Loads environment variables from a.envfilejsonwebtoken: Validates JWT tokens for webhook authenticationpino-pretty: (Dev Dependency) Makes Fastify's logs more readable during developmentSet up Basic Project Structure: Create the following files and directories:
Configure
.gitignore: Create a.gitignorefile to prevent committing sensitive information and unnecessary files:Step 3: Configure Your Vonage WhatsApp Account
Configure your Vonage account and the WhatsApp Sandbox before writing code.
Step 4: Set Up API Credentials and Environment Variables
Use environment variables to store sensitive credentials and configuration details.
Create
.env.example: This file serves as a template for required variables.Create
.env: Duplicate.env.example, rename it to.env, and fill in your actual values.VONAGE_API_KEY,VONAGE_API_SECRET: From your Vonage dashboardVONAGE_WHATSAPP_NUMBER: The phone number provided on the Vonage WhatsApp Sandbox pageVONAGE_SIGNATURE_SECRET: Obtain this from your Vonage Dashboard under the webhook settings for Messages API. This is the shared secret used to validate JWT tokens in webhook requests.PORT,HOST,LOG_LEVEL: Default application settingsWEBHOOK_BASE_URL: Leave this blank for now; fill it when running ngrokVONAGE_APPLICATION_ID,VONAGE_PRIVATE_KEY_PATH: Not required for the basic authentication method (API Key/Secret) used in this guide but needed for advanced JWT-based authentication featuresLoad Environment Variables: Modify your
package.jsonscripts to usedotenv(for loading.envfiles) andpino-pretty(for readable logs) during development:// package.json "scripts": { "start": "node src/server.js", "dev": "node -r dotenv/config src/server.js | pino-pretty" },startscript runs the server directly (suitable for production where env vars are set externally)devscript uses-r dotenv/configto preload environment variables from your.envfile before the application starts. It then pipes the JSON output logs throughpino-prettyfor better readability in the terminal5. Installing Vonage SDK (Already Done)
We installed
@vonage/server-sdkandjsonwebtokenin Step 2. These packages provide convenient methods for interacting with the Vonage API and validating webhook JWTs.Step 6: How to Send WhatsApp Messages Programmatically
Create the Fastify application structure and add a route to send messages.
Set up Fastify App (
src/app.js):// src/app.js 'use strict'; const Fastify = require('fastify'); const vonageRoutes = require('./routes/vonage'); const { Vonage } = require('@vonage/server-sdk'); function build(opts = {}) { const app = Fastify(opts); // Initialize Vonage Client // Note: Ensure VONAGE_API_KEY and VONAGE_API_SECRET are set in .env const vonage = new Vonage({ apiKey: process.env.VONAGE_API_KEY, apiSecret: process.env.VONAGE_API_SECRET, // Note: applicationId and privateKey are not needed for Key/Secret auth }); // Make Vonage client accessible in routes app.decorate('vonage', vonage); // Register routes app.register(vonageRoutes, { prefix: '/api/vonage' }); // Basic health check route app.get('/health', async (request, reply) => { return { status: 'ok', timestamp: new Date().toISOString() }; }); return app; } module.exports = build;app.decorateto make thevonageclient easily available within route handlers (request.server.vonage)/api/vonageprefix/healthcheck endpointCreate Server Entry Point (
src/server.js):// src/server.js 'use strict'; // Read .env file if not preloaded (e.g., via `node -r dotenv/config`) // This ensures it works if started with just `node src/server.js` in dev if (process.env.NODE_ENV !== 'production' && !process.env.DOTENV_CONFIG_PATH && require('fs').existsSync('.env')) { console.log('Loading .env file for non-production environment.'); require('dotenv').config(); } const server = require('./app')({ logger: { level: process.env.LOG_LEVEL || 'info', // pino-pretty is handled by the `npm run dev` script pipe, not here }, }); const start = async () => { try { const port = parseInt(process.env.PORT || '3000', 10); const host = process.env.HOST || '0.0.0.0'; await server.listen({ port: port, host: host }); // Note: Fastify logs the listening address automatically with default logger settings. server.log.info(`Access health check at http://localhost:${port}/health`); server.log.info(`API prefix: /api/vonage`); } catch (err) { server.log.error(err); process.exit(1); } }; start();.envif not preloaded via the script (useful for some debugging scenarios)Create Send Message Route (
src/routes/vonage.js):// src/routes/vonage.js 'use strict'; const jwt = require('jsonwebtoken'); const sendMessageSchema = { body: { type: 'object', required: ['to', 'text'], properties: { to: { type: 'string', description: 'Recipient WhatsApp number (E.164 format)', pattern: '^\\+?[1-9]\\d{1,14}' }, // Basic E.164 pattern text: { type: 'string', description: 'Message content', minLength: 1 }, }, }, response: { 200: { type: 'object', properties: { message_uuid: { type: 'string' }, detail: { type: 'string' } } }, // Define common error responses for better OpenAPI/Swagger documentation 400: { $ref: 'http://example.com/schemas/error#/definitions/badRequest' }, // Placeholder 500: { $ref: 'http://example.com/schemas/error#/definitions/serverError' } // Placeholder } }; // --- Helper function for JWT Signature Validation --- // Vonage Messages API uses JWT Bearer tokens in the Authorization header function validateVonageJWT(request, secret, log) { try { // Extract the JWT from the Authorization header const authHeader = request.headers['authorization']; if (!authHeader) { log.warn('Missing Authorization header in webhook request.'); return false; } // Authorization header format: "Bearer <jwt_token>" const parts = authHeader.split(' '); if (parts.length !== 2 || parts[0] !== 'Bearer') { log.warn('Invalid Authorization header format. Expected "Bearer <token>".'); return false; } const token = parts[1]; // Verify and decode the JWT using the signature secret const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] // Vonage uses HMAC-SHA256 }); log.info('JWT signature validated successfully.'); // Optional: Verify payload_hash to ensure payload hasn't been tampered with if (decoded.payload_hash && request.body) { const crypto = require('crypto'); const bodyString = JSON.stringify(request.body); const calculatedHash = crypto.createHash('sha256').update(bodyString).digest('hex'); if (decoded.payload_hash !== calculatedHash) { log.warn('Payload hash mismatch. Possible tampering detected.'); return false; } log.info('Payload hash verified successfully.'); } // Optional: Check token age using 'iat' (issued at) claim if (decoded.iat) { const tokenAge = Date.now() / 1000 - decoded.iat; const MAX_TOKEN_AGE = 300; // 5 minutes if (tokenAge > MAX_TOKEN_AGE) { log.warn(`Token is stale (age: ${tokenAge}s). Possible replay attack.`); return false; } } return true; // JWT is valid } catch (error) { if (error.name === 'JsonWebTokenError') { log.warn('Invalid JWT signature.'); } else if (error.name === 'TokenExpiredError') { log.warn('JWT token has expired.'); } else { log.error({ msg: 'Error during JWT validation', error: error.message, stack: error.stack }); } return false; } } // End of validateVonageJWT async function vonageRoutes(fastify, options) { const vonage = fastify.vonage; // Access decorated Vonage client const signatureSecret = process.env.VONAGE_SIGNATURE_SECRET; // --- Send WhatsApp Message Route --- fastify.post('/send-whatsapp', { schema: sendMessageSchema }, async (request, reply) => { const { to, text } = request.body; const from = process.env.VONAGE_WHATSAPP_NUMBER; if (!from) { fastify.log.error('VONAGE_WHATSAPP_NUMBER environment variable not set.'); return reply.status(500).send({ error: 'Server configuration error: Missing sender number.' }); } fastify.log.info(`Attempting to send WhatsApp message from ${from} to ${to}`); try { const resp = await vonage.messages.send({ message_type: "text", text: text, to: to, from: from, channel: "whatsapp" }); fastify.log.info(`Message sent successfully. UUID: ${resp.messageUuid}`); return reply.send({ message_uuid: resp.messageUuid, detail: `Message sent to ${to}` }); } catch (error) { fastify.log.error({ msg: 'Error sending WhatsApp message', error: error?.response?.data || error.message, statusCode: error?.response?.status }); // Provide more specific error feedback if possible let statusCode = 500; let errorMessage = 'Failed to send message due to an internal server error.'; if (error.response?.data) { // Use Vonage's error details if available errorMessage = error.response.data.title || error.response.data.detail || errorMessage; if (error.response.status >= 400 && error.response.status < 500) { statusCode = error.response.status; } } else if (error.message && error.message.toLowerCase().includes('number')) { // Basic check for common number format issues statusCode = 400; errorMessage = 'Invalid recipient number format. Ensure it uses E.164 format (e.g., +14155552671).'; } // Return a structured error return reply.status(statusCode).send({ error: { message: errorMessage, type: error.response?.data?.type, // Include Vonage error type if available details: error.response?.data?.invalid_parameters // Include specific invalid params if available } }); } }); // --- Inbound Message Webhook --- fastify.post('/webhooks/inbound', { // Optional: Add schema validation for expected inbound webhook structure if needed }, async (request, reply) => { fastify.log.info('Received inbound webhook'); // 1. JWT Signature Validation (CRITICAL for Security) if (signatureSecret) { if (!validateVonageJWT(request, signatureSecret, fastify.log)) { fastify.log.warn('Invalid webhook JWT signature received.'); return reply.status(401).send({ error: 'Invalid signature' }); } } else { // WARNING: Only skip validation in very specific, controlled non-production scenarios. fastify.log.warn('Webhook signature validation skipped (VONAGE_SIGNATURE_SECRET not set). THIS IS INSECURE.'); } // 2. Process the message const inboundData = request.body; fastify.log.info({ msg: 'Inbound message data:', data: inboundData }); // Example: Log sender and message text if (inboundData?.from?.type === 'whatsapp' && inboundData?.message?.content?.text) { fastify.log.info(`Message from ${inboundData.from.number}: ${inboundData.message.content.text}`); // TODO: Add your business logic here (e.g., reply, store in DB, queue for processing) } else { fastify.log.warn('Received inbound webhook with unexpected structure or non-text message.'); } // 3. Acknowledge receipt immediately // Vonage requires a 200 OK response quickly. Defer long processing. reply.status(200).send('OK'); }); // --- Message Status Webhook --- fastify.post('/webhooks/status', { // Optional: Add schema validation for expected status webhook structure if needed }, async (request, reply) => { fastify.log.info('Received status webhook'); // 1. JWT Signature Validation (CRITICAL for Security) if (signatureSecret) { if (!validateVonageJWT(request, signatureSecret, fastify.log)) { fastify.log.warn('Invalid webhook JWT signature received.'); return reply.status(401).send({ error: 'Invalid signature' }); } } else { fastify.log.warn('Webhook signature validation skipped (VONAGE_SIGNATURE_SECRET not set). THIS IS INSECURE.'); } // 2. Process the status update const statusData = request.body; fastify.log.info({ msg: 'Message status update:', data: statusData }); // Example: Log message UUID and status if (statusData?.message_uuid && statusData?.status) { fastify.log.info(`Status for message ${statusData.message_uuid}: ${statusData.status} (Timestamp: ${statusData.timestamp})`); // TODO: Update message status in your database if needed } else { fastify.log.warn('Received status webhook with unexpected structure.'); } // 3. Acknowledge receipt immediately reply.status(200).send('OK'); }); } // End of vonageRoutes plugin function module.exports = vonageRoutes;sendMessageSchemafor request body validation using Fastify's built-in capabilities, including a basic E.164 pattern check for thetonumber/api/vonage/send-whatsapptakes thetonumber andtextfrom the request bodyVONAGE_WHATSAPP_NUMBERfrom environment variables to use as thefromnumbervonage.messages.send()with the required parameters for a text message via WhatsAppvalidateVonageJWThelper function implements JWT validation using thejsonwebtokenlibrary/webhooks/inbound,/webhooks/status) use the helper for signature validation and log incoming data, responding quickly with200 OKTest Sending:
npm run dev(oryarn dev)curl(or a tool like Postman) to send a request. ReplaceYOUR_WHATSAPP_NUMBERwith your actual number linked to the Vonage Sandbox (in E.164 format, e.g.,+14155552671)curl -X POST http://localhost:3000/api/vonage/send-whatsapp \ -H "Content-Type: application/json" \ -d '{ "to": "YOUR_WHATSAPP_NUMBER", "text": "Hello from Fastify and Vonage!" }'{"message_uuid":"...","detail":"Message sent to YOUR_WHATSAPP_NUMBER"}). Check the server logs as well.Step 7: How to Receive WhatsApp Messages via Webhooks
Vonage uses webhooks to notify your application about incoming WhatsApp messages and message status updates. The routes for this (
/api/vonage/webhooks/inboundand/api/vonage/webhooks/status) are already included insrc/routes/vonage.jsfrom the previous step.Step 8: Expose Your Local Server for Webhook Testing
Allow Vonage's servers to reach your local development machine using ngrok.
npm run dev. Note the port (default is 3000).3000if your app uses a different port).httpsURL (e.g.,https://random-string.ngrok-free.app)..env: Set theWEBHOOK_BASE_URLin your.envfile to this ngrok URL.Ctrl+C) and restart your Fastify app (npm run dev) to pick up theWEBHOOK_BASE_URLif your code uses it (though it's primarily for configuring Vonage).YOUR_NGROK_HTTPS_URL/api/vonage/webhooks/inbound(e.g.,https://<your-random-string>.ngrok-free.app/api/vonage/webhooks/inbound)YOUR_NGROK_HTTPS_URL/api/vonage/webhooks/status(e.g.,https://<your-random-string>.ngrok-free.app/api/vonage/webhooks/status)VONAGE_SIGNATURE_SECRETin your.envfile exactlyStep 9: Test Your WhatsApp Message Receiving
Test receiving messages now.
npm run dev). You should see logs indicating:Received inbound webhookJWT signature validated successfully.(if validation passes)Inbound message data: { ... }(showing the message payload)Message from <your_number>: <your_message_text>ngrok http 3000) will also show incomingPOSTrequests to your webhook URLs (e.g.,POST /api/vonage/webhooks/inbound 200 OK).If you see errors, especially related to signature validation (401 Unauthorized response in ngrok, validation failure logs in Fastify):
VONAGE_SIGNATURE_SECRETin your.envexactly matches the secret configured in the Vonage Dashboard. Even a single character difference or whitespace will cause failure.httpsand the full path/api/vonage/webhooks/...).jsonwebtokenpackage is installed.Step 10: Secure Your WhatsApp Webhooks with JWT Authentication
Validating webhook signatures is critical to ensure requests genuinely come from Vonage and haven't been tampered with or forged.
Vonage Messages API Webhook Security:
Vonage uses JSON Web Token (JWT) Bearer Authorization for webhooks sent from the Messages API. This provides robust authentication different from HMAC signature validation.
How JWT Webhook Authentication Works:
Authorization Header: Vonage includes a JWT in the
Authorizationheader of each webhook request in the format:Authorization: Bearer <jwt_token>JWT Contents: The JWT is signed using HMAC-SHA256 with your signature secret and contains:
iat– issued at timestamp)payload_hash– SHA-256 hash of the webhook payload for tamper detectionValidation Process:
VONAGE_SIGNATURE_SECRETpayload_hashmatches the actual payloadiattimestamp to reject stale tokensImplementation:
The
validateVonageJWTfunction insrc/routes/vonage.jsimplements this validation using thejsonwebtokenlibrary. Ensure you have installed it:npm install jsonwebtoken # or yarn add jsonwebtokenSecurity Best Practices for WhatsApp Webhooks:
.envmust exactly match the one in your Vonage DashboardTroubleshooting Webhook Authentication:
If webhook validation fails:
VONAGE_SIGNATURE_SECRETmatches exactly in both.envand Vonage Dashboardhttpsand the correct pathsjsonwebtokenpackage is installed