Build Production-Ready Vonage WhatsApp Integration with Fastify & Node.js (2024)
Complete guide to building a Vonage WhatsApp integration using Fastify and Node.js. Learn webhook setup, JWT security, rate limits, and production deployment with code examples.
Build a production-ready Node.js application using Fastify to send and receive WhatsApp messages via the Vonage Messages API. You'll implement webhook handling, JWT security, error management, and learn critical production considerations including Meta's rate limits and messaging tiers.
Your Project Goal: Create a robust Fastify backend service that:
Receives incoming WhatsApp messages sent to your Vonage number via webhooks
Replies to incoming messages automatically
Securely validates webhook requests using JWT signatures
Provides a foundation for complex WhatsApp bot interactions
Why Use This Approach?
Fastify: A high-performance, low-overhead Node.js web framework focused on developer experience and speed. Its plugin architecture and built-in features like logging and schema validation make it excellent for building robust APIs.
Vonage Messages API: A unified API for sending and receiving messages across multiple channels, including WhatsApp, SMS, MMS, Facebook Messenger, and Viber. You'll use its WhatsApp capabilities via the official Vonage Node SDK.
Vonage WhatsApp Sandbox: Enables rapid development and testing without needing a dedicated WhatsApp Business Account initially.
Webhook Architecture: The standard mechanism for real-time communication from platforms like Vonage to your application.
Node.js: Version 20.x or 22.x LTS installed. This guide uses Fastify v5, which requires Node.js 20 or newer. If you're using Node.js 18 or earlier, you'll need to use Fastify v4 (supported until June 30, 2025) or upgrade your Node.js version. Source: Fastify LTS Documentation
npm or yarn: Node.js package manager.
Vonage API Account: Sign up for free at vonage.com if you don't have one. You'll get free credit to start.
WhatsApp Account: A personal WhatsApp account on a smartphone for testing.
ngrok: A tool to expose your local development server to the internet. Download and install it from ngrok.com. You'll need to sign up for a free account to get an authentication token. As of 2023, ngrok provides one free static domain per account, eliminating the need to update webhook URLs on every restart. Source: ngrok Agent Documentation
Final Outcome: A running Fastify application on your local machine, connected to the Vonage WhatsApp Sandbox via ngrok, capable of receiving WhatsApp messages and sending automated replies.
1. Setting Up the Project
Initialize your Node.js project using Fastify.
Create Project Directory:
Open your terminal and create a new directory for your project, then navigate into it.
Bash
mkdir fastify-vonage-whatsapp
cd fastify-vonage-whatsapp
Initialize Node.js Project:
Create a package.json file.
Bash
npm init -y
Install Dependencies:
Install Fastify, its environment variable handler (@fastify/env), sensible defaults (@fastify/sensible), and the Vonage SDK components.
Basic Fastify Server (src/server.js):
Create a minimal Fastify server to ensure your setup works.
JavaScript
// src/server.js'use strict';
constFastify = require('fastify');
// Instantiate Fastify with basic loggingconst fastify = Fastify({
logger: true, // Use true for basic Pino logging, or configure logger options
});
// Declare a simple health check route
fastify.get('/health', async (request, reply) => {
return { status: 'ok' };
});
// Run the serverconststart = async () => {
try {
// For now, hardcode the port. You'll use environment variables later.await fastify.listen({ port: 8000, host: '0.0.0.0' });
fastify.log.info(`Server listening on ${fastify.server.address().port}`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();
Run the Basic Server:
Execute the server file.
Bash
node src/server.js
You should see log output indicating your server is listening on port 8000. Test the health check endpoint using curl http://localhost:8000/health in another terminal window. Stop the server with Ctrl+C.
2. Vonage Configuration
Configure your Vonage account and application to work with the Messages API and WhatsApp Sandbox.
Navigate to Applications → Create a new application.
Give your application a name (e.g., "Fastify WhatsApp Demo").
Generate Public/Private Key Pair: Click the Generate public and private key button. This downloads a private.key file. Save this file securely. Place it in your root directory (fastify-vonage-whatsapp/private.key). You added private.key to .gitignore to prevent accidentally committing it. Warning: While gitignored, storing private keys directly within your application's directory structure is generally discouraged for security reasons, even locally. Store it outside the project folder if your local setup allows. Under no circumstances should this key ever be committed to version control. The public key is automatically associated with your application in the dashboard.
Enable Capabilities: Scroll down to Capabilities and toggle Messages ON. You'll see fields for Inbound URL and Status URL. Fill these in the next section after setting up ngrok.
Click Create application.
Note Your Application ID: After creation, you'll see your application's page. Copy the Application ID – you'll need it for your environment variables.
Set Up Vonage WhatsApp Sandbox:
In the Vonage Dashboard sidebar, navigate to Messages API → Sandbox.
Read the instructions. Add the Vonage Sandbox contact number to your phone's contacts and send it a specific message via WhatsApp to opt-in your number for testing.
Add the Sandbox Number: The number is displayed prominently (often +14157386102, but always verify the current number shown on your specific Sandbox page as it can change). Save it as a contact (e.g., "Vonage Sandbox").
Send Opt-in Message: Open WhatsApp on your phone, find the Vonage Sandbox contact, and send it the exact opt-in phrase shown on the Sandbox page (it includes a unique keyword).
Allowlist Your Number: Once Vonage receives your message, your number appears in the Allowlisted testing numbers section on the Sandbox page. Only numbers listed here can interact with the Sandbox.
Configure Sandbox Webhooks: Similar to the application, the Sandbox page has fields for Inbound webhook URL and Status webhook URL. Configure these along with the application webhooks in the next step. For the Sandbox to work correctly, both the Application and the Sandbox webhooks need to be set.
Configure Environment Variables (.env):
Create a file named .env in your project root (fastify-vonage-whatsapp/.env). Add the following variables, replacing the placeholders with your actual credentials:
.env
# .env
# Server Configuration
PORT=8000
HOST=0.0.0.0 # Listen on all available network interfaces
# Vonage API Credentials
# Found on the Vonage API Dashboard homepage
VONAGE_API_KEY=YOUR_API_KEY
VONAGE_API_SECRET=YOUR_API_SECRET
# Vonage Application Details
# Found in Your Applications -> [Your Application Name]
VONAGE_APPLICATION_ID=YOUR_APPLICATION_ID
# Path relative to the project root where you saved the private key
VONAGE_PRIVATE_KEY=./private.key
# Vonage WhatsApp Sandbox Number
# Found on the Messages API -> Sandbox page. **VERIFY THE NUMBER ON YOUR DASHBOARD**. Do not include '+'.
VONAGE_WHATSAPP_NUMBER=YOUR_SANDBOX_NUMBER_FROM_DASHBOARD
# Vonage Signature Secret
# Found in Vonage Dashboard -> Settings -> API settings -> Default Signing Secret for API Key
VONAGE_API_SIGNATURE_SECRET=YOUR_SIGNATURE_SECRET
# Vonage API Host (Use Sandbox URL for testing)
VONAGE_API_HOST=https://messages-sandbox.nexmo.com
Explanation of Variables:
PORT, HOST: Configure where your Fastify server listens. 0.0.0.0 is important for containerized environments or VMs.
VONAGE_API_KEY, VONAGE_API_SECRET: Your main Vonage account credentials, found directly on the dashboard landing page after logging in.
VONAGE_APPLICATION_ID: The unique ID for the Vonage Application you created.
VONAGE_PRIVATE_KEY: The path to the private.key file you downloaded. The path ./private.key assumes you run the node src/server.js command from the project's root directory (fastify-vonage-whatsapp/). If running from a different directory, adjust the path accordingly or use an absolute path during development (though this is less portable).
VONAGE_WHATSAPP_NUMBER: The specific phone number assigned to the Vonage WhatsApp Sandbox. Important: Verify the current number on your Vonage dashboard and use it without a leading + or 00.
VONAGE_API_SIGNATURE_SECRET: Used by Vonage to sign webhook requests with a JWT. Verify this signature to ensure requests genuinely came from Vonage. Find this in your main dashboard Settings page under API settings.
VONAGE_API_HOST: Specifies the API endpoint URL. For testing with the Sandbox, use https://messages-sandbox.nexmo.com. For production with a WhatsApp Business Account, use the standard production URL (https://api.nexmo.com).
3. Configure Webhooks with ngrok
Vonage needs a publicly accessible URL to send webhook events (incoming messages and status updates) to your application running locally. ngrok creates a secure tunnel from the internet to your machine. While other tunneling services like localtunnel or Cloudflare Tunnels exist, this guide uses ngrok due to its popularity and ease of use for development.
Start ngrok:
Open a new terminal window (keep your Fastify server terminal separate). Run ngrok, telling it to forward traffic to the port your Fastify app is listening on (Port 8000).
Bash
# Make sure you're logged into ngrok for longer sessions: ngrok config add-authtoken YOUR_TOKEN
ngrok http 8000
Copy the ngrok Forwarding URL:
ngrok displays output similar to this:
Copy the https://... URL (yours will have a different random string). This is your public URL. Note: Free ngrok URLs change every time you restart ngrok unless you use the free static domain feature.
Configure Vonage Webhooks:
Go back to your Vonage Application settings in the dashboard (Applications → Your Application).
Under the Messages capability:
Set Inbound URL:YOUR_NGROK_URL/webhooks/inbound (e.g., https://<random-string>.ngrok-free.app/webhooks/inbound)
Set Status URL:YOUR_NGROK_URL/webhooks/status (e.g., https://<random-string>.ngrok-free.app/webhooks/status)
Click Save changes.
Now go to the Vonage Messages API Sandbox page in the dashboard.
Why /webhooks/inbound and /webhooks/status? These are the specific endpoints you'll create in your Fastify application to handle these two types of events. It's crucial that both the Application and Sandbox webhooks point to your ngrok tunnel.
Now, let's write the Fastify code to handle the webhooks and send replies.
Load Environment Variables (src/server.js):
Use @fastify/env to load and validate variables from .env.
JavaScript
// src/server.js'use strict';
constFastify = require('fastify');
const sensible = require('@fastify/sensible');
const fastifyEnv = require('@fastify/env');
const path = require('path'); // Needed for path resolution// Define schema for environment variablesconst schema = {
type: 'object',
required: [
'PORT',
'HOST',
'VONAGE_API_KEY',
'VONAGE_API_SECRET',
'VONAGE_APPLICATION_ID',
'VONAGE_PRIVATE_KEY', // Path or content'VONAGE_WHATSAPP_NUMBER',
'VONAGE_API_SIGNATURE_SECRET',
'VONAGE_API_HOST',
],
properties: {
PORT: { type: 'string', default: 8000 },
HOST: { type: 'string', default: '0.0.0.0' },
VONAGE_API_KEY: { type: 'string' },
VONAGE_API_SECRET: { type: 'string' },
VONAGE_APPLICATION_ID: { type: 'string' },
VONAGE_PRIVATE_KEY: { type: 'string' }, // Can be path or key contentVONAGE_WHATSAPP_NUMBER: { type: 'string' },
VONAGE_API_SIGNATURE_SECRET: { type: 'string' },
VONAGE_API_HOST: { type: 'string' },
},
};
const envOptions = {
confKey: 'config', // Access environment variables via `fastify.config`schema: schema,
dotenv: true, // Load .env file
};
const fastify = Fastify({
logger: {
level: 'info', // Default log level// Pretty print logs in development, use structured JSON in productiontransport: process.env.NODE_ENV !== 'production' ? {
target: 'pino-pretty',
options: {
translateTime: 'HH:MM:ss Z',
ignore: 'pid,hostname',
},
} : undefined,
},
});
// Register plugins
fastify
.register(fastifyEnv, envOptions)
.register(sensible)
.register(require('./routes/webhooks')); // Register routes AFTER env// Start function remains the same, but uses configconststart = async () => {
try {
// Wait for plugins to load, especially fastifyEnvawait fastify.ready();
await fastify.listen({
port: fastify.config.PORT,
host: fastify.config.HOST,
});
// Note: fastify.server.address() might be null until listen completes// We log the port from config directly now
fastify.log.info(
`Server listening on host ${fastify.config.HOST} port ${fastify.config.PORT}`
);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();
// Export fastify instance if needed for testingmodule.exports = fastify;
We define a JSON schema for expected environment variables. @fastify/env will throw an error on startup if required variables are missing.
We configure Pino (Fastify's logger) for pretty printing during development (NODE_ENV !== 'production').
fastify.ready() ensures plugins like @fastify/env are loaded before we access fastify.config.
Routes are registered after core plugins like fastifyEnv.
Vonage Service (src/services/vonageService.js):
Create a service to encapsulate Vonage client initialization and message sending logic, handling private key as path or content.
JavaScript
// src/services/vonageService.js'use strict';
const { Vonage } = require('@vonage/server-sdk');
const { WhatsAppText } = require('@vonage/messages');
const path = require('path');
const fs = require('fs');
let vonageClient;
functioninitializeVonageClient(config, logger) {
if (!vonageClient) {
let privateKeyContent = config.VONAGE_PRIVATE_KEY;
// Check if the variable content looks like a path vs the actual keyconst looksLikePath = /[\\/]|(\.key$)/.test(privateKeyContent);
const looksLikePEMKey = privateKeyContent.startsWith('-----BEGIN PRIVATE KEY-----');
if (looksLikePath && !looksLikePEMKey) {
// Assume it's a path, try to read the fileconst privateKeyPath = path.resolve(privateKeyContent); // Resolve relative to CWDtry {
privateKeyContent = fs.readFileSync(privateKeyPath, 'utf8');
logger.info(`Read private key from path: ${privateKeyPath}`);
} catch (err) {
logger.error(`Failed to read private key from configured path: ${privateKeyPath}`);
logger.error(err);
// If reading fails, check if maybe it WAS the key content after all but looked like a pathif (config.VONAGE_PRIVATE_KEY.startsWith('-----BEGIN PRIVATE KEY-----')) {
logger.warn('VONAGE_PRIVATE_KEY looked like a path but failed to read; attempting to use content directly.');
// Reset to use original content
privateKeyContent = config.VONAGE_PRIVATE_KEY;
} else {
thrownewError('Could not read VONAGE_PRIVATE_KEY file and content does not look like a PEM key. Check path/permissions or provide key content directly.');
}
}
} elseif (looksLikePEMKey) {
// Assume it's the key content itself
logger.info('Using private key directly from VONAGE_PRIVATE_KEY environment variable content.');
} else {
// Content is ambiguous or invalid
logger.error('VONAGE_PRIVATE_KEY content is neither a readable path nor does it look like a PEM private key.');
thrownewError('Invalid VONAGE_PRIVATE_KEY configuration.');
}
// Now initialize Vonage with the resolved privateKeyContent
vonageClient = newVonage(
{
apiKey: config.VONAGE_API_KEY,
apiSecret: config.VONAGE_API_SECRET,
applicationId: config.VONAGE_APPLICATION_ID,
privateKey: privateKeyContent, // Use the resolved key content here
},
{
apiHost: config.VONAGE_API_HOST, // Use the sandbox or production hostlogger: logger.child({ service: 'vonage-sdk' }), // Pass a child logger for context
}
);
logger.info('Vonage client initialized.');
}
return vonageClient;
}
asyncfunctionsendWhatsAppReply(to_number, text, config, logger) {
const client = initializeVonageClient(config, logger); // Ensure client is initializedconst from_number = config.VONAGE_WHATSAPP_NUMBER;
logger.info(`Attempting to send WhatsApp message from ${from_number} to ${to_number}`);
try {
const response = await client.messages.send(
newWhatsAppText({
from: from_number,
to: to_number,
text: text,
})
);
logger.info(
{ msgUuid: response.messageUUID },
`Message sent successfully`
);
return response;
} catch (error) {
logger.error('Error sending WhatsApp message via Vonage:');
// Log detailed error information if availableif (error.response && error.response.data) {
logger.error(
{ vonageError: error.response.data, statusCode: error.response.status },
`Vonage API Error`
);
} else {
logger.error(error);
}
// Re-throw or handle appropriately for the callerthrownewError('Failed to send WhatsApp message.');
}
}
module.exports = {
initializeVonageClient,
sendWhatsAppReply,
};
Detects if VONAGE_PRIVATE_KEY is a path or the key content.
Reads the file content if it looks like a path.
Uses the content directly if it looks like a PEM key.
Initializes the Vonage client using configuration values and the resolved key content.
Uses the specific apiHost from the config.
Includes detailed error logging.
Passes a child logger to the Vonage SDK for better context.
JWT Signature Verification Hook (src/hooks/verifyVonageSignature.js):
Create a Fastify hook to verify the Authorization header on incoming status webhooks.
JavaScript
// src/hooks/verifyVonageSignature.js'use strict';
const { verifySignature } = require('@vonage/jwt');
const fp = require('fastify-plugin');
asyncfunctionverifyVonageSignatureHook(fastify, options) {
// Decorate fastify instance with the verification function// so it can be easily used in route options (preHandler)
fastify.decorate('verifyVonageSignature', async (request, reply) => {
fastify.log.trace('Attempting Vonage JWT signature verification...'); // Use trace for verbose stepsconst authHeader = request.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
fastify.log.warn('Missing or invalid Authorization header for webhook');
reply.code(401).send({ error: 'Unauthorized: Missing or invalid JWT Bearer token' });
return; // Stop further execution
}
const token = authHeader.split(' ')[1];
const secret = fastify.config.VONAGE_API_SIGNATURE_SECRET;
if (!secret) {
fastify.log.error('VONAGE_API_SIGNATURE_SECRET is not configured. Cannot verify webhook signature.');
reply.code(500).send({ error: 'Internal Server Error: Signature secret not configured' });
return;
}
try {
// Note: verifySignature throws on error in some versions, returns bool in others.// Let's assume it returns boolean for safety.const isValid = verifySignature(token, secret);
if (!isValid) {
fastify.log.warn('Invalid Vonage JWT signature received.');
reply.code(403).send({ error: 'Forbidden: Invalid signature' });
return; // Stop further execution
}
fastify.log.info('Vonage JWT signature verified successfully.');
} catch (err) {
// Catch potential errors from the verifySignature function itself
fastify.log.error({ err }, 'Error during JWT signature verification process');
reply.code(500).send({ error: 'Internal Server Error during JWT verification' });
return; // Stop further execution
}
});
}
// Export as a pluginmodule.exports = fp(verifyVonageSignatureHook);
Uses fastify-plugin to properly encapsulate the hook/decorator.
Defines a decorator verifyVonageSignature which contains the verification logic.
Extracts the JWT from the Authorization: Bearer <token> header.
Uses @vonage/jwt's verifySignature function with the secret from fastify.config.
Sends appropriate error responses (401, 403, 500) if verification fails or config is missing.
Security Note: Vonage uses HMAC-SHA256 to sign webhook JWTs. The signature expires 5 minutes after issuance, and verifying an expired JWT will result in verification failure. The signing secret should be at least 32 bits to prevent brute-force attacks. For additional security when using HTTP (not HTTPS), you can verify the payload_hash claim in the JWT matches a SHA-256 hash of the request payload to detect tampering. However, Transport Layer Security (TLS/HTTPS) prevents man-in-the-middle attacks, making payload hash verification optional for HTTPS connections. Source: Vonage Webhook Security Documentation
Webhook Routes (src/routes/webhooks.js):
Define the routes to handle /webhooks/inbound and /webhooks/status.
JavaScript
// src/routes/webhooks.js'use strict';
const { sendWhatsAppReply } = require('../services/vonageService');
asyncfunctionwebhookRoutes(fastify, options) {
// Register the JWT verification hook/decorator plugin// This makes fastify.verifyVonageSignature available
fastify.register(require('../hooks/verifyVonageSignature'));
// --- Inbound Message Webhook ---// No JWT verification applied here by default, as per common practice
fastify.post('/webhooks/inbound', async (request, reply) => {
fastify.log.info('Received /webhooks/inbound POST request');
fastify.log.debug({ body: request.body }, 'Inbound webhook payload:'); // Use debug for full payload// Basic validation: Check if it's a WhatsApp message with necessary fieldsif (
!request.body ||
request.body.channel !== 'whatsapp' ||
!request.body.from?.number || // Use optional chaining
!request.body.message?.content?.text// Check for text content specifically
) {
fastify.log.warn({ body: request.body }, 'Invalid, non-WhatsApp, or non-text inbound payload received.');
// Send 200 OK to Vonage even for invalid payloads we don't process,// to prevent retries for malformed requests. Log the issue.return reply.code(200).send();
}
const requesterNumber = request.body.from.number;
const messageText = request.body.message.content.text;
const messageUuid = request.body.message_uuid; // Log the message UUID
fastify.log.info(
{ from: requesterNumber, msgUuid: messageUuid },
`Incoming WhatsApp message: ""${messageText}""`
);
// Simple auto-reply logicconst replyText = `Received your message: ""${messageText}""`;
try {
awaitsendWhatsAppReply(
requesterNumber,
replyText,
fastify.config, // Pass config
fastify.log// Pass logger
);
fastify.log.info({ to: requesterNumber }, `Reply sent successfully`);
// Acknowledge receipt to Vonage
reply.code(200).send();
} catch (error) {
fastify.log.error(
{ err: error, to: requesterNumber },
`Failed to process inbound message or send reply`
);
// Send 500 Internal Server Error if our processing fails
reply.code(500).send({ error: 'Failed to process message' });
}
});
// --- Status Webhook ---const statusRouteOptions = {
// Apply the JWT verification hook specifically to this route's preHandler phasepreHandler: [fastify.verifyVonageSignature], // Use the decorated function
};
fastify.post('/webhooks/status', statusRouteOptions, async (request, reply) => {
fastify.log.info('Received /webhooks/status POST request');
fastify.log.debug({ body: request.body }, 'Status webhook payload:'); // Use debug for payload// Process the status update (e.g., log it, update database)const { message_uuid, status, timestamp, error, usage } = request.body;
if (message_uuid && status) {
const logData = { msgUuid: message_uuid, status, timestamp, usage };
if (error) {
logData.error = error;
fastify.log.warn(logData, `Message status update: ${status} with error`);
} else {
fastify.log.info(logData, `Message status update: ${status}`);
}
// In a real app: await updateMessageStatusInDB(message_uuid, status, error);
} else {
fastify.log.warn({ body: request.body }, 'Received status webhook with missing message_uuid or status');
}
// Acknowledge receipt to Vonage
reply.code(200).send();
});
}
module.exports = webhookRoutes;
Registers the verifyVonageSignature plugin.
Inbound Route:
Logs incoming payload (using debug level).
Performs basic validation for WhatsApp text messages.
Extracts sender number, message text, and UUID.
Calls sendWhatsAppReply.
Handles errors and returns 200 OK or 500.
Status Route:
Uses preHandler: [fastify.verifyVonageSignature] to apply the JWT check before the main handler runs.
Logs the received status payload details.
Returns 200 OK.
Register Routes in src/server.js:
This was already corrected in step 1 of this section to ensure routes are registered after core plugins. The code in step 1 is final.
5. Error Handling and Logging
Fastify provides excellent built-in logging via Pino and error handling capabilities.
Logging:
We configured Pino with pino-pretty for readable development logs in src/server.js. In production (NODE_ENV=production), it logs structured JSON, suitable for log aggregation services (like Datadog, Splunk, ELK stack).
We pass fastify.log (or child loggers) to our services (vonageService.js) for consistent, contextual logging.
We log key events: server start, incoming requests (with debug for payload), message content, status updates, successful replies, and crucially, errors with context (including relevant IDs like message_uuid or phone numbers). Different log levels (info, warn, error, debug, trace) are used appropriately.
Error Handling:
@fastify/sensible provides standard error handling and utility error constructors.
Our JWT hook (verifyVonageSignature.js) explicitly sends 401, 403 or 500 errors upon verification failure or configuration issues.
Route handlers use try...catch blocks:
To catch errors during Vonage API calls (sendWhatsAppReply).
To handle unexpected issues during request processing.
When our server fails internally (e.g., cannot send reply, error during JWT verification), we log the error and return a 500 Internal Server Error to Vonage.
For invalid incoming webhook payloads (e.g., wrong format, missing fields on /webhooks/inbound), we log the issue and return a 200 OK to Vonage to prevent unnecessary retries, while ensuring our logs capture the problem.
6. WhatsApp Business API Rate Limits and Production Considerations
Before deploying your application to production with a WhatsApp Business Account (not the Sandbox), you must understand Meta's rate limits and messaging restrictions.
Messaging Limits (Tiered System)
WhatsApp uses a tiered messaging limit system based on phone number quality and verification status:
Tier 0 (Starting): 250 business-initiated conversations per 24-hour period (default for new businesses)
Tier 1: 1,000 conversations per 24-hour period
Tier 2: 10,000 conversations per 24-hour period
Tier 3: 100,000 conversations per 24-hour period
How to Increase Tiers:
Tier 1 is achieved automatically upon successful business verification or when you reach 50% of your current limit within a 7-day period.
Higher tiers (2-3) require sending 1,000+ delivered messages outside customer service windows to unique user phone numbers in a 30-day moving period, using templates with high quality ratings.
Per-Second Limit: Maximum 80 messages per second (MPS) per business phone number, automatically scalable to 1,000 MPS based on demand
Pair Rate Limit: Maximum 1 message every 6 seconds (~10 messages per minute) to a specific WhatsApp user number
Quality Rating Requirements
Meta assigns quality ratings (High, Medium, Low) based on user interactions:
Response rates and response times
User blocks and reports
Message opt-out rates
Low quality ratings can reduce your messaging quotas or restrict messaging capabilities. Maintain high-quality interactions by responding promptly, respecting user preferences, and following WhatsApp's messaging policies.
24-Hour Conversation Window
Customer Service Window: After a user messages your business, you have 24 hours to send free-form messages without using templates.
Marketing Messages: Outside the 24-hour window, you can only send pre-approved message templates, which Meta must review and approve.
Service Conversations: As of November 1, 2024, service conversations (customer support within 24-hour window) are free for all businesses with no conversation cap.
Integrate WhatsApp by using the Vonage Messages API with a Fastify Node.js server. Set up webhooks to receive incoming messages and send replies, ensuring your server is publicly accessible via a tool like ngrok during development. The Vonage Node.js SDK simplifies interaction with the API. This setup creates a foundation for building more complex WhatsApp bot interactions and a production-ready application.
The Vonage Messages API is a unified platform for sending and receiving messages across multiple channels, including WhatsApp, SMS, MMS, Facebook Messenger, and Viber. The API handles routing messages between your application and users on these different platforms, providing a single interface for managing communication.
Fastify is a high-performance Node.js web framework ideal for building efficient and scalable WhatsApp integrations. Its speed, plugin architecture, and features like schema validation and logging make it well-suited for handling real-time message processing and API interactions. It focuses on maximizing developer experience and reducing boilerplate, resulting in clean, maintainable code.
Use the Vonage WhatsApp Sandbox during the initial development and testing phases of your application. It allows you to experiment and iterate quickly without needing a dedicated WhatsApp Business Account. You can test sending and receiving messages, and ensure your webhooks are set up correctly before going live.
Download and install ngrok, then run it with 'ngrok http '. Ngrok creates a public URL that forwards requests to your local server. Copy this URL and use it as the Inbound and Status URL in both your Vonage Application settings and Vonage WhatsApp Sandbox configuration. This ensures that Vonage can reach your local development environment.
A webhook is a mechanism for real-time communication from the Vonage platform to your application. Vonage sends HTTP POST requests to specific URLs (your webhooks) to deliver events like incoming messages and status updates. This allows your app to react immediately to user interactions without constantly polling the Vonage API. Two key webhook types are 'inbound', for incoming messages, and 'status', for delivery reports.
Secure your webhooks by verifying the JWT signature included in the 'Authorization' header of incoming webhook requests. Use the '@vonage/jwt' library's 'verifySignature' function with your Vonage API Signature Secret to validate that the request originates from Vonage. This prevents unauthorized access to your webhook endpoints. The JWT validation should happen before processing the request content.
Use the 'sendWhatsAppReply' function within the Vonage Node.js SDK. This function typically requires parameters like recipient number, message content, your Vonage WhatsApp Sandbox number (or Business Account number in production), API credentials, and Vonage application details. The SDK simplifies message sending and handles communication with the Vonage API.
In your inbound webhook handler, extract the sender's number and the incoming message text. Then, call the 'sendWhatsAppReply' function (from the Vonage service), passing the extracted sender's number as the recipient and your desired reply message. Ensure your Vonage client and credentials are correctly configured before sending the reply. The example includes a simple auto-reply logic.
You will need Node.js version 20 or higher, npm or yarn, a Vonage API account, a WhatsApp account (and smartphone) for testing, and ngrok for local development. Sign up for a free Vonage account to get started and use their WhatsApp Sandbox for testing. The sandbox allows testing without a dedicated business account during the development phase.
Set up the Vonage WhatsApp Sandbox, configure ngrok to expose your local server, and ensure your Fastify application is running. Then, send a WhatsApp message from your allowlisted number to the sandbox number. Your application should receive the message via the inbound webhook and then send an automatic reply, which you can observe on your phone. Check the logs for details of the interaction.
Create a new directory, initialize a Node.js project with npm init -y, and install the required dependencies: fastify, @fastify/env, @fastify/sensible, @vonage/server-sdk, @vonage/messages, and @vonage/jwt. Organize your project with directories for routes, services, and hooks. The article provides example source code for project setup and organization.
Build a production-ready Node.js application using Fastify to send and receive WhatsApp messages via the Vonage Messages API. You'll implement webhook handling, JWT security, error management, and learn critical production considerations including Meta's rate limits and messaging tiers.
Your Project Goal: Create a robust Fastify backend service that:
Why Use This Approach?
System Architecture:
Prerequisites:
Final Outcome: A running Fastify application on your local machine, connected to the Vonage WhatsApp Sandbox via ngrok, capable of receiving WhatsApp messages and sending automated replies.
1. Setting Up the Project
Initialize your Node.js project using Fastify.
Create Project Directory: Open your terminal and create a new directory for your project, then navigate into it.
mkdir fastify-vonage-whatsapp cd fastify-vonage-whatsappInitialize Node.js Project: Create a
package.jsonfile.Install Dependencies: Install Fastify, its environment variable handler (
@fastify/env), sensible defaults (@fastify/sensible), and the Vonage SDK components.fastify: The core web framework@fastify/env: Loads and validates environment variables from a.envfile@fastify/sensible: Provides sensible defaults for error handling and security headers@vonage/server-sdk: The main Vonage SDK package@vonage/messages: Specifically for using the Messages API@vonage/jwt: Verifies webhook signaturesProject Structure: Create the following basic structure:
src/: Contains your application source codesrc/hooks/: Fastify request lifecycle hooks (like authentication/validation)src/routes/: API endpoints (webhooks)src/services/: Business logic for interacting with the Vonage APIsrc/server.js: Main application entry point where you configure and start the Fastify server.env: Stores sensitive configuration and API keys (you'll create this later).gitignore: Specifies files/directories Git should ignoreprivate.key: The private key downloaded from Vonage (ensure it's listed in.gitignore)Create
.gitignore: Add common Node.js ignores plus your sensitive files:Basic Fastify Server (
src/server.js): Create a minimal Fastify server to ensure your setup works.// src/server.js 'use strict'; const Fastify = require('fastify'); // Instantiate Fastify with basic logging const fastify = Fastify({ logger: true, // Use true for basic Pino logging, or configure logger options }); // Declare a simple health check route fastify.get('/health', async (request, reply) => { return { status: 'ok' }; }); // Run the server const start = async () => { try { // For now, hardcode the port. You'll use environment variables later. await fastify.listen({ port: 8000, host: '0.0.0.0' }); fastify.log.info(`Server listening on ${fastify.server.address().port}`); } catch (err) { fastify.log.error(err); process.exit(1); } }; start();Run the Basic Server: Execute the server file.
You should see log output indicating your server is listening on port 8000. Test the health check endpoint using
curl http://localhost:8000/healthin another terminal window. Stop the server withCtrl+C.2. Vonage Configuration
Configure your Vonage account and application to work with the Messages API and WhatsApp Sandbox.
private.keyfile. Save this file securely. Place it in your root directory (fastify-vonage-whatsapp/private.key). You addedprivate.keyto.gitignoreto prevent accidentally committing it. Warning: While gitignored, storing private keys directly within your application's directory structure is generally discouraged for security reasons, even locally. Store it outside the project folder if your local setup allows. Under no circumstances should this key ever be committed to version control. The public key is automatically associated with your application in the dashboard.+14157386102, but always verify the current number shown on your specific Sandbox page as it can change). Save it as a contact (e.g., "Vonage Sandbox").Configure Environment Variables (
.env): Create a file named.envin your project root (fastify-vonage-whatsapp/.env). Add the following variables, replacing the placeholders with your actual credentials:Explanation of Variables:
PORT,HOST: Configure where your Fastify server listens.0.0.0.0is important for containerized environments or VMs.VONAGE_API_KEY,VONAGE_API_SECRET: Your main Vonage account credentials, found directly on the dashboard landing page after logging in.VONAGE_APPLICATION_ID: The unique ID for the Vonage Application you created.VONAGE_PRIVATE_KEY: The path to theprivate.keyfile you downloaded. The path./private.keyassumes you run thenode src/server.jscommand from the project's root directory (fastify-vonage-whatsapp/). If running from a different directory, adjust the path accordingly or use an absolute path during development (though this is less portable).VONAGE_WHATSAPP_NUMBER: The specific phone number assigned to the Vonage WhatsApp Sandbox. Important: Verify the current number on your Vonage dashboard and use it without a leading+or00.VONAGE_API_SIGNATURE_SECRET: Used by Vonage to sign webhook requests with a JWT. Verify this signature to ensure requests genuinely came from Vonage. Find this in your main dashboard Settings page under API settings.VONAGE_API_HOST: Specifies the API endpoint URL. For testing with the Sandbox, usehttps://messages-sandbox.nexmo.com. For production with a WhatsApp Business Account, use the standard production URL (https://api.nexmo.com).3. Configure Webhooks with ngrok
Vonage needs a publicly accessible URL to send webhook events (incoming messages and status updates) to your application running locally. ngrok creates a secure tunnel from the internet to your machine. While other tunneling services like localtunnel or Cloudflare Tunnels exist, this guide uses ngrok due to its popularity and ease of use for development.
Start ngrok: Open a new terminal window (keep your Fastify server terminal separate). Run ngrok, telling it to forward traffic to the port your Fastify app is listening on (Port 8000).
# Make sure you're logged into ngrok for longer sessions: ngrok config add-authtoken YOUR_TOKEN ngrok http 8000Copy the ngrok Forwarding URL: ngrok displays output similar to this:
Copy the
https://...URL (yours will have a different random string). This is your public URL. Note: Free ngrok URLs change every time you restart ngrok unless you use the free static domain feature.Configure Vonage Webhooks:
YOUR_NGROK_URL/webhooks/inbound(e.g.,https://<random-string>.ngrok-free.app/webhooks/inbound)YOUR_NGROK_URL/webhooks/status(e.g.,https://<random-string>.ngrok-free.app/webhooks/status)YOUR_NGROK_URL/webhooks/inboundYOUR_NGROK_URL/webhooks/statusWhy
/webhooks/inboundand/webhooks/status? These are the specific endpoints you'll create in your Fastify application to handle these two types of events. It's crucial that both the Application and Sandbox webhooks point to your ngrok tunnel.4. Implementing Core Functionality (Fastify Adaptation)
Now, let's write the Fastify code to handle the webhooks and send replies.
Load Environment Variables (
src/server.js): Use@fastify/envto load and validate variables from.env.// src/server.js 'use strict'; const Fastify = require('fastify'); const sensible = require('@fastify/sensible'); const fastifyEnv = require('@fastify/env'); const path = require('path'); // Needed for path resolution // Define schema for environment variables const schema = { type: 'object', required: [ 'PORT', 'HOST', 'VONAGE_API_KEY', 'VONAGE_API_SECRET', 'VONAGE_APPLICATION_ID', 'VONAGE_PRIVATE_KEY', // Path or content 'VONAGE_WHATSAPP_NUMBER', 'VONAGE_API_SIGNATURE_SECRET', 'VONAGE_API_HOST', ], properties: { PORT: { type: 'string', default: 8000 }, HOST: { type: 'string', default: '0.0.0.0' }, VONAGE_API_KEY: { type: 'string' }, VONAGE_API_SECRET: { type: 'string' }, VONAGE_APPLICATION_ID: { type: 'string' }, VONAGE_PRIVATE_KEY: { type: 'string' }, // Can be path or key content VONAGE_WHATSAPP_NUMBER: { type: 'string' }, VONAGE_API_SIGNATURE_SECRET: { type: 'string' }, VONAGE_API_HOST: { type: 'string' }, }, }; const envOptions = { confKey: 'config', // Access environment variables via `fastify.config` schema: schema, dotenv: true, // Load .env file }; const fastify = Fastify({ logger: { level: 'info', // Default log level // Pretty print logs in development, use structured JSON in production transport: process.env.NODE_ENV !== 'production' ? { target: 'pino-pretty', options: { translateTime: 'HH:MM:ss Z', ignore: 'pid,hostname', }, } : undefined, }, }); // Register plugins fastify .register(fastifyEnv, envOptions) .register(sensible) .register(require('./routes/webhooks')); // Register routes AFTER env // Start function remains the same, but uses config const start = async () => { try { // Wait for plugins to load, especially fastifyEnv await fastify.ready(); await fastify.listen({ port: fastify.config.PORT, host: fastify.config.HOST, }); // Note: fastify.server.address() might be null until listen completes // We log the port from config directly now fastify.log.info( `Server listening on host ${fastify.config.HOST} port ${fastify.config.PORT}` ); } catch (err) { fastify.log.error(err); process.exit(1); } }; start(); // Export fastify instance if needed for testing module.exports = fastify;@fastify/envwill throw an error on startup if required variables are missing.NODE_ENV !== 'production').fastify.ready()ensures plugins like@fastify/envare loaded before we accessfastify.config.fastifyEnv.Vonage Service (
src/services/vonageService.js): Create a service to encapsulate Vonage client initialization and message sending logic, handling private key as path or content.// src/services/vonageService.js 'use strict'; const { Vonage } = require('@vonage/server-sdk'); const { WhatsAppText } = require('@vonage/messages'); const path = require('path'); const fs = require('fs'); let vonageClient; function initializeVonageClient(config, logger) { if (!vonageClient) { let privateKeyContent = config.VONAGE_PRIVATE_KEY; // Check if the variable content looks like a path vs the actual key const looksLikePath = /[\\/]|(\.key$)/.test(privateKeyContent); const looksLikePEMKey = privateKeyContent.startsWith('-----BEGIN PRIVATE KEY-----'); if (looksLikePath && !looksLikePEMKey) { // Assume it's a path, try to read the file const privateKeyPath = path.resolve(privateKeyContent); // Resolve relative to CWD try { privateKeyContent = fs.readFileSync(privateKeyPath, 'utf8'); logger.info(`Read private key from path: ${privateKeyPath}`); } catch (err) { logger.error(`Failed to read private key from configured path: ${privateKeyPath}`); logger.error(err); // If reading fails, check if maybe it WAS the key content after all but looked like a path if (config.VONAGE_PRIVATE_KEY.startsWith('-----BEGIN PRIVATE KEY-----')) { logger.warn('VONAGE_PRIVATE_KEY looked like a path but failed to read; attempting to use content directly.'); // Reset to use original content privateKeyContent = config.VONAGE_PRIVATE_KEY; } else { throw new Error('Could not read VONAGE_PRIVATE_KEY file and content does not look like a PEM key. Check path/permissions or provide key content directly.'); } } } else if (looksLikePEMKey) { // Assume it's the key content itself logger.info('Using private key directly from VONAGE_PRIVATE_KEY environment variable content.'); } else { // Content is ambiguous or invalid logger.error('VONAGE_PRIVATE_KEY content is neither a readable path nor does it look like a PEM private key.'); throw new Error('Invalid VONAGE_PRIVATE_KEY configuration.'); } // Now initialize Vonage with the resolved privateKeyContent vonageClient = new Vonage( { apiKey: config.VONAGE_API_KEY, apiSecret: config.VONAGE_API_SECRET, applicationId: config.VONAGE_APPLICATION_ID, privateKey: privateKeyContent, // Use the resolved key content here }, { apiHost: config.VONAGE_API_HOST, // Use the sandbox or production host logger: logger.child({ service: 'vonage-sdk' }), // Pass a child logger for context } ); logger.info('Vonage client initialized.'); } return vonageClient; } async function sendWhatsAppReply(to_number, text, config, logger) { const client = initializeVonageClient(config, logger); // Ensure client is initialized const from_number = config.VONAGE_WHATSAPP_NUMBER; logger.info(`Attempting to send WhatsApp message from ${from_number} to ${to_number}`); try { const response = await client.messages.send( new WhatsAppText({ from: from_number, to: to_number, text: text, }) ); logger.info( { msgUuid: response.messageUUID }, `Message sent successfully` ); return response; } catch (error) { logger.error('Error sending WhatsApp message via Vonage:'); // Log detailed error information if available if (error.response && error.response.data) { logger.error( { vonageError: error.response.data, statusCode: error.response.status }, `Vonage API Error` ); } else { logger.error(error); } // Re-throw or handle appropriately for the caller throw new Error('Failed to send WhatsApp message.'); } } module.exports = { initializeVonageClient, sendWhatsAppReply, };VONAGE_PRIVATE_KEYis a path or the key content.Vonageclient using configuration values and the resolved key content.apiHostfrom the config.JWT Signature Verification Hook (
src/hooks/verifyVonageSignature.js): Create a Fastify hook to verify theAuthorizationheader on incoming status webhooks.// src/hooks/verifyVonageSignature.js 'use strict'; const { verifySignature } = require('@vonage/jwt'); const fp = require('fastify-plugin'); async function verifyVonageSignatureHook(fastify, options) { // Decorate fastify instance with the verification function // so it can be easily used in route options (preHandler) fastify.decorate('verifyVonageSignature', async (request, reply) => { fastify.log.trace('Attempting Vonage JWT signature verification...'); // Use trace for verbose steps const authHeader = request.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { fastify.log.warn('Missing or invalid Authorization header for webhook'); reply.code(401).send({ error: 'Unauthorized: Missing or invalid JWT Bearer token' }); return; // Stop further execution } const token = authHeader.split(' ')[1]; const secret = fastify.config.VONAGE_API_SIGNATURE_SECRET; if (!secret) { fastify.log.error('VONAGE_API_SIGNATURE_SECRET is not configured. Cannot verify webhook signature.'); reply.code(500).send({ error: 'Internal Server Error: Signature secret not configured' }); return; } try { // Note: verifySignature throws on error in some versions, returns bool in others. // Let's assume it returns boolean for safety. const isValid = verifySignature(token, secret); if (!isValid) { fastify.log.warn('Invalid Vonage JWT signature received.'); reply.code(403).send({ error: 'Forbidden: Invalid signature' }); return; // Stop further execution } fastify.log.info('Vonage JWT signature verified successfully.'); } catch (err) { // Catch potential errors from the verifySignature function itself fastify.log.error({ err }, 'Error during JWT signature verification process'); reply.code(500).send({ error: 'Internal Server Error during JWT verification' }); return; // Stop further execution } }); } // Export as a plugin module.exports = fp(verifyVonageSignatureHook);fastify-pluginto properly encapsulate the hook/decorator.verifyVonageSignaturewhich contains the verification logic.Authorization: Bearer <token>header.@vonage/jwt'sverifySignaturefunction with the secret fromfastify.config.401,403,500) if verification fails or config is missing.payload_hashclaim in the JWT matches a SHA-256 hash of the request payload to detect tampering. However, Transport Layer Security (TLS/HTTPS) prevents man-in-the-middle attacks, making payload hash verification optional for HTTPS connections. Source: Vonage Webhook Security DocumentationWebhook Routes (
src/routes/webhooks.js): Define the routes to handle/webhooks/inboundand/webhooks/status.// src/routes/webhooks.js 'use strict'; const { sendWhatsAppReply } = require('../services/vonageService'); async function webhookRoutes(fastify, options) { // Register the JWT verification hook/decorator plugin // This makes fastify.verifyVonageSignature available fastify.register(require('../hooks/verifyVonageSignature')); // --- Inbound Message Webhook --- // No JWT verification applied here by default, as per common practice fastify.post('/webhooks/inbound', async (request, reply) => { fastify.log.info('Received /webhooks/inbound POST request'); fastify.log.debug({ body: request.body }, 'Inbound webhook payload:'); // Use debug for full payload // Basic validation: Check if it's a WhatsApp message with necessary fields if ( !request.body || request.body.channel !== 'whatsapp' || !request.body.from?.number || // Use optional chaining !request.body.message?.content?.text // Check for text content specifically ) { fastify.log.warn({ body: request.body }, 'Invalid, non-WhatsApp, or non-text inbound payload received.'); // Send 200 OK to Vonage even for invalid payloads we don't process, // to prevent retries for malformed requests. Log the issue. return reply.code(200).send(); } const requesterNumber = request.body.from.number; const messageText = request.body.message.content.text; const messageUuid = request.body.message_uuid; // Log the message UUID fastify.log.info( { from: requesterNumber, msgUuid: messageUuid }, `Incoming WhatsApp message: ""${messageText}""` ); // Simple auto-reply logic const replyText = `Received your message: ""${messageText}""`; try { await sendWhatsAppReply( requesterNumber, replyText, fastify.config, // Pass config fastify.log // Pass logger ); fastify.log.info({ to: requesterNumber }, `Reply sent successfully`); // Acknowledge receipt to Vonage reply.code(200).send(); } catch (error) { fastify.log.error( { err: error, to: requesterNumber }, `Failed to process inbound message or send reply` ); // Send 500 Internal Server Error if our processing fails reply.code(500).send({ error: 'Failed to process message' }); } }); // --- Status Webhook --- const statusRouteOptions = { // Apply the JWT verification hook specifically to this route's preHandler phase preHandler: [fastify.verifyVonageSignature], // Use the decorated function }; fastify.post('/webhooks/status', statusRouteOptions, async (request, reply) => { fastify.log.info('Received /webhooks/status POST request'); fastify.log.debug({ body: request.body }, 'Status webhook payload:'); // Use debug for payload // Process the status update (e.g., log it, update database) const { message_uuid, status, timestamp, error, usage } = request.body; if (message_uuid && status) { const logData = { msgUuid: message_uuid, status, timestamp, usage }; if (error) { logData.error = error; fastify.log.warn(logData, `Message status update: ${status} with error`); } else { fastify.log.info(logData, `Message status update: ${status}`); } // In a real app: await updateMessageStatusInDB(message_uuid, status, error); } else { fastify.log.warn({ body: request.body }, 'Received status webhook with missing message_uuid or status'); } // Acknowledge receipt to Vonage reply.code(200).send(); }); } module.exports = webhookRoutes;verifyVonageSignatureplugin.debuglevel).sendWhatsAppReply.200 OKor500.preHandler: [fastify.verifyVonageSignature]to apply the JWT check before the main handler runs.200 OK.src/server.js: This was already corrected in step 1 of this section to ensure routes are registered after core plugins. The code in step 1 is final.5. Error Handling and Logging
Fastify provides excellent built-in logging via Pino and error handling capabilities.
pino-prettyfor readable development logs insrc/server.js. In production (NODE_ENV=production), it logs structured JSON, suitable for log aggregation services (like Datadog, Splunk, ELK stack).fastify.log(or child loggers) to our services (vonageService.js) for consistent, contextual logging.message_uuidor phone numbers). Different log levels (info,warn,error,debug,trace) are used appropriately.@fastify/sensibleprovides standard error handling and utility error constructors.verifyVonageSignature.js) explicitly sends401,403or500errors upon verification failure or configuration issues.try...catchblocks:sendWhatsAppReply).500 Internal Server Errorto Vonage./webhooks/inbound), we log the issue and return a200 OKto Vonage to prevent unnecessary retries, while ensuring our logs capture the problem.6. WhatsApp Business API Rate Limits and Production Considerations
Before deploying your application to production with a WhatsApp Business Account (not the Sandbox), you must understand Meta's rate limits and messaging restrictions.
Messaging Limits (Tiered System)
WhatsApp uses a tiered messaging limit system based on phone number quality and verification status:
How to Increase Tiers:
Source: Meta WhatsApp Messaging Limits
Rate Limits
Quality Rating Requirements
Meta assigns quality ratings (High, Medium, Low) based on user interactions:
Low quality ratings can reduce your messaging quotas or restrict messaging capabilities. Maintain high-quality interactions by responding promptly, respecting user preferences, and following WhatsApp's messaging policies.
24-Hour Conversation Window
Source: Meta WhatsApp Pricing Updates 2024
Production Migration Checklist
When moving from Sandbox to production WhatsApp Business Account:
VONAGE_API_HOSTfromhttps://messages-sandbox.nexmo.comtohttps://api.nexmo.comVONAGE_WHATSAPP_NUMBERto your verified business phone numberSource: Vonage WhatsApp Getting Started Guide