messaging channels

Sent logo
Sent TeamMar 8, 2026 / messaging channels / Vonage

How to Send WhatsApp Messages with Node.js and Fastify | Vonage API Tutorial

Learn how to build WhatsApp messaging into your Node.js app using Vonage Messages API and Fastify. Step-by-step guide with webhook authentication, message sending/receiving, and production-ready code examples.

Note: This article covers Vonage Messages API integration. The filename references Twilio, but the content is specifically for Vonage's WhatsApp Business API.

Learn how to send and receive WhatsApp messages programmatically using the Vonage Messages API with Node.js v22 and Fastify v5. This step-by-step tutorial shows you how to build a production-ready WhatsApp integration, from initial setup to secure webhook handling with JWT authentication, complete with working code examples you can deploy today.

This integration enables real-world use cases like customer support automation, appointment reminders, order notifications, two-factor authentication (2FA), and interactive chatbots—all through WhatsApp Business API.

By the end of this tutorial, you'll have a functional Fastify application capable of:

  1. Sending WhatsApp messages via the Vonage Messages API
  2. Receiving incoming WhatsApp messages through secure webhooks
  3. Validating webhook signatures using JWT Bearer tokens to ensure requests originate from Vonage
  4. Implementing production-grade logging and error handling

This guide assumes you have foundational knowledge of Node.js, asynchronous programming (async/await), and REST APIs.

What You'll Build: WhatsApp Messaging API Integration

Problem: Businesses need reliable ways to communicate with customers on preferred channels like WhatsApp. Building this integration from scratch involves handling API authentication, message sending protocols, receiving incoming messages (webhooks), and securing these endpoints.

Solution: Build a Node.js backend using the Fastify framework to interact with the Vonage Messages API. This provides a structured way to send messages and expose secure HTTP endpoints (webhooks) for receiving messages and delivery status updates from WhatsApp via Vonage.

Technologies:

  • Node.js: JavaScript runtime environment for server-side development
  • Fastify: High-performance, low-overhead web framework for Node.js, known for its speed and developer experience
  • Vonage Messages API: Unified API for sending and receiving messages across various channels, including WhatsApp Business API, SMS, MMS, and Facebook Messenger
  • Vonage Node SDK (@vonage/server-sdk): Simplifies interaction with Vonage APIs in Node.js applications
  • dotenv: Manages environment variables securely
  • ngrok (for development): Exposes local development servers to the internet for webhook testing
  • jsonwebtoken: Validates JWT tokens for webhook authentication

For additional messaging options, see our guides on SMS integration with E.164 formatting and 10DLC registration for US phone numbers.

System Architecture:

