code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Article

Build Production WhatsApp & SMS Integration with Vonage, Node.js & Express

Complete guide to building two-way WhatsApp and SMS messaging with Vonage Messages API, Node.js, Express, and PostgreSQL. Includes webhook security, database integration, and deployment.

Build Production WhatsApp & SMS Integration with Vonage, Node.js & Express

Build a production-ready Node.js application using Express to send and receive both WhatsApp and SMS messages via the Vonage Messages API. This complete guide covers project setup, core messaging logic, database integration, webhook security with JWT verification, error handling, and deployment strategies.

What You'll Build:

  • Send SMS and WhatsApp messages programmatically through Vonage Messages API
  • Receive incoming SMS and WhatsApp messages via secure webhooks
  • Store message history in PostgreSQL with Prisma ORM
  • Verify incoming webhook requests using JWT signatures (HMAC-SHA256)
  • Handle errors gracefully with comprehensive logging

Target Audience: Developers familiar with Node.js and basic web concepts implementing reliable two-way messaging capabilities.

Table of Contents

  1. System Architecture
  2. Prerequisites
  3. Setting Up the Project
  4. Implementing Core Functionality
  5. Building the API Layer
  6. Integrating with Vonage
  7. Implementing Error Handling
  8. Creating Database Schema
  9. Frequently Asked Questions

By the end of this tutorial, you will have a robust application capable of:

  • Sending SMS and WhatsApp messages programmatically.
  • Receiving incoming SMS and WhatsApp messages via webhooks.
  • Storing message history in a database.
  • Verifying incoming webhook requests for security.
  • Handling errors gracefully.

Technologies Used:

  • Node.js: JavaScript runtime environment. Minimum required: Node.js 16.13.0+ (as of October 2025, recommended: Node.js 18+ LTS for production use).
  • Express: Minimalist web framework for Node.js. Version 4.x stable.
  • Vonage Messages API: Unified API for sending and receiving messages across multiple channels (SMS, WhatsApp, MMS, etc.).
  • Vonage Node SDK (@vonage/server-sdk): Simplifies interaction with Vonage APIs. Current version: 3.24.1 (October 2025). Version 3.x uses TypeScript, Promises (no callbacks), and requires async/await or .then/.catch patterns. Official Documentation
  • PostgreSQL: Robust open-source relational database. Version 9.6+ supported by Prisma.
  • Prisma: Modern ORM for Node.js and TypeScript. Version 5.x (minimum Node.js 16.13.0, TypeScript 4.7+). System Requirements
  • ngrok: Tool to expose local servers to the internet for webhook testing.
  • dotenv: Module to load environment variables from a .env file. Version 16.x.

Important Version Compatibility Notes (October 2025):

  • Node.js 16.x reached end-of-life on September 11, 2023. Upgrade to Node.js 18+ LTS for production.
  • Vonage SDK v3.x requires Promise-based code patterns. Version 2.x (callback-based) is deprecated.
  • Prisma 5.x is the last major version to support Node.js 16. Prisma 6+ requires Node.js 18+.

System Architecture

The basic flow of information is as follows:

  1. Outbound Messages: Your Node.js/Express application uses the Vonage Node SDK to send an API request to the Vonage Messages API, specifying the channel (SMS or WhatsApp), recipient, sender ID/number, and message content. Vonage handles the delivery to the end user.
  2. Inbound Messages: A user sends an SMS or WhatsApp message to your Vonage provisioned number/WhatsApp sender. Vonage receives the message and sends an HTTP POST request (webhook) to a predefined endpoint in your Express application (/webhooks/inbound).
  3. Message Status Updates: Vonage sends status updates (e.g., delivered, read, failed) about outbound messages to another predefined webhook endpoint in your application (/webhooks/status).
  4. Database Interaction: Your application interacts with a PostgreSQL database (via Prisma) to store records of inbound and outbound messages, along with their statuses.
text
[ User ] <---- SMS/WhatsApp ----> [ Vonage Platform ] <---- API Calls/Webhooks ----> [ Your Node.js/Express App ] <---- Prisma ----> [ PostgreSQL DB ]
    ^                                    |
    |--- ( ngrok tunnel during dev ) ----|

Prerequisites

  • Vonage API Account: Sign up for free at Vonage. You'll get free credit to start.
  • Node.js: Version 16.13.0 or higher required (18+ LTS recommended for production). Install from nodejs.org.
  • npm or yarn: Package manager for Node.js (comes with Node.js).
  • ngrok: Install from ngrok.com. A free account is sufficient for this guide.
  • PostgreSQL Database: A running instance accessible to your application. You will need to set this up yourself, common methods include running it locally, using Docker, or utilizing a cloud database provider.
  • Vonage CLI (Optional but Recommended): Install via npm: npm install -g @vonage/cli. Useful for managing applications and numbers.

Critical WhatsApp Sandbox Limitations (October 2025):

⚠️ Rate Limits:

  • 1 message per second across all channels
  • 100 messages per month total (all channels combined)
  • These limits apply to the entire sandbox, not per user

⚠️ WhatsApp Business Requirements:

  • Testing: Use Messages API Sandbox (shared number, ~10 minutes setup)
  • Production: Requires approved WhatsApp Business Account (WABA) and verified phone number
  • January 2024 Policy: Every business must have their own WABA and phone number (no shared accounts)
  • Phone Number Requirements: Must not be registered elsewhere on WhatsApp, must accept VOICE calls or SMS for verification