mermaid
graph LR
    User[WhatsApp User] -- Sends/Receives Message --> WhatsApp
    WhatsApp -- Message Events --> Vonage[Vonage Platform]
    Vonage -- Sends Message via API --> API[Fastify App API Endpoint /send]
    Vonage -- Inbound/Status Webhook --> Webhook[Fastify App Webhook Endpoint /webhooks/*]
    API -- Uses Vonage SDK --> Vonage
    Webhook -- Processes Request --> AppLogic[Application Logic]
    AppLogic -- Sends Response via SDK --> Vonage
    Developer[Developer/App] -- Calls /send --> API

    subgraph Your Server
        API
        Webhook
        AppLogic
    end

    style Vonage fill:#00a5bd,stroke:#333,stroke-width:2px,color:#fff
    style API fill:#f9f,stroke:#333,stroke-width:2px
    style Webhook fill:#f9f,stroke:#333,stroke-width:2px
    style AppLogic fill:#ccf,stroke:#333,stroke-width:2px

(Note: Ensure your publishing platform supports Mermaid diagram rendering.)

Prerequisites:

  • Node.js: LTS version v22.x recommended (latest LTS as of 2025), minimum v20.x required for Fastify v5 compatibility
  • npm or yarn package manager
  • A Vonage account (Sign up for free at vonage.com)
  • A publicly accessible URL for webhook testing (use ngrok for local development)
  • Basic familiarity with terminal/command line usage
  • A WhatsApp-enabled mobile number for testing

Version Compatibility (2025):

  • Vonage Node SDK: v3.24.1 or later (@vonage/server-sdk)
  • Fastify: v5.x (requires Node.js v20+)
  • Node.js: v22.x LTS recommended, v20.x minimum
  • jsonwebtoken: Latest version for JWT validation

Final Outcome: A Fastify application with endpoints to send WhatsApp messages and receive/validate incoming message webhooks from Vonage with production-ready security.

Step 1: Set Up Your Node.js Development Environment

Ensure Node.js and npm/yarn are installed. Verify by opening your terminal and running:

bash
node -v
npm -v
# or
# yarn -v

If not installed, download and install Node.js from nodejs.org. For additional setup guidance, check our Node.js SMS tutorial with Express which covers similar environment configuration.

Step 2: Create Your Fastify Project Structure

Create your project directory and initialize it with npm (or yarn).

  1. Create Project Directory:

    bash
    mkdir fastify-vonage-whatsapp
    cd fastify-vonage-whatsapp
  2. Initialize Node.js Project:

    bash
    npm init -y
    # or
    # yarn init -y

    This creates a package.json file.

  3. Install Dependencies: Install production dependencies first:

    bash
    npm install fastify @vonage/server-sdk dotenv jsonwebtoken
    # or
    # yarn add fastify @vonage/server-sdk dotenv jsonwebtoken

    Then, install development dependencies like pino-pretty:

    bash
    npm install -D pino-pretty
    # or
    # yarn add -D pino-pretty
    • fastify: The web framework
    • @vonage/server-sdk: The official Vonage Node SDK for WhatsApp messaging
    • dotenv: Loads environment variables from a .env file
    • jsonwebtoken: Validates JWT tokens for webhook authentication
    • pino-pretty: (Dev Dependency) Makes Fastify's logs more readable during development
  4. Set up Basic Project Structure: Create the following files and directories:

    fastify-vonage-whatsapp/ ├── node_modules/ ├── .env ├── .env.example ├── .gitignore ├── package.json ├── src/ │ ├── app.js │ ├── server.js │ └── routes/ │ └── vonage.js └── yarn.lock or package-lock.json
  5. Configure .gitignore: Create a .gitignore file to prevent committing sensitive information and unnecessary files:

    text
    # Environment variables
    .env
    
    # Node dependencies
    node_modules/
    
    # Build artifacts
    dist/
    build/
    
    # Log files
    *.log
    npm-debug.log*
    yarn-debug.log*
    yarn-error.log*
    
    # OS generated files
    .DS_Store
    Thumbs.db

Step 3: Configure Your Vonage WhatsApp Account

Configure your Vonage account and the WhatsApp Sandbox before writing code.

  1. Sign Up/Log In: Go to the Vonage Dashboard and create an account or log in.
  2. API Credentials: Navigate to the API settings section (usually under your profile name or "API Settings"). Note down your API Key and API Secret. You'll need these shortly.
  3. Set up WhatsApp Sandbox:
    • In the Vonage Dashboard sidebar, find "Messages and Dispatch" > "Sandbox"
    • Activate the WhatsApp Sandbox by scanning the QR code with your WhatsApp app or sending the specified message to the provided Vonage sandbox number
    • Keep this Sandbox page open; you'll need the Vonage sandbox number and to configure webhooks later. The Sandbox allows testing without needing a dedicated WhatsApp Business number initially.
  4. Generate Signature Secret: In the Vonage Dashboard, navigate to the webhook settings for your Messages API Sandbox. Generate or note the Signature Secret used for JWT webhook validation. This is a shared secret between you and Vonage used to verify webhook authenticity.

Step 4: Set Up API Credentials and Environment Variables

Use environment variables to store sensitive credentials and configuration details.

  1. Create .env.example: This file serves as a template for required variables.

    dotenv
    # .env.example
    
    # Vonage Credentials
    VONAGE_API_KEY=YOUR_VONAGE_API_KEY
    VONAGE_API_SECRET=YOUR_VONAGE_API_SECRET
    VONAGE_APPLICATION_ID=OPTIONAL_VONAGE_APPLICATION_ID # Needed for JWT-based authentication
    VONAGE_PRIVATE_KEY_PATH=OPTIONAL_PATH_TO_PRIVATE_KEY # Needed for JWT-based authentication
    VONAGE_SIGNATURE_SECRET=YOUR_VONAGE_WEBHOOK_SIGNATURE_SECRET # Get from Vonage Dashboard for JWT webhook validation
    
    # Vonage Numbers
    VONAGE_WHATSAPP_NUMBER=VONAGE_SANDBOX_NUMBER # Get this from the Vonage Sandbox page
    
    # Application Settings
    PORT=3000
    HOST=0.0.0.0
    LOG_LEVEL=info
    
    # Webhook Base URL (for ngrok or deployed env)
    WEBHOOK_BASE_URL=YOUR_NGROK_OR_DEPLOYED_URL
  2. Create .env: Duplicate .env.example, rename it to .env, and fill in your actual values.

    • VONAGE_API_KEY, VONAGE_API_SECRET: From your Vonage dashboard
    • VONAGE_WHATSAPP_NUMBER: The phone number provided on the Vonage WhatsApp Sandbox page
    • VONAGE_SIGNATURE_SECRET: Obtain this from your Vonage Dashboard under the webhook settings for Messages API. This is the shared secret used to validate JWT tokens in webhook requests.
    • PORT, HOST, LOG_LEVEL: Default application settings
    • WEBHOOK_BASE_URL: Leave this blank for now; fill it when running ngrok
    • VONAGE_APPLICATION_ID, VONAGE_PRIVATE_KEY_PATH: Not required for the basic authentication method (API Key/Secret) used in this guide but needed for advanced JWT-based authentication features
  3. Load Environment Variables: Modify your package.json scripts to use dotenv (for loading .env files) and pino-pretty (for readable logs) during development:

    json
    // package.json
    "scripts": {
      "start": "node src/server.js",
      "dev": "node -r dotenv/config src/server.js | pino-pretty"
    },
    • The start script runs the server directly (suitable for production where env vars are set externally)
    • The dev script uses -r dotenv/config to preload environment variables from your .env file before the application starts. It then pipes the JSON output logs through pino-pretty for better readability in the terminal

5. Installing Vonage SDK (Already Done)

We installed @vonage/server-sdk and jsonwebtoken in Step 2. These packages provide convenient methods for interacting with the Vonage API and validating webhook JWTs.

Step 6: How to Send WhatsApp Messages Programmatically

Create the Fastify application structure and add a route to send messages.

  1. Set up Fastify App (src/app.js):

    javascript
    // src/app.js
    'use strict';
    
    const Fastify = require('fastify');
    const vonageRoutes = require('./routes/vonage');
    const { Vonage } = require('@vonage/server-sdk');
    
    function build(opts = {}) {
      const app = Fastify(opts);
    
      // Initialize Vonage Client
      // Note: Ensure VONAGE_API_KEY and VONAGE_API_SECRET are set in .env
      const vonage = new Vonage({
        apiKey: process.env.VONAGE_API_KEY,
        apiSecret: process.env.VONAGE_API_SECRET,
        // Note: applicationId and privateKey are not needed for Key/Secret auth
      });
    
      // Make Vonage client accessible in routes
      app.decorate('vonage', vonage);
    
      // Register routes
      app.register(vonageRoutes, { prefix: '/api/vonage' });
    
      // Basic health check route
      app.get('/health', async (request, reply) => {
        return { status: 'ok', timestamp: new Date().toISOString() };
      });
    
      return app;
    }
    
    module.exports = build;
    • Initialize Fastify
    • Create a Vonage client instance using API Key and Secret from environment variables
    • Use app.decorate to make the vonage client easily available within route handlers (request.server.vonage)
    • Register specific Vonage routes under the /api/vonage prefix
    • Include a simple /health check endpoint
  2. Create Server Entry Point (src/server.js):

    javascript
    // src/server.js
    'use strict';
    
    // Read .env file if not preloaded (e.g., via `node -r dotenv/config`)
    // This ensures it works if started with just `node src/server.js` in dev
    if (process.env.NODE_ENV !== 'production' && !process.env.DOTENV_CONFIG_PATH && require('fs').existsSync('.env')) {
      console.log('Loading .env file for non-production environment.');
      require('dotenv').config();
    }
    
    const server = require('./app')({
      logger: {
        level: process.env.LOG_LEVEL || 'info',
        // pino-pretty is handled by the `npm run dev` script pipe, not here
      },
    });
    
    const start = async () => {
      try {
        const port = parseInt(process.env.PORT || '3000', 10);
        const host = process.env.HOST || '0.0.0.0';
        await server.listen({ port: port, host: host });
        // Note: Fastify logs the listening address automatically with default logger settings.
        server.log.info(`Access health check at http://localhost:${port}/health`);
        server.log.info(`API prefix: /api/vonage`);
    
      } catch (err) {
        server.log.error(err);
        process.exit(1);
      }
    };
    
    start();
    • Configure the Fastify logger based on the environment variable
    • Start the server, listening on the configured host and port
    • Include a check to load .env if not preloaded via the script (useful for some debugging scenarios)
  3. Create Send Message Route (src/routes/vonage.js):

    javascript
    // src/routes/vonage.js
    'use strict';
    
    const jwt = require('jsonwebtoken');
    
    const sendMessageSchema = {
      body: {
        type: 'object',
        required: ['to', 'text'],
        properties: {
          to: { type: 'string', description: 'Recipient WhatsApp number (E.164 format)', pattern: '^\\+?[1-9]\\d{1,14}' }, // Basic E.164 pattern
          text: { type: 'string', description: 'Message content', minLength: 1 },
        },
      },
      response: {
        200: {
          type: 'object',
          properties: {
            message_uuid: { type: 'string' },
            detail: { type: 'string' }
          }
        },
        // Define common error responses for better OpenAPI/Swagger documentation
        400: { $ref: 'http://example.com/schemas/error#/definitions/badRequest' }, // Placeholder
        500: { $ref: 'http://example.com/schemas/error#/definitions/serverError' }  // Placeholder
      }
    };
    
    // --- Helper function for JWT Signature Validation ---
    // Vonage Messages API uses JWT Bearer tokens in the Authorization header
    function validateVonageJWT(request, secret, log) {
      try {
          // Extract the JWT from the Authorization header
          const authHeader = request.headers['authorization'];
    
          if (!authHeader) {
              log.warn('Missing Authorization header in webhook request.');
              return false;
          }
    
          // Authorization header format: "Bearer <jwt_token>"
          const parts = authHeader.split(' ');
          if (parts.length !== 2 || parts[0] !== 'Bearer') {
              log.warn('Invalid Authorization header format. Expected "Bearer <token>".');
              return false;
          }
    
          const token = parts[1];
    
          // Verify and decode the JWT using the signature secret
          const decoded = jwt.verify(token, secret, {
              algorithms: ['HS256'] // Vonage uses HMAC-SHA256
          });
    
          log.info('JWT signature validated successfully.');
    
          // Optional: Verify payload_hash to ensure payload hasn't been tampered with
          if (decoded.payload_hash && request.body) {
              const crypto = require('crypto');
              const bodyString = JSON.stringify(request.body);
              const calculatedHash = crypto.createHash('sha256').update(bodyString).digest('hex');
    
              if (decoded.payload_hash !== calculatedHash) {
                  log.warn('Payload hash mismatch. Possible tampering detected.');
                  return false;
              }
              log.info('Payload hash verified successfully.');
          }
    
          // Optional: Check token age using 'iat' (issued at) claim
          if (decoded.iat) {
              const tokenAge = Date.now() / 1000 - decoded.iat;
              const MAX_TOKEN_AGE = 300; // 5 minutes
              if (tokenAge > MAX_TOKEN_AGE) {
                  log.warn(`Token is stale (age: ${tokenAge}s). Possible replay attack.`);
                  return false;
              }
          }
    
          return true; // JWT is valid
    
      } catch (error) {
          if (error.name === 'JsonWebTokenError') {
              log.warn('Invalid JWT signature.');
          } else if (error.name === 'TokenExpiredError') {
              log.warn('JWT token has expired.');
          } else {
              log.error({ msg: 'Error during JWT validation', error: error.message, stack: error.stack });
          }
          return false;
      }
    } // End of validateVonageJWT
    
    async function vonageRoutes(fastify, options) {
      const vonage = fastify.vonage; // Access decorated Vonage client
      const signatureSecret = process.env.VONAGE_SIGNATURE_SECRET;
    
      // --- Send WhatsApp Message Route ---
      fastify.post('/send-whatsapp', { schema: sendMessageSchema }, async (request, reply) => {
        const { to, text } = request.body;
        const from = process.env.VONAGE_WHATSAPP_NUMBER;
    
        if (!from) {
          fastify.log.error('VONAGE_WHATSAPP_NUMBER environment variable not set.');
          return reply.status(500).send({ error: 'Server configuration error: Missing sender number.' });
        }
    
        fastify.log.info(`Attempting to send WhatsApp message from ${from} to ${to}`);
    
        try {
          const resp = await vonage.messages.send({
            message_type: "text",
            text: text,
            to: to,
            from: from,
            channel: "whatsapp"
          });
    
          fastify.log.info(`Message sent successfully. UUID: ${resp.messageUuid}`);
          return reply.send({
            message_uuid: resp.messageUuid,
            detail: `Message sent to ${to}`
          });
    
        } catch (error) {
          fastify.log.error({ msg: 'Error sending WhatsApp message', error: error?.response?.data || error.message, statusCode: error?.response?.status });
    
          // Provide more specific error feedback if possible
          let statusCode = 500;
          let errorMessage = 'Failed to send message due to an internal server error.';
    
          if (error.response?.data) {
             // Use Vonage's error details if available
             errorMessage = error.response.data.title || error.response.data.detail || errorMessage;
             if (error.response.status >= 400 && error.response.status < 500) {
                 statusCode = error.response.status;
             }
          } else if (error.message && error.message.toLowerCase().includes('number')) { // Basic check for common number format issues
              statusCode = 400;
              errorMessage = 'Invalid recipient number format. Ensure it uses E.164 format (e.g., +14155552671).';
          }
    
          // Return a structured error
          return reply.status(statusCode).send({
              error: {
                  message: errorMessage,
                  type: error.response?.data?.type, // Include Vonage error type if available
                  details: error.response?.data?.invalid_parameters // Include specific invalid params if available
              }
          });
        }
      });
    
      // --- Inbound Message Webhook ---
      fastify.post('/webhooks/inbound', {
          // Optional: Add schema validation for expected inbound webhook structure if needed
      }, async (request, reply) => {
        fastify.log.info('Received inbound webhook');
    
        // 1. JWT Signature Validation (CRITICAL for Security)
        if (signatureSecret) {
          if (!validateVonageJWT(request, signatureSecret, fastify.log)) {
             fastify.log.warn('Invalid webhook JWT signature received.');
             return reply.status(401).send({ error: 'Invalid signature' });
          }
        } else {
          // WARNING: Only skip validation in very specific, controlled non-production scenarios.
          fastify.log.warn('Webhook signature validation skipped (VONAGE_SIGNATURE_SECRET not set). THIS IS INSECURE.');
        }
    
        // 2. Process the message
        const inboundData = request.body;
        fastify.log.info({ msg: 'Inbound message data:', data: inboundData });
    
        // Example: Log sender and message text
        if (inboundData?.from?.type === 'whatsapp' && inboundData?.message?.content?.text) {
            fastify.log.info(`Message from ${inboundData.from.number}: ${inboundData.message.content.text}`);
            // TODO: Add your business logic here (e.g., reply, store in DB, queue for processing)
        } else {
            fastify.log.warn('Received inbound webhook with unexpected structure or non-text message.');
        }
    
        // 3. Acknowledge receipt immediately
        // Vonage requires a 200 OK response quickly. Defer long processing.
        reply.status(200).send('OK');
      });
    
      // --- Message Status Webhook ---
      fastify.post('/webhooks/status', {
          // Optional: Add schema validation for expected status webhook structure if needed
      }, async (request, reply) => {
        fastify.log.info('Received status webhook');
    
        // 1. JWT Signature Validation (CRITICAL for Security)
         if (signatureSecret) {
          if (!validateVonageJWT(request, signatureSecret, fastify.log)) {
             fastify.log.warn('Invalid webhook JWT signature received.');
             return reply.status(401).send({ error: 'Invalid signature' });
          }
        } else {
          fastify.log.warn('Webhook signature validation skipped (VONAGE_SIGNATURE_SECRET not set). THIS IS INSECURE.');
        }
    
        // 2. Process the status update
        const statusData = request.body;
        fastify.log.info({ msg: 'Message status update:', data: statusData });
    
        // Example: Log message UUID and status
        if (statusData?.message_uuid && statusData?.status) {
          fastify.log.info(`Status for message ${statusData.message_uuid}: ${statusData.status} (Timestamp: ${statusData.timestamp})`);
           // TODO: Update message status in your database if needed
        } else {
            fastify.log.warn('Received status webhook with unexpected structure.');
        }
    
        // 3. Acknowledge receipt immediately
        reply.status(200).send('OK');
      });
    
    } // End of vonageRoutes plugin function
    
    module.exports = vonageRoutes;
    • Define a sendMessageSchema for request body validation using Fastify's built-in capabilities, including a basic E.164 pattern check for the to number
    • The route handler /api/vonage/send-whatsapp takes the to number and text from the request body
    • Retrieve the VONAGE_WHATSAPP_NUMBER from environment variables to use as the from number
    • Call vonage.messages.send() with the required parameters for a text message via WhatsApp
    • Enhanced logging and error handling included, attempting to extract and return specific error details from the Vonage API response
    • The validateVonageJWT helper function implements JWT validation using the jsonwebtoken library
    • Webhook routes (/webhooks/inbound, /webhooks/status) use the helper for signature validation and log incoming data, responding quickly with 200 OK
  4. Test Sending:

    • Run the development server: npm run dev (or yarn dev)
    • Open a new terminal and use curl (or a tool like Postman) to send a request. Replace YOUR_WHATSAPP_NUMBER with your actual number linked to the Vonage Sandbox (in E.164 format, e.g., +14155552671)
    bash
    curl -X POST http://localhost:3000/api/vonage/send-whatsapp \
      -H "Content-Type: application/json" \
      -d '{
            "to": "YOUR_WHATSAPP_NUMBER",
            "text": "Hello from Fastify and Vonage!"
          }'
    • You should receive the WhatsApp message on your phone and see a success response in the terminal (like {"message_uuid":"...","detail":"Message sent to YOUR_WHATSAPP_NUMBER"}). Check the server logs as well.

Step 7: How to Receive WhatsApp Messages via Webhooks

Vonage uses webhooks to notify your application about incoming WhatsApp messages and message status updates. The routes for this (/api/vonage/webhooks/inbound and /api/vonage/webhooks/status) are already included in src/routes/vonage.js from the previous step.

Step 8: Expose Your Local Server for Webhook Testing

Allow Vonage's servers to reach your local development machine using ngrok.

  1. Install ngrok: Follow the instructions at ngrok.com. Sign up for a free account and install an auth token.
  2. Start Your Fastify App: If it's not running, start it: npm run dev. Note the port (default is 3000).
  3. Start ngrok: Open a new terminal window and run:
    bash
    ngrok http 3000
    (Replace 3000 if your app uses a different port).
  4. Copy the ngrok URL: ngrok will display forwarding URLs. Copy the https URL (e.g., https://random-string.ngrok-free.app).
  5. Update .env: Set the WEBHOOK_BASE_URL in your .env file to this ngrok URL.
    dotenv
    # .env
    # ... other vars
    WEBHOOK_BASE_URL=https://<your-random-string>.ngrok-free.app # Use the actual URL from ngrok output
  6. Restart Fastify App: Stop (Ctrl+C) and restart your Fastify app (npm run dev) to pick up the WEBHOOK_BASE_URL if your code uses it (though it's primarily for configuring Vonage).
  7. Configure Vonage Webhooks:
    • Go back to your Vonage Dashboard's Sandbox page
    • Find the "Webhooks" section
    • Enter your Inbound URL: YOUR_NGROK_HTTPS_URL/api/vonage/webhooks/inbound (e.g., https://<your-random-string>.ngrok-free.app/api/vonage/webhooks/inbound)
    • Enter your Status URL: YOUR_NGROK_HTTPS_URL/api/vonage/webhooks/status (e.g., https://<your-random-string>.ngrok-free.app/api/vonage/webhooks/status)
    • Important: Ensure the signature secret in your Vonage Dashboard matches the VONAGE_SIGNATURE_SECRET in your .env file exactly
    • Click "Save webhooks"

Step 9: Test Your WhatsApp Message Receiving

Test receiving messages now.

  1. Send a WhatsApp Message: From the phone number linked to your Vonage Sandbox, send a message to the Vonage Sandbox number.
  2. Check Logs: Observe the terminal running your Fastify application (npm run dev). You should see logs indicating:
    • Received inbound webhook
    • JWT signature validated successfully. (if validation passes)
    • Inbound message data: { ... } (showing the message payload)
    • Message from <your_number>: <your_message_text>
  3. Check ngrok Console: The terminal running ngrok (ngrok http 3000) will also show incoming POST requests to your webhook URLs (e.g., POST /api/vonage/webhooks/inbound 200 OK).

If you see errors, especially related to signature validation (401 Unauthorized response in ngrok, validation failure logs in Fastify):

  • Verify the VONAGE_SIGNATURE_SECRET in your .env exactly matches the secret configured in the Vonage Dashboard. Even a single character difference or whitespace will cause failure.
  • Confirm the webhook URLs in the Vonage Dashboard are correct (using https and the full path /api/vonage/webhooks/...).
  • Ensure your Fastify app and ngrok are running and connected.
  • Check that the jsonwebtoken package is installed.
  • Review Fastify logs for specific JWT validation error messages.

Step 10: Secure Your WhatsApp Webhooks with JWT Authentication

Validating webhook signatures is critical to ensure requests genuinely come from Vonage and haven't been tampered with or forged.

Vonage Messages API Webhook Security:

Vonage uses JSON Web Token (JWT) Bearer Authorization for webhooks sent from the Messages API. This provides robust authentication different from HMAC signature validation.

How JWT Webhook Authentication Works:

  1. Authorization Header: Vonage includes a JWT in the Authorization header of each webhook request in the format: Authorization: Bearer <jwt_token>

  2. JWT Contents: The JWT is signed using HMAC-SHA256 with your signature secret and contains:

    • Standard JWT claims (iat – issued at timestamp)
    • payload_hash – SHA-256 hash of the webhook payload for tamper detection
    • Other Vonage-specific claims
  3. Validation Process:

    • Extract the JWT from the Authorization header
    • Verify the signature using your VONAGE_SIGNATURE_SECRET
    • Optionally verify the payload_hash matches the actual payload
    • Optionally check the iat timestamp to reject stale tokens

Implementation:

The validateVonageJWT function in src/routes/vonage.js implements this validation using the jsonwebtoken library. Ensure you have installed it:

bash
npm install jsonwebtoken
# or
yarn add jsonwebtoken

Security Best Practices for WhatsApp Webhooks:

  • Always validate signatures in production – Never skip JWT validation
  • Keep your signature secret secure – Store it in environment variables, never commit to version control
  • Use the same secret in both places – The secret in your .env must exactly match the one in your Vonage Dashboard
  • Implement payload hash verification – Adds an extra layer of security against tampering
  • Check token age – Reject tokens older than a few minutes to prevent replay attacks
  • Log validation failures – Monitor for potential security issues

Troubleshooting Webhook Authentication:

If webhook validation fails:

  • Verify VONAGE_SIGNATURE_SECRET matches exactly in both .env and Vonage Dashboard
  • Check that webhook URLs in Vonage Dashboard use https and the correct paths
  • Ensure the jsonwebtoken package is installed
  • Review Fastify logs for specific JWT validation error messages
  • Check for whitespace or encoding issues in the secret