⚠️ Sandbox Registration:

  • Recipients must opt-in by sending a specific message to the Sandbox number before they can receive messages
  • Sandbox number typically provided: +14157386102 (may vary by region)

Vonage Messages API Sandbox Documentation | WhatsApp Business API Guide


1. Setting Up the Project

Let's initialize the project, install dependencies, and configure the basic structure.

1.1 Create Project Directory & Initialize:

Open your terminal and run the following commands:

bash
# Create a new directory for your project
mkdir vonage-messaging-app
cd vonage-messaging-app

# Initialize a new Node.js project
npm init -y

1.2 Install Dependencies:

We need Express for the server, the Vonage SDK, Prisma for database interaction, dotenv for environment variables, and the PostgreSQL driver.

bash
# Install runtime dependencies
npm install express @vonage/server-sdk dotenv pg @prisma/client

# Install development dependencies (Prisma CLI, nodemon for auto-restarts)
npm install --save-dev prisma nodemon

1.3 Configure Prisma:

Initialize Prisma in your project. This creates a prisma directory with a schema.prisma file and a .env file (if one doesn't exist).

bash
npx prisma init --datasource-provider postgresql

1.4 Configure Environment Variables (.env):

Prisma creates a basic .env file. Open it and add the necessary variables for Vonage and your database. Crucially, obtain these values as described below the code block.

dotenv
# .env

# Database Connection (Adjust based on your PostgreSQL setup)
# Example: postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public
DATABASE_URL="postgresql://your_db_user:your_db_password@localhost:5432/vonage_messages?schema=public"

# Vonage API Credentials
VONAGE_API_KEY="YOUR_VONAGE_API_KEY"
VONAGE_API_SECRET="YOUR_VONAGE_API_SECRET"

# Vonage Application Credentials
VONAGE_APPLICATION_ID="YOUR_VONAGE_APPLICATION_ID"
# Path relative to your project root where you save the private key
VONAGE_PRIVATE_KEY_PATH="./private.key"

# Vonage Numbers / Senders
# Your Vonage virtual number capable of sending SMS
VONAGE_SMS_FROM_NUMBER="YOUR_VONAGE_SMS_NUMBER"
# Your Vonage WhatsApp Sender ID (often the same as SMS number or a dedicated one)
VONAGE_WHATSAPP_FROM_NUMBER="YOUR_VONAGE_WHATSAPP_NUMBER" # Use the sandbox number initially (e.g., 14157386102)

# Vonage Webhook Security
# Generate a secure random string for this
VONAGE_SIGNATURE_SECRET="YOUR_WEBHOOK_SIGNATURE_SECRET"

# Server Configuration
PORT=3000 # Or any port you prefer

How to Obtain Environment Variable Values:

  • DATABASE_URL: Construct this based on your PostgreSQL setup (username, password, host, port, database name).
  • VONAGE_API_KEY, VONAGE_API_SECRET: Found at the top of your Vonage API Dashboard after logging in.
  • VONAGE_APPLICATION_ID, VONAGE_PRIVATE_KEY_PATH: You will generate these when creating a Vonage Application (Section 4). The path should point to where you save the downloaded private.key file within your project.
  • VONAGE_SMS_FROM_NUMBER: A virtual number you purchase or rent from Vonage (Section 4). Must be SMS-capable.
  • VONAGE_WHATSAPP_FROM_NUMBER: For initial testing, use the number provided by the Vonage Messages API Sandbox (Section 4). For production, this will be your approved WhatsApp Business number linked to Vonage.
  • VONAGE_SIGNATURE_SECRET: Critical for webhook security. Go to your Vonage Dashboard Settings. Under "API settings", find the "Signature secret" for your API key. This is used to verify JWT signatures in webhook requests. If none exists, you might need to generate one. Webhook Security (October 2025): Vonage uses JWT (JSON Web Token) Bearer Authorization with HMAC-SHA256 signatures in the Vonage-Signature or Authorization header. The signature expires 5 minutes after issuance. Webhook Security Guide
  • PORT: The local port your Express server will listen on.

Webhook Security Requirements (October 2025):

  • JWT Verification: All webhook requests include a JWT signature that must be verified
  • Signature Location: Check Vonage-Signature or Authorization header (format: "Bearer <token>")
  • Algorithm: HMAC-SHA256
  • Expiration: Signatures expire 5 minutes after issuance
  • Payload Hash: Optionally verify payload integrity by comparing SHA-256 hash to payload_hash claim in JWT
  • Production Requirement: Always verify signatures in production to prevent unauthorized webhook submissions

1.5 Configure .gitignore:

Ensure sensitive files and generated files are not committed to version control. Create a .gitignore file:

text
# .gitignore

# Node
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# Environment Variables
.env

# Vonage Private Key
private.key

# Build Output (if applicable)
dist/
build/

# OS generated files
.DS_Store
Thumbs.db

1.6 Project Structure:

Organize your code for clarity. A possible structure:

vonage-messaging-app/ ├── prisma/ │ ├── schema.prisma │ └── migrations/ ├── src/ │ ├── services/ │ │ └── vonage.service.js │ ├── routes/ │ │ ├── api.routes.js │ │ └── webhooks.routes.js │ ├── middleware/ │ │ └── verifySignature.js │ ├── controllers/ │ │ ├── message.controller.js │ │ └── webhook.controller.js │ ├── utils/ │ │ └── logger.js │ ├── db.js # Prisma client instance │ └── server.js # Express server setup ├── .env ├── .gitignore ├── package.json ├── package-lock.json └── private.key # <-- Store your downloaded private key here

1.7 Add npm Scripts:

Modify the scripts section in your package.json for easier development and running:

json
{
  "name": "vonage-messaging-app",
  "version": "1.0.0",
  "description": "",
  "main": "src/server.js",
  "scripts": {
    "start": "node src/server.js",
    "dev": "nodemon src/server.js",
    "db:migrate": "npx prisma migrate dev",
    "db:generate": "npx prisma generate",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@prisma/client": "^5.x.x",
    "@vonage/server-sdk": "^3.x.x",
    "dotenv": "^16.x.x",
    "express": "^4.x.x",
    "pg": "^8.x.x"
  },
  "devDependencies": {
    "nodemon": "^3.x.x",
    "prisma": "^5.x.x"
  }
}

(Note: Replace x.x.x placeholders in package.json with actual installed versions if needed, or remove the version numbers entirely if managing via package-lock.json)

Now you can run npm run dev to start the server with auto-reloading via nodemon, and npm run db:migrate to apply database schema changes.


2. Implementing Core Functionality (Sending & Receiving Logic)

Let's write the code to interact with Vonage and handle basic server setup.

2.1 Initialize Prisma Client:

Create src/db.js to export a singleton instance of the Prisma client.

javascript
// src/db.js
const { PrismaClient } = require('@prisma/client');

const prisma = new PrismaClient();

module.exports = prisma;

2.2 Create Utility Logger:

Create a simple logger utility in src/utils/logger.js. You can replace this with a more robust library like Winston later.

javascript
// src/utils/logger.js
const logger = {
    log: (...args) => console.log('[LOG]', ...args),
    error: (...args) => console.error('[ERROR]', ...args),
    warn: (...args) => console.warn('[WARN]', ...args),
    info: (...args) => console.info('[INFO]', ...args),
};
module.exports = logger;

2.3 Create Vonage Service:

This service will encapsulate interactions with the Vonage SDK.

Important: Vonage SDK v3.x Changes (October 2025):

  • No Callbacks: Version 3.x removed all callback functions. Must use Promises with async/await or .then/.catch
  • TypeScript Native: Full TypeScript support with improved IDE code completion
  • Breaking Changes: Code written for SDK v2.x will not work without migration
  • Migration Required: If upgrading from v2.x, see v2 to v3 Migration Guide
javascript
// src/services/vonage.service.js
require('dotenv').config(); // Ensure env vars are loaded
const { Vonage } = require('@vonage/server-sdk');
const { Message } = require('@vonage/server-sdk/dist/messages/message'); // Adjusted path if needed
const logger = require('../utils/logger'); // Import the logger
const prisma = require('../db');

// Initialize Vonage client with v3.x syntax (Promise-based)
const vonage = new Vonage({
    apiKey: process.env.VONAGE_API_KEY,
    apiSecret: process.env.VONAGE_API_SECRET,
    applicationId: process.env.VONAGE_APPLICATION_ID,
    privateKey: process.env.VONAGE_PRIVATE_KEY_PATH,
});

/**
 * Sends a message via the Vonage Messages API.
 * @param {string} to - Recipient phone number (E.164 format).
 * @param {string} text - Message content.
 * @param {'sms' | 'whatsapp'} channel - Messaging channel.
 * @returns {Promise<object>} - The Vonage API response.
 */
async function sendMessage(to, text, channel) {
    const from = channel === 'sms'
        ? process.env.VONAGE_SMS_FROM_NUMBER
        : process.env.VONAGE_WHATSAPP_FROM_NUMBER;

    if (!from) {
        logger.error(`Vonage 'from' number/ID not configured for channel: ${channel}`);
        throw new Error(`Sender ID not configured for ${channel}`);
    }

    logger.info(`Attempting to send ${channel} message from ${from} to ${to}`);

    try {
        // Use the Message builder for flexibility
        const message = new Message(
            { type: 'text', text: text }, // Content
            { number: to },               // To
            { type: channel, number: from } // From
        );

        const response = await vonage.messages.send(message);
        logger.info('Message sent successfully:', response);

        // Store outbound message record (async, don't block response)
        prisma.message.create({
            data: {
                vonageMessageId: response.message_uuid,
                direction: 'outbound',
                channel: channel,
                to: to,
                from: from,
                text: text,
                status: 'submitted', // Initial status from Vonage
                timestamp: new Date(), // Use current time as submission time
            }
        }).catch(dbError => logger.error('Failed to save outbound message to DB:', dbError));


        return response; // Contains message_uuid
    } catch (error) {
        const errorData = error?.response?.data || { message: error.message };
        logger.error('Error sending Vonage message:', errorData);

        // Attempt to store failed submission
         prisma.message.create({
            data: {
                vonageMessageId: `failed_${Date.now()}`, // Placeholder ID
                direction: 'outbound',
                channel: channel,
                to: to,
                from: from,
                text: text,
                status: 'failed_submission',
                timestamp: new Date(),
                errorDetails: JSON.stringify(errorData),
            }
        }).catch(dbError => logger.error('Failed to save FAILED outbound message to DB:', dbError));

        throw error; // Re-throw for the controller to handle
    }
}

module.exports = {
    sendMessage,
    vonage // Export instance if needed elsewhere (e.g., for signature verification)
};

2.4 Basic Express Server Setup:

Create the main server file src/server.js.

javascript
// src/server.js
require('dotenv').config();
const express = require('express');
const logger = require('./utils/logger');
const apiRoutes = require('./routes/api.routes');
const webhookRoutes = require('./routes/webhooks.routes');
const prisma = require('./db'); // Import to ensure connection pool is ready (optional)

const app = express();
const PORT = process.env.PORT || 3000;

// --- IMPORTANT: Raw Body Middleware for Webhook Verification ---
// This MUST come BEFORE the webhook routes are defined and BEFORE express.json()
// if you want the default JSON parser to run afterwards on those routes.
// However, it's safer to put it just before the webhook router is used.
// We will configure it more precisely in section 7.1.
// For now, let's use the standard JSON parser first.

// Standard Middleware
// Note: We will modify express.json() later in Section 7.1 for webhook signature verification
app.use(express.json()); // Parse JSON bodies
app.use(express.urlencoded({ extended: true })); // Parse URL-encoded bodies

// Simple Request Logger Middleware
app.use((req, res, next) => {
    logger.info(`${req.method} ${req.url}`);
    next();
});

// Routes
app.use('/api', apiRoutes);
app.use('/webhooks', webhookRoutes); // Webhook routes will include specific body parsing/verification

// Basic Root Route
app.get('/', (req, res) => {
    res.send('Vonage Messaging App is running!');
});

// Global Error Handler (Basic)
app.use((err, req, res, next) => {
    logger.error('Unhandled Error:', err);
    res.status(500).json({ error: 'Internal Server Error' });
});

// Start Server and store the server instance
const server = app.listen(PORT, () => {
    logger.log(`Server listening on port ${PORT}`);
    logger.log(`Local URL: http://localhost:${PORT}`);
    // Remind about ngrok for webhooks
    logger.warn('Remember to run ngrok and update Vonage webhook URLs if testing locally!');
    logger.warn(`Example ngrok command: ngrok http ${PORT}`);
});

// Graceful Shutdown (Optional but Recommended)
process.on('SIGTERM', async () => {
    logger.info('SIGTERM signal received. Closing HTTP server.');
    // Close the server (requires the 'server' variable assigned above)
    server.close(async () => {
        logger.info('HTTP server closed.');
        // Disconnect Prisma Client
        try {
            await prisma.$disconnect();
            logger.info('Prisma Client disconnected.');
        } catch (e) {
            logger.error('Error disconnecting Prisma:', e);
        } finally {
            process.exit(0);
        }
    });
});

3. Building the API Layer (Sending Messages)

Let's create an API endpoint to trigger sending messages.

3.1 Create Message Controller:

Handles the logic for the send message route.

javascript
// src/controllers/message.controller.js
const vonageService = require('../services/vonage.service');
const logger = require('../utils/logger');

async function handleSendMessage(req, res) {
    const { to, text, channel } = req.body;

    // Basic Validation
    if (!to || !text || !channel) {
        return res.status(400).json({ error: 'Missing required fields: to, text, channel' });
    }

    if (channel !== 'sms' && channel !== 'whatsapp') {
        return res.status(400).json({ error: 'Invalid channel. Must be "sms" or "whatsapp".' });
    }

    // Decide whether to reject or attempt anyway based on format
    // Stricter E.164 format check (requires '+', country code, and subscriber number)
    if (!/^\+\d{10,15}$/.test(to)) {
        logger.error(`Invalid 'to' number format received: ${to}`);
        return res.status(400).json({ error: 'Invalid "to" number format. Use E.164 standard (e.g., "+15551234567").' });
    }


    try {
        const response = await vonageService.sendMessage(to, text, channel);
        res.status(202).json({ // 202 Accepted - request initiated
            message: `Message sending initiated via ${channel}.`,
            message_uuid: response.message_uuid
        });
    } catch (error) {
        logger.error(`Failed to send message via API: ${error.message}`);
        // Determine appropriate status code based on error type if possible
        const statusCode = error.response?.status || 500;
        const errorDetails = error.response?.data?.title || error.message;
        res.status(statusCode).json({
            error: 'Failed to send message',
            details: errorDetails
        });
    }
}

module.exports = {
    handleSendMessage,
};

3.2 Create API Routes:

Define the route that uses the controller.

javascript
// src/routes/api.routes.js
const express = require('express');
const messageController = require('../controllers/message.controller');
// Add rate limiting later (Section 7)

const router = express.Router();

// POST /api/send-message
router.post('/send-message', messageController.handleSendMessage);

// Add a simple health check endpoint
router.get('/health', (req, res) => {
    res.status(200).json({ status: 'UP' });
});


module.exports = router;

3.3 Testing the API Endpoint:

Once the server is running (npm run dev), you can test this endpoint using curl or Postman.

Curl Example (SMS):

bash
curl -X POST http://localhost:3000/api/send-message \
-H "Content-Type: application/json" \
-d '{
  "to": "+15551234567",
  "text": "Hello from Node.js via Vonage SMS!",
  "channel": "sms"
}'

Curl Example (WhatsApp):

Remember: WhatsApp sending via Sandbox requires the recipient to have first messaged the Sandbox number.

bash
curl -X POST http://localhost:3000/api/send-message \
-H "Content-Type: application/json" \
-d '{
  "to": "+15551234567",
  "text": "Hello from Node.js via Vonage WhatsApp Sandbox!",
  "channel": "whatsapp"
}'

Expected JSON Response (Success):

json
{
  "message": "Message sending initiated via sms.",
  "message_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}

Expected JSON Response (Validation Error):

json
{
  "error": "Invalid \"to\" number format. Use E.164 standard (e.g., \"+15551234567\")."
}

Expected JSON Response (Vonage API Error):

json
{
  "error": "Failed to send message",
  "details": "Authentication failure"
}

4. Integrating with Vonage (Dashboard & ngrok Setup)

This section details the crucial steps within the Vonage Dashboard and using ngrok.

4.1 Start ngrok:

Before configuring Vonage webhooks, you need a publicly accessible URL for your local server.

Open a new terminal window in your project directory and run:

bash
# Replace 3000 if you used a different PORT in .env
ngrok http 3000

ngrok will output something like this:

Session Status online Account Your Name (Plan: Free) Version x.x.x Region United States (us-cal-1) Web Interface http://127.0.0.1:4040 Forwarding https://xxxxxxxxxxxx.ngrok-free.app -> http://localhost:3000 Connections ttl opn rt1 rt5 p50 p90 0 0 0.00 0.00 0.00 0.00

Copy the https://xxxxxxxxxxxx.ngrok-free.app URL. This is your public base URL. Keep this terminal running.

4.2 Create a Vonage Application:

Vonage Applications act as containers for your configurations (like webhook URLs) and link your virtual numbers.

  1. Log in to the Vonage API Dashboard.
  2. Navigate to "Applications" -> "Create a new application".
  3. Name: Give it a descriptive name (e.g., "Node Express Messenger").
  4. Generate Public/Private Key: Click "Generate public and private key". Immediately save the private.key file that downloads into the root of your project directory (or wherever VONAGE_PRIVATE_KEY_PATH points). Vonage does not store this key, so save it securely.
  5. Capabilities:
    • Toggle Messages ON.
    • Inbound URL: Paste your ngrok Forwarding URL and append /webhooks/inbound. Example: https://xxxxxxxxxxxx.ngrok-free.app/webhooks/inbound
    • Status URL: Paste your ngrok Forwarding URL and append /webhooks/status. Example: https://xxxxxxxxxxxx.ngrok-free.app/webhooks/status
    • (Optional) Enable other capabilities like Voice if needed later.
  6. Click "Create application".
  7. On the next page, you'll see the Application ID. Copy this value and paste it into your .env file for VONAGE_APPLICATION_ID.

4.3 Link a Virtual Number (for SMS):

You need a Vonage number to send/receive SMS messages.

  1. If you don't have one, go to "Numbers" -> "Buy numbers". Search for a number with SMS capability in your desired country and purchase it.
  2. Go back to "Applications", find the application you just created, and click its name.
  3. Scroll down to the "Link numbers" section.
  4. Find your purchased number and click the "Link" button next to it.
  5. Copy this phone number (in E.164 format, e.g., 12015550123) and paste it into your .env file for VONAGE_SMS_FROM_NUMBER.

4.4 Set Up Messages API Sandbox (for WhatsApp Testing):

The Sandbox provides a shared number for testing WhatsApp integration without needing your own approved WhatsApp Business number initially.

  1. In the Vonage Dashboard, navigate to "Developer Tools" -> "Messages API Sandbox".
  2. Whitelist your number: Follow the instructions to send a specific WhatsApp message from your personal phone number to the provided Sandbox number (e.g., +14157386102). This allows the Sandbox to send messages to you.
  3. Configure Webhooks: In the Sandbox page, find the "Webhooks" section.
    • Inbound URL: Enter your ngrok URL + /webhooks/inbound (e.g., https://xxxxxxxxxxxx.ngrok-free.app/webhooks/inbound).
    • Status URL: Enter your ngrok URL + /webhooks/status (e.g., https://xxxxxxxxxxxx.ngrok-free.app/webhooks/status).
    • Click "Save webhooks".
  4. Sandbox Number: Note the Sandbox WhatsApp number (e.g., 14157386102). Use this value in your .env file for VONAGE_WHATSAPP_FROM_NUMBER during testing.

4.5 Ensure Correct Vonage API Settings:

Vonage has older APIs (like the SMS API). Ensure your account is set to use the Messages API as the default for SMS to receive webhooks in the correct format expected by this guide.

  1. Go to Vonage Dashboard Settings.
  2. Scroll down to "API settings".
  3. Under "Default SMS Setting", ensure "Messages API" is selected. If not, change it and click "Save changes".

5. Implementing Error Handling, Logging, and Retry Mechanisms

Robust applications need proper error handling and logging.

5.1 Consistent Error Handling Strategy:

  • Service Layer: Catch specific Vonage API errors in vonage.service.js. Log detailed errors. Re-throw generic or transformed errors for controllers.
  • Controller Layer: Catch errors from the service layer. Log context-specific errors. Send appropriate HTTP status codes (4xx for client errors, 5xx for server errors) and JSON error responses to the client.
  • Webhook Handlers: Use try...catch extensively. Always respond with 200 OK quickly to Vonage, even if processing fails internally (log the failure). Vonage retries webhooks if it doesn't receive a 2xx response, which can cause duplicate processing.
  • Global Error Handler: Use the Express global error handler (app.use((err, req, res, next) => {...})) in server.js as a final catch-all for unexpected errors.

5.2 Logging:

The simple logger.js provided is basic. For production, consider a more structured logging library like Winston or Pino.

  • Log Levels: Use different levels (INFO, WARN, ERROR, DEBUG) appropriately.
  • Structured Logging: Log objects (JSON format) instead of just strings. Include timestamps, request IDs (if using), error details, and relevant context. This makes logs easier to parse and analyze.
  • Log Destinations: Configure logging to output to console during development and to files or a log aggregation service (like Datadog, Logstash, Splunk, Sentry) in production.

Example using basic logger (already integrated):

javascript
// In vonage.service.js (already added)
logger.info('Message sent successfully:', response);
logger.error('Error sending Vonage message:', error?.response?.data || error.message);

// In message.controller.js (already added)
logger.error(`Failed to send message via API: ${error.message}`);

// In webhook controller (add in Section 6)
logger.info('Received inbound message webhook:', JSON.stringify(req.body, null, 2));
logger.error('Error processing inbound webhook:', error);

5.3 Retry Mechanisms (Vonage Webhooks):

Vonage handles retries for webhooks automatically if it doesn't receive a 200 OK response within a certain timeout.

  • Your Responsibility: Ensure your webhook endpoints respond quickly with 200 OK. Perform time-consuming processing (like complex database operations, external API calls) asynchronously after sending the response.
  • Idempotency: Design your webhook handlers to be idempotent. This means processing the same webhook request multiple times should not cause unintended side effects (e.g., creating duplicate database entries). Check if a message ID already exists before creating a new record.

5.4 Testing Error Scenarios:

  • Invalid Credentials: Temporarily change VONAGE_API_KEY or VONAGE_PRIVATE_KEY_PATH in .env and try sending a message.
  • Invalid Recipient: Send a message to a known invalid number format (e.g., missing '+').
  • Network Issues: Simulate network failure (e.g., disconnect your machine briefly) while ngrok is running and try sending/receiving.
  • Webhook Errors: Introduce an error in your webhook processing logic (e.g., throw new Error('Test webhook error')) and observe logs. Ensure a 200 OK is still sent if the error is caught correctly after the response.
  • Signature Verification Failure: Send a manual POST request to your webhook endpoint without the correct Vonage signature (Section 7).

6. Creating a Database Schema and Data Layer (Prisma)

Let's define the database schema and integrate it into our webhook handlers.

6.1 Define Prisma Schema:

Open prisma/schema.prisma and define the model for storing messages.

prisma
// prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model Message {
  id              String    @id @default(cuid()) // Unique ID for the DB record
  vonageMessageId String    @unique // The UUID from Vonage (important for idempotency)
  direction       String    // 'inbound' or 'outbound'
  channel         String    // 'sms', 'whatsapp', etc.
  from            String    // Sender number/ID (E.164 or WhatsApp ID)
  to              String    // Recipient number/ID (E.164 or WhatsApp ID)
  text            String?   // Message content (nullable if it's not text)
  status          String    // Vonage status (e.g., 'submitted', 'delivered', 'read', 'inbound', 'failed')
  timestamp       DateTime  // Timestamp from Vonage webhook or submission time
  errorDetails    Json?     // Store error info if status is 'failed' or 'rejected'
  imageUrl        String?   // Store image URL for MMS/WhatsApp images

  createdAt       DateTime  @default(now())
  updatedAt       DateTime  @updatedAt
}

6.2 Create and Apply Migration:

Run the Prisma migrate command to generate SQL migration files and apply them to your database.

bash
# Ensure your DATABASE_URL in .env is correct and the DB is running
npm run db:migrate -- --name init-messages-table

This creates a migrations folder and updates your database schema. You also need to generate the Prisma Client code:

bash
npm run db:generate

6.3 Create Webhook Controller:

Handles the logic for processing incoming Vonage webhooks.

javascript
// src/controllers/webhook.controller.js
const prisma = require('../db');
const logger = require('../utils/logger');

/**
 * Handles inbound message webhooks (/webhooks/inbound)
 */
async function handleInboundMessage(req, res) {
    const payload = req.body;
    logger.info('Received /inbound webhook:', JSON.stringify(payload, null, 2));

    // Respond quickly to Vonage
    res.status(200).send('OK');

    // --- Asynchronous Processing ---
    (async () => {
        try {
            // Basic validation - ensure essential fields exist
            if (!payload.message_uuid || !payload.from || !payload.to || !payload.channel) {
                logger.warn('Inbound webhook missing essential fields.', payload);
                return; // Don't process incomplete data
            }

            // Idempotency Check: See if we already processed this message UUID
            const existingMessage = await prisma.message.findUnique({
                where: { vonageMessageId: payload.message_uuid },
            });

            if (existingMessage) {
                logger.warn(`Duplicate inbound message webhook received: ${payload.message_uuid}. Ignoring.`);
                return;
            }

            // Extract sender/recipient numbers correctly (can be object or string)
            const fromNumber = typeof payload.from === 'object' ? payload.from.number : payload.from;
            const toNumber = typeof payload.to === 'object' ? payload.to.number : payload.to;

            // Determine text content, handling different payload structures
            let textContent = null;
            if (payload.message?.content?.type === 'text') {
                textContent = payload.message.content.text;
            } else if (payload.text) { // Fallback for older/different structures
                textContent = payload.text;
            }

            // Determine image URL, handling different payload structures
            let imageUrlContent = null;
            if (payload.message?.content?.type === 'image') {
                imageUrlContent = payload.message.content.url;
            } else if (payload.image?.[0]?.url) { // Fallback for potential alternative structure
                imageUrlContent = payload.image[0].url;
            }

            // Store the inbound message
            await prisma.message.create({
                data: {
                    vonageMessageId: payload.message_uuid,
                    direction: 'inbound',
                    channel: payload.channel,
                    from: fromNumber,
                    to: toNumber,
                    text: textContent,
                    status: 'inbound', // Custom status for received messages
                    timestamp: payload.timestamp ? new Date(payload.timestamp) : new Date(),
                    imageUrl: imageUrlContent,
                    // Add more fields from the payload if needed (e.g., payload.message.content for various types)
                },
            });
            logger.info(`Inbound message ${payload.message_uuid} saved to DB.`);

        } catch (error) {
            logger.error(`Error processing inbound webhook ${payload.message_uuid}:`, error);
            // Consider sending an alert here (e.g., to Sentry)
        }
    })(); // Immediately invoke the async function
}

/**
 * Handles message status webhooks (/webhooks/status)
 */
async function handleMessageStatus(req, res) {
    const payload = req.body;
    logger.info('Received /status webhook:', JSON.stringify(payload, null, 2));

     // Respond quickly to Vonage
    res.status(200).send('OK');

    // --- Asynchronous Processing ---
    (async () => {
        try {
            if (!payload.message_uuid || !payload.status) {
                 logger.warn('Status webhook missing essential fields.', payload);
                return;
            }

            // Find the original outbound message by Vonage UUID
            const message = await prisma.message.findUnique({
                where: { vonageMessageId: payload.message_uuid },
            });

            if (!message) {
                // This can happen if the webhook arrives before the initial send record is saved,
                // or if it's a status for an inbound message (which we might not track status for).
                logger.warn(`Received status update for unknown message UUID: ${payload.message_uuid}. Status: ${payload.status}`);
                return;
            }

            // Update the message status
            await prisma.message.update({
                where: { vonageMessageId: payload.message_uuid },
                data: {
                    status: payload.status.toLowerCase(), // Normalize status
                    timestamp: payload.timestamp ? new Date(payload.timestamp) : new Date(), // Update timestamp to status time
                    // Optionally store error details if the status indicates failure
                    errorDetails: (payload.status === 'failed' || payload.status === 'rejected') ? payload.error || payload : undefined,
                    updatedAt: new Date(), // Explicitly set updatedAt
                },
            });
            logger.info(`Updated status for message ${payload.message_uuid} to ${payload.status}.`);

        } catch (error) {
            logger.error(`Error processing status webhook ${payload.message_uuid}:`, error);
            // Consider sending an alert here
        }
    })(); // Immediately invoke the async function
}

module.exports = {
    handleInboundMessage,
    handleMessageStatus,
};

6.4 Create Webhook Routes:

Define the routes that use the webhook controller.

javascript
// src/routes/webhooks.routes.js
const express = require('express');
const webhookController = require('../controllers/webhook.controller');
// Webhook signature verification middleware will be added in Section 7

const router = express.Router();

// POST /webhooks/inbound - Receive incoming messages
router.post('/inbound', webhookController.handleInboundMessage);

// POST /webhooks/status - Receive message status updates
router.post('/status', webhookController.handleMessageStatus);

module.exports = router;

Frequently Asked Questions

What Node.js version is required for Vonage SDK v3.x?

Minimum requirement: Node.js 16.13.0+ Recommended for production: Node.js 18+ LTS

Note that Node.js 16.x reached end-of-life on September 11, 2023. Vonage SDK v3.x uses TypeScript and Promises (no callbacks), requiring async/await or .then/.catch patterns. For complete compatibility information, see the Vonage Node SDK documentation.

What are the Vonage Messages API Sandbox rate limits?

The Messages API Sandbox has these limitations:

  • 1 message per second across all channels (SMS, WhatsApp, etc.)
  • 100 messages per month total (all channels combined)
  • Limits apply to the entire sandbox, not per user

For production use, you need an approved WhatsApp Business Account (WABA) with your own verified phone number. Learn more about sandbox limitations.

How do I verify Vonage webhook signatures?

Vonage uses JWT (JSON Web Token) Bearer Authorization with HMAC-SHA256 signatures:

  1. Extract JWT: Check the Vonage-Signature or Authorization header (format: "Bearer <token>")
  2. Verify signature: Use your signature secret from Vonage Dashboard Settings
  3. Check expiration: Signatures expire 5 minutes after issuance
  4. Verify payload (optional): Compare SHA-256 hash of payload to payload_hash claim in JWT

Critical: Always verify signatures in production to prevent unauthorized webhook submissions. See the official webhook security guide.

Can I use Vonage SDK v2.x with this guide?

No. This guide uses Vonage SDK v3.x (current version: 3.24.1 as of October 2025), which has breaking changes:

  • No callbacks: Version 3.x removed all callback functions
  • Promises required: Must use async/await or .then/.catch patterns
  • TypeScript native: Full TypeScript support with improved IDE completion

If you're upgrading from v2.x, see the v2 to v3 Migration Guide.

What are the WhatsApp Business requirements for production?

For production WhatsApp messaging (January 2024 policy):

  • Approved WhatsApp Business Account (WABA) required
  • Your own verified phone number (cannot use shared accounts)
  • Phone number must not be registered elsewhere on WhatsApp
  • Phone number must accept VOICE calls or SMS for verification

For testing: Use the Messages API Sandbox with provided number (typically +14157386102). Recipients must opt-in by sending a specific message to the Sandbox number. WhatsApp Business API Guide.

How do I handle duplicate webhook deliveries?

Implement idempotency checks in your webhook handlers:

javascript
// Check if message UUID already exists before creating
const existingMessage = await prisma.message.findUnique({
    where: { vonageMessageId: payload.message_uuid },
});

if (existingMessage) {
    logger.warn(`Duplicate webhook: ${payload.message_uuid}. Ignoring.`);
    return;
}

Key principles:

  • Always respond with 200 OK quickly (within timeout)
  • Process time-consuming operations asynchronously after sending response
  • Check for existing records using Vonage's message_uuid
  • Design handlers to be idempotent (safe to process multiple times)

What database versions are compatible with Prisma 5.x?

Prisma 5.x supports:

  • PostgreSQL: 9.6+
  • Node.js: 16.13.0+ (minimum)
  • TypeScript: 4.7+ (if using TypeScript)

Important: Prisma 5.x is the last major version to support Node.js 16. Prisma 6+ requires Node.js 18+. For complete system requirements, see the Prisma documentation.

How do I transition from sandbox to production WhatsApp?

Steps to move from sandbox to production:

  1. Apply for WhatsApp Business Account: Use Vonage's Embedded Sign-Up (~10 minutes) or managed onboarding
  2. Verify your business: Provide business information and Facebook Business Manager access
  3. Verify phone number: Submit phone number that accepts VOICE or SMS calls
  4. Wait for approval: Typically 1-3 business days
  5. Update environment variables: Replace sandbox number with your approved WABA number
  6. Update webhook URLs: Configure production webhook URLs in Vonage Application settings
  7. Test thoroughly: Verify message sending/receiving before going live

Getting Started with WhatsApp Guide

Frequently Asked Questions

How to send WhatsApp messages with Node.js and Express?

Use the Vonage Messages API and Node SDK. After setting up a Vonage application and linking a WhatsApp sender, your Express app can send messages programmatically via API calls, handling responses and status updates through webhooks. The Node SDK simplifies these interactions, ensuring smooth message delivery.

What is the Vonage Messages API?

It's a unified API from Vonage that enables sending and receiving messages over multiple channels including SMS, WhatsApp, and MMS. This simplifies messaging integration by providing a single interface to handle different message types, statuses, and delivery mechanisms.

Why does Vonage use webhooks for inbound messages?

Vonage uses webhooks to asynchronously deliver real-time notifications about incoming messages to your Node.js application. When a user sends a message to your Vonage number, the platform sends an HTTP POST request to your configured webhook endpoint, allowing immediate processing and response.

When should I use ngrok with Vonage?

ngrok is essential during local development. Because Vonage webhooks require a publicly accessible URL, ngrok creates a tunnel that exposes your local Express server, enabling you to test webhook functionality before deploying your application live.

Can I store message history with this setup?

Yes, the guide demonstrates integration with PostgreSQL through the Prisma ORM. Inbound and outbound messages, along with delivery status updates, are saved in the database, ensuring complete message tracking and enabling historical analysis.

How to set up a Node.js Express server for Vonage?

Initialize a Node.js project, install Express, the Vonage SDK, Prisma, and other necessary modules. Configure your server file to handle API routes, webhook endpoints, and error handling, while leveraging environment variables for secure credential management.

What is the Vonage Application ID used for?

The Vonage Application ID is a unique identifier for your Vonage application. It's essential for authenticating your application's interactions with the Vonage APIs and linking your application to virtual numbers and webhook configurations.

How to handle inbound WhatsApp messages in Express?

Set up a specific webhook endpoint (`/webhooks/inbound`) in your Express app. When a user sends a WhatsApp message to your Vonage number, Vonage forwards it as an HTTP POST request to this endpoint, allowing you to process the message content and respond appropriately.

What is Prisma used for in Vonage integration?

Prisma is an ORM (Object-Relational Mapper) used to interact with the PostgreSQL database efficiently in the Node.js application. It simplifies database operations, such as creating, reading, updating, and deleting message records.

How to verify Vonage webhook signatures in Express?

Vonage provides a signature with each webhook request for security. Your app should verify this signature to ensure the request originates from Vonage and hasn't been tampered with, adding an extra layer of protection against malicious actors.

Why should I use dotenv in my Node.js project?

dotenv allows you to manage environment variables easily. It loads values from a `.env` file, which is excluded from version control, ensuring that sensitive data like API keys and database credentials are kept secure.

How to handle errors when sending Vonage messages?

Implement robust error handling within your Node.js application, catching errors during API interactions and in webhook processing. Log errors with sufficient detail, notify administrators, and provide appropriate responses to the client or Vonage.

When should I use the Vonage Messages API Sandbox?

The Vonage Messages API Sandbox is extremely valuable for testing WhatsApp integration during development. It allows you to send and receive WhatsApp messages through a shared number without having a dedicated business number, simplifying initial setup and experimentation.

What is the purpose of a Vonage virtual number?

A Vonage virtual number acts as your sender ID for SMS messages and a connection point for incoming messages. You'll need an SMS-capable virtual number linked to your Vonage application to send and receive SMS messages programmatically.

How to install the Vonage Node SDK in my Express project?

Use npm (or yarn) to install the Vonage Node SDK: `npm install @vonage/server-sdk`. This provides convenient methods and functions to simplify interactions with Vonage APIs, including sending messages and managing applications.