code examples

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

How to Build Vonage WhatsApp Integration with RedwoodJS & Node.js (Complete 2025 Guide)

Step-by-step guide to integrate Vonage WhatsApp Business API in RedwoodJS apps. Includes webhook setup, JWT authentication, message logging with Prisma, automated replies, and production deployment strategies.

Build Vonage WhatsApp Integration with RedwoodJS & Node.js

Integrate Vonage's WhatsApp Business API to send and receive WhatsApp messages within your RedwoodJS application. This comprehensive tutorial shows you how to build a production-ready solution with secure webhook handling, JWT authentication, Prisma database logging, and automated message replies using RedwoodJS Functions and Services.

By the end of this guide, you'll have a fully functional RedwoodJS WhatsApp integration capable of two-way communication, complete with security best practices, error handling, deployment guidance, and troubleshooting strategies for real-world applications.

Project Overview and Goals

What You'll Build:

Create a RedwoodJS application featuring:

  1. An API endpoint (Redwood Function) to receive inbound WhatsApp messages from Vonage via webhooks, secured with signature verification
  2. An API endpoint (Redwood Function) to receive message status updates from Vonage via webhooks, secured with signature verification
  3. A Redwood Service to encapsulate the logic for interacting with the Vonage Messages API
  4. Functionality to automatically reply "Message received" to incoming WhatsApp messages
  5. Secure handling of API credentials using environment variables
  6. Basic database logging of messages using Prisma

Use Cases:

  • Customer support chatbots with automated responses
  • Appointment reminders and booking confirmations
  • Order status notifications for e-commerce
  • Two-way communication for field service management
  • Marketing campaigns with opt-in/opt-out handling

Development Time: Approximately 2-3 hours for initial setup and basic implementation. Additional time required for custom business logic and production hardening.

Skill Level: Intermediate. Requires familiarity with JavaScript/TypeScript, REST APIs, and basic understanding of webhooks and asynchronous messaging patterns.

Problem Solved: This guide enables you to leverage WhatsApp's ubiquity (over 2 billion users worldwide as of 2024) for customer communication, notifications, or bot interactions directly within your RedwoodJS applications, using Vonage as the communication provider. WhatsApp offers higher engagement rates (98% open rate) compared to email (20-25%), making it ideal for time-sensitive communications.

Technologies Used:

  • RedwoodJS: A full-stack JavaScript/TypeScript framework for building modern web applications. It provides structure, conventions (like Services and Functions), and tools (CLI, Prisma integration) that simplify development.
  • Node.js: The runtime environment for the RedwoodJS API side. Requires Node.js v18.x or v20.x (LTS versions recommended).
  • Vonage Messages API: Enables sending and receiving messages across various channels, including WhatsApp. You'll use the Node SDK.
  • Vonage WhatsApp Sandbox: A testing environment provided by Vonage to develop WhatsApp integrations without needing a dedicated WhatsApp Business Account initially.
  • Ngrok: A tool to expose local development servers to the internet, necessary for Vonage webhooks to reach your local machine during development.
  • Prisma: The default ORM for RedwoodJS, used for database interactions.

Version Compatibility Matrix:

ComponentMinimum VersionRecommended VersionNotes
Node.js18.x20.x LTSRequired for RedwoodJS v6+
RedwoodJS6.0.0Latest stableUse yarn rw upgrade to update
@vonage/server-sdk3.13.03.25.1+Released January 2025
@vonage/messages2.0.0LatestPart of server-sdk
@vonage/jwt1.10.01.12.1+For webhook signature verification
Prisma5.0.0LatestBundled with RedwoodJS

System Architecture:

text
+-----------------+      +------------+      +----------------+      +----------------------+      +-------------------+      +----------------+      +-----------------+
| End User        |----->| WhatsApp   |----->| Vonage         |----->| Ngrok/Public URL     |----->| RedwoodJS Function|----->| RedwoodJS Service|----->| Vonage          |
| (WhatsApp App)  |<-----| Platform   |<-----| Messages API   |<-----| (API Side - Port 8911)|----->| (Vonage Logic)   |<-----| Messages API    |      | (API & SDK)     |
+-----------------+      +------------+      +----------------+      +----------------------+      +-------------------+      +----------------+      +-----------------+
        ^                                        (Webhooks)                                                |                                  |
        |                                                                                                  | Logs Inbound Message             | Logs Outbound Message
        |                                                                                                  v                                  v
        +---------------------------------------------------------------------------------------------+ Prisma (Database) |<-----------------+
                                                                                                      +-------------------+

Prerequisites:

  • Node.js: Version 18 or higher installed. Download from nodejs.org
  • Yarn: Package manager for RedwoodJS. Install via npm: npm install -g yarn
  • RedwoodJS CLI: Installed globally: npm install -g @redwoodjs/cli or yarn global add @redwoodjs/cli
  • Vonage API Account: Sign up for free credit. You'll need your API Key and Secret.
  • Ngrok: Download and install, then authenticate with your account token.
  • WhatsApp Account: A personal WhatsApp account on a smartphone for testing with the sandbox.
  • Basic familiarity with Git for version control.
  • OS: Compatible with macOS, Windows (using WSL recommended), and Linux (Node.js and RedwoodJS are generally cross-platform).

Verification Steps:

bash
# Verify Node.js version (should be 18.x or higher)
node --version

# Verify Yarn is installed
yarn --version

# Verify RedwoodJS CLI
yarn rw --version

# Verify Ngrok is installed and authenticated
ngrok version
ngrok config check

1. Setting up Your RedwoodJS WhatsApp Project

Create a new RedwoodJS project and configure it for Vonage WhatsApp integration.

  1. Create RedwoodJS App: Open your terminal and run:

    bash
    yarn create redwood-app ./redwood-vonage-whatsapp
    cd redwood-vonage-whatsapp

    Choose TypeScript when prompted for the best development experience. This command scaffolds a complete RedwoodJS project with api and web directories, configures Prisma, and sets up the development environment.

  2. Install Vonage SDK Dependencies: Install the Vonage SDKs. Navigate to the api workspace:

    bash
    cd api
    yarn add @vonage/server-sdk@^3.25.0 @vonage/messages@^2.0.0 @vonage/jwt@^1.12.0
    cd ..
    • @vonage/server-sdk: Core SDK for authentication, general API access, and initialization of Vonage services
    • @vonage/messages: Specific SDK for the Messages API with WhatsApp, Viber, and Facebook Messenger support
    • @vonage/jwt: Verifies webhook signatures using JWT tokens to ensure requests originate from Vonage
  3. Define Prisma Schema for Message Logging: Before configuring environment variables, define the database model for message logging. Open api/db/schema.prisma and add the MessageLog model:

    prisma
    // api/db/schema.prisma
    
    datasource db {
      provider = "sqlite" // Use "postgresql" for production
      url      = env("DATABASE_URL")
    }
    
    generator client {
      provider = "prisma-client-js"
    }
    
    model MessageLog {
      id               Int      @id @default(autoincrement())
      direction        String   // "INBOUND" or "OUTBOUND"
      vonageMessageId  String?  // UUID from Vonage, nullable for failed sends
      fromNumber       String   // E.164 format phone number
      toNumber         String   // E.164 format phone number
      body             String   // Message content
      status           String   // submitted, delivered, read, failed, etc.
      statusDetails    String?  // JSON string with error details if applicable
      createdAt        DateTime @default(now())
      updatedAt        DateTime @updatedAt
    
      @@index([vonageMessageId])
      @@index([fromNumber])
      @@index([status])
    }

    Run Prisma migrations to create the database schema:

    bash
    yarn rw prisma migrate dev --name add_message_log
  4. Configure Environment Variables: RedwoodJS uses .env files for environment variables. Create a .env file in your project root:

    dotenv
    # redwood-vonage-whatsapp/.env
    
    # Vonage API Credentials
    VONAGE_API_KEY="YOUR_VONAGE_API_KEY"
    VONAGE_API_SECRET="YOUR_VONAGE_API_SECRET"
    VONAGE_APPLICATION_ID="YOUR_VONAGE_APPLICATION_ID"
    # --- Vonage Private Key ---
    # Paste the entire content of your private.key file here.
    # This is more secure and portable for deployments than using a file path.
    # Ensure this value is enclosed in double quotes as it contains newlines and special characters.
    # Example: VONAGE_PRIVATE_KEY_CONTENT="-----BEGIN PRIVATE KEY-----\nYOURKEYCONTENTHERE...\n-----END PRIVATE KEY-----"
    VONAGE_PRIVATE_KEY_CONTENT="PASTE_YOUR_PRIVATE_KEY_CONTENT_HERE"
    
    # --- Vonage Signature Secret ---
    # Used to verify incoming webhooks. Find in Vonage Dashboard → Settings.
    VONAGE_API_SIGNATURE_SECRET="YOUR_VONAGE_SIGNATURE_SECRET"
    
    # Vonage WhatsApp Sandbox Number (Provided by Vonage)
    # This is often 14157386102 for the Vonage sandbox.
    VONAGE_WHATSAPP_NUMBER="YOUR_VONAGE_SANDBOX_NUMBER"
    
    # Redwood API Server Port (Default: 8911)
    PORT=8911
    
    # Database URL (Defaults to SQLite for dev, adjust for production)
    # Example for Render PostgreSQL: Will be set by Render environment variable group
    # DATABASE_URL="postgresql://user:password@host:port/database"
    # For local dev (SQLite default):
    DATABASE_URL="file:./dev.db"

    Environment Variable Validation:

    Test that environment variables are loaded correctly:

    bash
    # In your project root
    yarn rw console
    
    # In the console, run:
    process.env.VONAGE_API_KEY
    process.env.VONAGE_APPLICATION_ID
    # Should display your values, not undefined

    How to Obtain These Values:

    • VONAGE_API_KEY & VONAGE_API_SECRET: Found on the main page of your Vonage API Dashboard after logging in.
    • VONAGE_APPLICATION_ID & VONAGE_PRIVATE_KEY_CONTENT:
      • Go to ApplicationsCreate a new application in the Vonage Dashboard.
      • Give it a name (e.g., "Redwood WhatsApp App").
      • Enable Messages under Capabilities. Leave webhook URLs blank for now.
      • Click Generate public and private key. Immediately save the private.key file that downloads.
      • Open the downloaded private.key file with a text editor and copy its entire content.
      • Paste this content as the value for VONAGE_PRIVATE_KEY_CONTENT in your .env file. Make sure to include the -----BEGIN PRIVATE KEY----- and -----END PRIVATE KEY----- lines and enclose the whole thing in double quotes.
      • Click Generate new application.
      • The VONAGE_APPLICATION_ID will be displayed for your new application. Copy it.
    • VONAGE_API_SIGNATURE_SECRET: Go to the Vonage Dashboard → Settings. Find your API key and click Edit. Your signature secret is listed there. If none exists, generate one.
    • VONAGE_WHATSAPP_NUMBER: Go to Messages API Sandbox in the Vonage Dashboard. The sandbox number is displayed prominently (often 14157386102).

    Allowlisting Your WhatsApp Number (Required):

    To use the Messages API Sandbox, you must allowlist your personal WhatsApp number:

    1. In the Vonage Dashboard, navigate to Messages API Sandbox
    2. Click Add to sandbox in the External Accounts section
    3. Choose one of three methods:
      • Scan QR Code: Use your phone camera to open WhatsApp with a pre-filled message
      • Email Instructions: Send setup instructions to yourself or team members
      • Manual Message: Send the unique phrase shown (e.g., "join example-word") from your WhatsApp to the sandbox number 14157386102
    4. Verify success: The sandbox page should show "You have 1 user(s) whitelisted". Click Refresh if it doesn't appear immediately.

    Security Considerations:

    • .env File: Add .env to your .gitignore file to prevent committing secrets. Redwood's default .gitignore usually covers this, but double-check.
    • Private Key Content: Treat the VONAGE_PRIVATE_KEY_CONTENT value as highly sensitive. Do not commit it to version control. Use platform-specific secret management (like Render Environment Variables, GitHub Secrets, etc.) for production deployments.
    • Secret Rotation: Rotate credentials quarterly or immediately if compromised. To rotate:
      1. Generate a new private key in Vonage Dashboard (Applications → Your App → Generate new key pair)
      2. Update VONAGE_PRIVATE_KEY_CONTENT in all environments
      3. Generate new VONAGE_API_SIGNATURE_SECRET (Settings → API Key → Edit → Regenerate signature secret)
      4. Update secret across all deployments with zero downtime by deploying new version before revoking old credentials
  5. Start Ngrok for Webhook Development: Vonage needs a publicly accessible URL to send webhooks. Ngrok creates a secure tunnel to your local machine. The RedwoodJS API server runs on port 8911 by default (defined by PORT in .env).

    Ngrok Free Tier Limitations (as of 2024):

    • 1 GB data transfer per month
    • 1 concurrent endpoint
    • Up to 20,000 HTTP requests/month
    • Up to 5,000 TCP connections/month
    • Requires authentication with account token (sign up at ngrok.com)
    • Interstitial warning page on free domains (can be bypassed with custom headers)
    bash
    # Authenticate ngrok (first time only)
    ngrok config add-authtoken YOUR_NGROK_AUTH_TOKEN
    
    # Start tunnel to port 8911
    ngrok http 8911

    Ngrok displays a forwarding URL like https://<unique_id>.ngrok.io or https://<unique_id>.ngrok.app. Keep this URL handy. Keep this terminal window running.

    Ngrok Troubleshooting:

    • Connection refused: Ensure RedwoodJS dev server is running on port 8911 (yarn rw dev api)
    • Tunnel not found: Restart ngrok and update webhook URLs in Vonage Dashboard
    • Bandwidth exceeded: Upgrade to paid plan or monitor usage at ngrok dashboard
    • Interstitial page blocking webhooks: This shouldn't affect API calls from Vonage, only browser traffic
  6. Configure Vonage Webhooks: Tell Vonage where to send incoming messages and status updates.

    • Application Webhooks: Go back to your application settings in the Vonage Dashboard (Applications → Your App → Edit).

      • Under CapabilitiesMessages, enter:
        • Inbound URL: YOUR_NGROK_URL/.redwood/functions/whatsappInbound (e.g., https://xyz.ngrok.app/.redwood/functions/whatsappInbound)
        • Status URL: YOUR_NGROK_URL/.redwood/functions/whatsappStatus (e.g., https://xyz.ngrok.app/.redwood/functions/whatsappStatus)
      • Save the changes.
    • Sandbox Webhooks: Go to the Messages API Sandbox page.

      • Click the Webhooks tab.
      • Enter the same URLs as above for Inbound and Status.
      • Save the webhooks.

    Why configure both? The Sandbox Webhooks are used specifically when you interact directly with the Messages API Sandbox environment (using the sandbox number, allowlisted numbers, etc.). The Application Webhooks are tied to your specific Vonage Application (identified by the VONAGE_APPLICATION_ID). These become relevant when you move beyond the sandbox, potentially using a purchased WhatsApp number linked to that application or utilizing other application-specific features. For initial sandbox testing, configuring both ensures messages sent via the sandbox trigger your endpoints correctly.

    Why /.redwood/functions/? This is the default path RedwoodJS exposes its API functions under during development and typically in serverless deployments.

    Webhook Testing:

    After configuration, test webhooks are working:

    1. Start your RedwoodJS dev server: yarn rw dev api
    2. Send a test message from your allowlisted WhatsApp number to the sandbox number
    3. Check your terminal logs for "Inbound WhatsApp webhook received"
    4. If no webhook arrives within 10 seconds:
      • Verify ngrok is running and the URL hasn't changed
      • Check Vonage Dashboard → Messages API Sandbox → Webhooks are saved correctly
      • Ensure your number is allowlisted (check External Accounts section)

2. Implementing WhatsApp Message Handling (API Side)

Create Redwood Functions to handle the webhooks and a Service to manage Vonage interactions.

  1. Create Redwood Service for Vonage: Services encapsulate business logic in RedwoodJS, providing a clean separation of concerns and making code testable. Generate a service:

    bash
    yarn rw g service vonage

    This creates api/src/services/vonage/vonage.ts (and test files).

  2. Implement Vonage Service Logic: Open api/src/services/vonage/vonage.ts and add the following code:

    typescript
    // api/src/services/vonage/vonage.ts
    import { Vonage } from '@vonage/server-sdk'
    import { WhatsAppText } from '@vonage/messages'
    import { logger } from 'src/lib/logger'
    import { db } from 'src/lib/db' // Import Prisma client
    
    // Ensure environment variables are loaded and valid
    if (
      !process.env.VONAGE_API_KEY ||
      !process.env.VONAGE_API_SECRET ||
      !process.env.VONAGE_APPLICATION_ID ||
      !process.env.VONAGE_PRIVATE_KEY_CONTENT || // Check for key content
      !process.env.VONAGE_WHATSAPP_NUMBER
    ) {
      logger.error('Missing required Vonage environment variables.')
      throw new Error('Missing required Vonage environment variables.')
    }
    
    // Validate private key format minimally (optional but good practice)
    if (
      !process.env.VONAGE_PRIVATE_KEY_CONTENT.includes('-----BEGIN PRIVATE KEY-----') ||
      !process.env.VONAGE_PRIVATE_KEY_CONTENT.includes('-----END PRIVATE KEY-----')
    ) {
      logger.warn('VONAGE_PRIVATE_KEY_CONTENT might be malformed (missing BEGIN/END markers).')
      // Decide if this should be a fatal error depending on requirements
      // throw new Error('VONAGE_PRIVATE_KEY_CONTENT appears malformed.')
    }
    
    
    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_CONTENT, // Use key content directly
      },
      {
        // Use the sandbox URL for testing
        apiHost: 'https://messages-sandbox.nexmo.com', // NOTE: Remove this line when moving to production to use the default Vonage production API host.
        logger: logger, // Use Redwood's logger for SDK logs
      }
    )
    
    /**
     * Validates E.164 phone number format.
     * E.164 format: +[country code][national number]
     * Max 15 digits total, starting with +
     * Examples: +14155552671 (US), +442071838750 (UK), +34912345678 (Spain)
     * @param phoneNumber Phone number to validate
     * @returns true if valid E.164 format
     */
    const validateE164 = (phoneNumber: string): boolean => {
      const e164Regex = /^\+?[1-9]\d{1,14}$/
      return e164Regex.test(phoneNumber)
    }
    
    /**
     * Sends a WhatsApp text message using Vonage.
     * @param to The recipient's phone number (E.164 format: +[country code][number]).
     * @param text The message content (max 4096 characters for WhatsApp).
     */
    export const sendWhatsAppMessage = async ({
      to,
      text,
    }: {
      to: string
      text: string
    }) => {
      // Input validation
      if (!to || !text) {
        throw new Error('Both "to" and "text" parameters are required')
      }
    
      if (!validateE164(to)) {
        throw new Error(
          `Invalid phone number format. Expected E.164 format (e.g., +14155552671), got: ${to}`
        )
      }
    
      if (text.length > 4096) {
        throw new Error('Message text exceeds maximum length of 4096 characters')
      }
    
      logger.info({ to }, 'Attempting to send WhatsApp message')
      try {
        const response = await vonage.messages.send(
          new WhatsAppText({
            from: process.env.VONAGE_WHATSAPP_NUMBER, // Sender is the Sandbox number
            to: to, // Recipient's number
            text: text,
            channel: 'whatsapp', // Explicitly specify channel
            message_type: 'text', // Explicitly specify message type
          })
        )
    
        logger.info(
          { messageUUID: response.messageUUID },
          'WhatsApp message sent successfully'
        )
    
        // Log outgoing message to database
        await logMessage({
          direction: 'OUTBOUND',
          vonageMessageId: response.messageUUID,
          fromNumber: process.env.VONAGE_WHATSAPP_NUMBER,
          toNumber: to,
          body: text,
          status: 'submitted', // Initial status
        })
    
        return { success: true, messageUUID: response.messageUUID }
      } catch (error) {
        // Log detailed error information from Vonage if available
        const errorMessage = error.response?.data?.title || error.response?.data?.detail || error.message || error
        const errorDetails = error.response?.data || error
        logger.error(
          { error: errorMessage, details: errorDetails },
          'Failed to send WhatsApp message via Vonage'
        )
        // Optionally log failed attempt
        await logMessage({
          direction: 'OUTBOUND',
          vonageMessageId: null, // No ID assigned
          fromNumber: process.env.VONAGE_WHATSAPP_NUMBER,
          toNumber: to,
          body: text,
          status: 'failed',
          statusDetails: JSON.stringify({ error: errorMessage }), // Log error summary
        })
        return { success: false, error: 'Failed to send message' }
      }
    }
    
    /**
     * Handles the logic for an incoming WhatsApp message.
     * Currently sends a simple reply.
     * @param fromNumber The sender's phone number.
     * @param messageBody The content of the incoming message.
     * @param vonageMessageId The message ID from Vonage.
     */
    export const handleInboundWhatsAppMessage = async ({
      fromNumber,
      messageBody,
      vonageMessageId,
    }: {
      fromNumber: string
      messageBody: string
      vonageMessageId: string
    }) => {
      logger.info(
        { fromNumber, messageBody, vonageMessageId },
        'Handling inbound WhatsApp message'
      )
    
      // Log incoming message
      await logMessage({
        direction: 'INBOUND',
        vonageMessageId: vonageMessageId,
        fromNumber: fromNumber,
        toNumber: process.env.VONAGE_WHATSAPP_NUMBER,
        body: messageBody,
        status: 'delivered', // Assume delivered if webhook received
      })
    
      // Simple auto-reply logic
      const replyText = 'Message received.'
      await sendWhatsAppMessage({ to: fromNumber, text: replyText })
    
      return { success: true }
    }
    
    /**
     * Updates the status of a previously sent message.
     * @param vonageMessageId The UUID of the message.
     * @param status The new status (e.g., 'delivered', 'read', 'failed').
     * @param timestamp The timestamp of the status update.
     * @param error Optional error details if status is 'failed'.
     */
    export const handleMessageStatusUpdate = async ({
      vonageMessageId,
      status,
      timestamp,
      error,
    }: {
      vonageMessageId: string
      status: string
      timestamp: string
      error?: any
    }) => {
      logger.info(
        { vonageMessageId, status, timestamp, error },
        'Handling message status update'
      )
    
      try {
        await db.messageLog.updateMany({
          where: { vonageMessageId: vonageMessageId },
          data: {
            status: status,
            // Store error details if status is failed/rejected
            statusDetails: error ? JSON.stringify(error) : null,
            updatedAt: new Date(timestamp), // Use status timestamp
          },
        })
        logger.info(
          { vonageMessageId, status },
          'Message status updated in database'
        )
      } catch (dbError) {
        logger.error(
          { dbError, vonageMessageId, status },
          'Failed to update message status in database'
        )
      }
    
      return { success: true }
    }
    
    // --- Helper function to log messages ---
    const logMessage = async (data: {
      direction: 'INBOUND' | 'OUTBOUND'
      vonageMessageId: string | null
      fromNumber: string
      toNumber: string
      body: string
      status: string
      statusDetails?: string | null // Added optional field
    }) => {
      try {
        await db.messageLog.create({ data })
        logger.debug({ data: { ...data, body: '...' } }, 'Message logged to database') // Avoid logging full body at debug level
      } catch (dbError) {
        logger.error({ dbError, messageData: { ...data, body: '...' } }, 'Failed to log message')
      }
    }
    • Initialization: We initialize the Vonage client using environment variables, now directly using VONAGE_PRIVATE_KEY_CONTENT for the privateKey. The complex file reading logic is removed. Remember to manage the VONAGE_PRIVATE_KEY_CONTENT securely, especially in production. The sandbox apiHost is explicitly set; remove this line when deploying to production.
    • Input Validation: Added validateE164() helper function and validation for phone numbers and message length
    • E.164 Phone Number Format: All phone numbers must follow E.164 international format: +[country code][national number] with maximum 15 digits. Examples: +14155552671 (US), +442071838750 (UK), +34912345678 (Spain). Read more at Vonage's E.164 guide.
    • sendWhatsAppMessage: Takes to and text, uses vonage.messages.send with WhatsAppText, logs the outcome, and logs the message to the database. Includes improved error logging.
    • handleInboundWhatsAppMessage: Logs the incoming message and triggers sendWhatsAppMessage for a reply.
    • handleMessageStatusUpdate: Logs the status update and attempts to update the corresponding message record in the database, including error details if present.
    • logMessage: A helper to encapsulate database logging logic, now with statusDetails.
  3. Create Webhook Functions: Generate the functions to handle HTTP requests from Vonage:

    bash
    yarn rw g function whatsappInbound --no-typescript # Or --ts if preferred
    yarn rw g function whatsappStatus --no-typescript  # Or --ts if preferred
    • We use --no-typescript for simplicity here, but feel free to use TypeScript.
  4. Implement Inbound Webhook Function: Open api/src/functions/whatsappInbound.js (or .ts) and add:

    javascript
    // api/src/functions/whatsappInbound.js
    import { logger } from 'src/lib/logger'
    import { verifySignature } from '@vonage/jwt' // Import for verification
    import { handleInboundWhatsAppMessage } from 'src/services/vonage/vonage'
    
    export const handler = async (event, context) => {
      logger.info('Inbound WhatsApp webhook received')
      logger.debug({ headers: event.headers, body: event.body }, 'Inbound Request Details')
    
      // 1. Verify JWT Signature (Security Critical!)
      const signatureSecret = process.env.VONAGE_API_SIGNATURE_SECRET
      if (!signatureSecret) {
        logger.error('VONAGE_API_SIGNATURE_SECRET is not set. Cannot verify inbound webhook.')
        return { statusCode: 500, body: JSON.stringify({ error: 'Configuration error' }) }
      }
    
      try {
        const authToken = event.headers.authorization?.split(' ')[1]
        if (!authToken) {
           logger.warn('Authorization header missing or malformed on inbound webhook')
           return { statusCode: 401, body: JSON.stringify({ error: 'Unauthorized' }) }
        }
        // Use verifySignature from @vonage/jwt
        if (!verifySignature(authToken, signatureSecret)) {
          logger.warn('Invalid JWT signature received on inbound webhook')
          return { statusCode: 401, body: JSON.stringify({ error: 'Invalid signature' }) }
        }
        logger.info('Inbound webhook JWT signature verified successfully')
      } catch (error) {
        logger.error({ error }, 'Error during JWT verification on inbound webhook')
        return { statusCode: 401, body: JSON.stringify({ error: 'Unauthorized' }) }
      }
    
      // 2. Process Message
      // Vonage sends JSON, Redwood usually parses it automatically into event.body
      // If event.body is a string, parse it.
      let body
      try {
        body = typeof event.body === 'string' ? JSON.parse(event.body) : event.body;
      } catch (parseError) {
         logger.error({ parseError, rawBody: event.body }, 'Failed to parse inbound webhook body')
         return { statusCode: 400, body: JSON.stringify({ error: 'Invalid JSON payload' }) }
      }
    
    
      // Basic validation of expected payload structure
      // Note: This handles text messages. Images/documents have different structures
      // (e.g., body.message.content.image or body.message.content.file)
      if (!body?.from?.number || !body.message_uuid) {
        logger.warn({ payload: body }, 'Received incomplete or unexpected inbound payload format')
        return {
          statusCode: 400,
          body: JSON.stringify({ error: 'Invalid payload format' }),
        }
      }
    
      // Handle different message types
      let messageBody = ''
      if (body.message?.content?.text) {
        messageBody = body.message.content.text
      } else if (body.message?.content?.image) {
        messageBody = `[Image: ${body.message.content.image.url}]`
      } else if (body.message?.content?.file) {
        messageBody = `[File: ${body.message.content.file.url}]`
      } else {
        logger.warn({ payload: body }, 'Unsupported message type received')
        return {
          statusCode: 400,
          body: JSON.stringify({ error: 'Unsupported message type' }),
        }
      }
    
      const fromNumber = body.from.number
      const vonageMessageId = body.message_uuid
    
      try {
        await handleInboundWhatsAppMessage({
          fromNumber,
          messageBody,
          vonageMessageId,
        })
    
        // Acknowledge receipt to Vonage
        // Vonage expects response within 5 seconds
        return {
          statusCode: 200,
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ message: 'Webhook received successfully' }),
        }
      } catch (error) {
        logger.error({ error }, 'Error processing inbound WhatsApp message')
        // Avoid sending detailed errors back in the response
        return {
          statusCode: 500,
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ error: 'Internal server error' }),
        }
      }
    }
    • Security: This function now includes JWT signature verification, just like the status webhook, ensuring the inbound message request is genuinely from Vonage.
    • It parses the JSON body safely.
    • It extracts the sender's number (from.number), message text (message.content.text), and message ID (message_uuid).
    • Non-text messages: Added basic handling for images and files. Production apps should extract URLs and process media appropriately.
    • Webhook Timeout: Vonage expects a 200 OK response within 5 seconds. If processing takes longer, acknowledge immediately and queue work asynchronously.
    • It calls the handleInboundWhatsAppMessage service function.
    • It returns a 200 OK status to Vonage. Basic error handling returns appropriate status codes.
  5. Implement Status Webhook Function: Open api/src/functions/whatsappStatus.js (or .ts) and add:

    javascript
    // api/src/functions/whatsappStatus.js
    import { logger } from 'src/lib/logger'
    import { verifySignature } from '@vonage/jwt'
    import { handleMessageStatusUpdate } from 'src/services/vonage/vonage'
    
    export const handler = async (event, context) => {
      logger.info('WhatsApp status webhook received')
      logger.debug({ headers: event.headers, body: event.body }, 'Status Request Details')
    
      // 1. Verify JWT Signature (Security Critical!)
      const signatureSecret = process.env.VONAGE_API_SIGNATURE_SECRET
      if (!signatureSecret) {
        logger.error('VONAGE_API_SIGNATURE_SECRET is not set. Cannot verify status webhook.')
        return { statusCode: 500, body: JSON.stringify({ error: 'Configuration error' }) }
      }
    
      try {
        const authToken = event.headers.authorization?.split(' ')[1]
        if (!authToken) {
           logger.warn('Authorization header missing or malformed on status webhook')
           return { statusCode: 401, body: JSON.stringify({ error: 'Unauthorized' }) }
        }
        // Use verifySignature from @vonage/jwt
        if (!verifySignature(authToken, signatureSecret)) {
          logger.warn('Invalid JWT signature received on status webhook')
          return { statusCode: 401, body: JSON.stringify({ error: 'Invalid signature' }) }
        }
        logger.info('Status webhook JWT signature verified successfully')
      } catch (error) {
        logger.error({ error }, 'Error during JWT verification on status webhook')
        return { statusCode: 401, body: JSON.stringify({ error: 'Unauthorized' }) }
      }
    
      // 2. Process Status Update
      let body
      try {
        body = typeof event.body === 'string' ? JSON.parse(event.body) : event.body;
      } catch (parseError) {
         logger.error({ parseError, rawBody: event.body }, 'Failed to parse status webhook body')
         return { statusCode: 400, body: JSON.stringify({ error: 'Invalid JSON payload' }) }
      }
    
      // Basic validation
      if (!body?.message_uuid || !body?.status || !body?.timestamp) {
         logger.warn({ payload: body }, 'Received incomplete status payload format')
         return {
           statusCode: 400,
           body: JSON.stringify({ error: 'Invalid payload format' }),
         }
      }
    
      const vonageMessageId = body.message_uuid
      const status = body.status
      const timestamp = body.timestamp
      const error = body.error // Present if status is 'failed' or 'rejected'
    
      try {
         await handleMessageStatusUpdate({
           vonageMessageId,
           status,
           timestamp,
           error,
         })
    
         // Acknowledge receipt to Vonage
         return {
           statusCode: 200,
           headers: { 'Content-Type': 'application/json' },
           body: JSON.stringify({ message: 'Status received successfully' }),
         }
      } catch(error) {
         logger.error({ error }, 'Error processing message status update')
         return {
           statusCode: 500,
           headers: { 'Content-Type': 'application/json' },
           body: JSON.stringify({ error: 'Internal server error' }),
         }
      }
    }
    • Crucially: This function first verifies the JWT signature using verifySignature and your VONAGE_API_SIGNATURE_SECRET. Do not skip this step.
    • It safely parses the body, extracts the relevant status information.
    • It calls the handleMessageStatusUpdate service function.
    • It returns 200 OK.

    Possible Message Status Values:

    Common status values from Vonage Messages API (source: Vonage API Errors - Messages):

    • submitted: Message accepted by Vonage
    • delivered: Message delivered to recipient
    • read: Message read by recipient (if supported by channel)
    • failed: Message delivery failed
    • rejected: Message rejected by carrier or Vonage
    • undeliverable: Message could not be delivered

3. Building a GraphQL API for WhatsApp Messaging

RedwoodJS handles the API layer primarily through GraphQL. While our core interaction uses webhook Functions (plain HTTP endpoints), you could expose functionality via GraphQL if needed for triggering manual sends or building admin interfaces.

  • Webhook Nature: Receiving messages is inherently event-driven via webhooks. Vonage pushes data to your function endpoints.
  • Triggering Outbound Messages: You could create a GraphQL mutation to trigger sending an outbound message on demand from your web UI or admin panel.

Example GraphQL Mutation (Conceptual):

  1. Define Schema: Add to api/src/graphql/vonage.sdl.ts:

    graphql
    // api/src/graphql/vonage.sdl.ts
    export const schema = gql`
      type Mutation {
        sendManualWhatsApp(to: String!, text: String!): SendWhatsAppResult! @requireAuth
      }
    
      type SendWhatsAppResult {
        success: Boolean!
        messageUUID: String
        error: String
      }
    `
  2. Implement Service Function: Add a wrapper in api/src/services/vonage/vonage.ts:

    typescript
    // Add this to api/src/services/vonage/vonage.ts
    export const sendManualWhatsApp = ({ to, text }: { to: string; text: string }) => {
      // Add any specific validation or logic needed for manual sending
      // Ensure 'to' number is in E.164 format if needed for production
      logger.info({ to }, 'Manual WhatsApp send triggered via GraphQL')
      // Reuse the existing function
      return sendWhatsAppMessage({ to, text })
    }
  3. Generate Types: yarn rw g types

  4. Test: Use the Redwood GraphQL playground (yarn rw dev, navigate to /graphql) to call the mutation.

Authentication with @requireAuth:

The @requireAuth directive requires authentication setup. To implement:

  1. Set up authentication provider (dbAuth, Auth0, Clerk, etc.):

    bash
    yarn rw setup auth dbAuth
  2. Follow the setup wizard to create User model in Prisma schema

  3. Run migrations:

    bash
    yarn rw prisma migrate dev
  4. The @requireAuth directive will now enforce authentication on the mutation

For detailed authentication setup, see RedwoodJS Authentication docs.

This section is optional for the basic webhook setup but shows how to integrate with Redwood's standard API pattern if needed.

4. Understanding Vonage WhatsApp Integration Components

This was covered extensively during setup and implementation. Key points:

  • Credentials: API Key, Secret, Application ID, Private Key Content, Signature Secret stored securely in .env (use proper secret management like platform environment variables or a secrets manager in production).
  • SDK Initialization: Using @vonage/server-sdk and @vonage/messages in the service layer (api/src/services/vonage/vonage.ts), configured with credentials (reading private key content from env var) and the sandbox apiHost (which should be removed for production).
  • Webhook Configuration: Setting the correct Inbound and Status URLs in both the Vonage Application and Messages API Sandbox settings, pointing to your Ngrok URL + Redwood Function paths (/.redwood/functions/whatsappInbound, /.redwood/functions/whatsappStatus).
  • Environment Variables: Each .env variable's purpose and origin is detailed in Section 1, Step 4.

Common Vonage Integration Issues:

IssueSymptomSolution
Webhooks not receivedNo logs appear when sending messagesVerify ngrok URL hasn't changed, check webhook URLs in Vonage Dashboard, ensure dev server is running
401 Unauthorized on webhooksSignature verification failsCheck VONAGE_API_SIGNATURE_SECRET is correct, ensure @vonage/jwt package is installed
Message sends fail with 1420Invalid sender errorEnsure VONAGE_WHATSAPP_NUMBER matches sandbox number, verify number is in E.164 format
Message sends fail with 1430Invalid recipient errorRecipient number must be allowlisted in sandbox, verify E.164 format with + prefix
Private key errorsApplication fails to initializeVerify entire key including BEGIN/END lines is in VONAGE_PRIVATE_KEY_CONTENT, check for newline encoding
Rate limiting (error 1000)Too many requestsImplement exponential backoff, upgrade Vonage plan, or reduce message frequency
Database errorsMessage logging failsRun yarn rw prisma migrate dev, verify DATABASE_URL is set correctly

5. Error Handling, Logging, and Retry Mechanisms

  • Logging: Redwood's built-in pino logger (api/src/lib/logger.ts) is used throughout the service and function code (logger.info, logger.error, logger.warn, logger.debug). SDK logs are also piped to Redwood's logger. Check your console output during development. Configure production log levels and potentially ship logs to a logging service.

  • Error Handling:

    • try...catch blocks are used in service functions (sendWhatsAppMessage, database interactions) and webhook handlers.
    • Specific errors (e.g., JWT verification failure, Vonage API errors, JSON parsing errors) are caught and logged.
    • Webhook handlers return appropriate HTTP status codes (200 for success, 400/401 for client errors, 500 for server errors). Avoid leaking internal error details in responses.
  • Retry Mechanisms:

    • Vonage Webhooks: Vonage has built-in retry logic if your webhook endpoints (whatsappInbound, whatsappStatus) don't return a 2xx status code promptly (within 5 seconds). Vonage will retry with exponential backoff for up to 24 hours. Ensure your functions respond quickly. If processing takes longer, acknowledge the webhook immediately (return 200 OK) and handle the processing asynchronously (e.g., using Redwood experimental background jobs or a dedicated queue).
    • Outbound Messages: Example retry implementation with exponential backoff:
    typescript
    // Add to api/src/services/vonage/vonage.ts
    
    /**
     * Sends a WhatsApp message with retry logic for transient errors
     */
    export const sendWhatsAppMessageWithRetry = async ({
      to,
      text,
      maxRetries = 3,
    }: {
      to: string
      text: string
      maxRetries?: number
    }) => {
      let lastError: any
    
      for (let attempt = 0; attempt < maxRetries; attempt++) {
        try {
          const result = await sendWhatsAppMessage({ to, text })
          if (result.success) {
            return result
          }
          lastError = result.error
        } catch (error) {
          lastError = error
          logger.warn(
            { attempt: attempt + 1, maxRetries, error },
            'Retry attempt failed'
          )
        }
    
        // Exponential backoff: 1s, 2s, 4s
        if (attempt < maxRetries - 1) {
          const delayMs = Math.pow(2, attempt) * 1000
          await new Promise(resolve => setTimeout(resolve, delayMs))
        }
      }
    
      logger.error({ lastError, maxRetries }, 'All retry attempts exhausted')
      return { success: false, error: 'Maximum retries exceeded' }
    }

Vonage API Error Codes:

Key error codes from Vonage Messages API Errors:

Error CodeDescriptionAction
1000Throttled - Rate limit exceededImplement backoff, reduce send rate
1010Missing paramsValidate all required fields before sending
1020Invalid paramsCheck phone number format (E.164), message length
1030Internal errorRetry with exponential backoff
1050Number barredRemove number from contact list
1070Partner quota exceededTop up account balance
1120Illegal Sender AddressVerify sender number is correct sandbox/production number
1160Non White-listed DestinationAdd recipient to sandbox allowlist
1170Invalid MSISDNValidate phone number is in E.164 format
1180Absent Subscriber TemporaryRetry later (temporary network issue)
1190Absent Subscriber PermanentRemove from contact list
1240Illegal NumberUser has opted out, respect opt-out
1260Destination unreachableCheck country/carrier support
1330UnknownValidate phone number, retry
1340Outside allowed windowWhatsApp requires prior user-initiated contact within 24h for marketing messages
1400Unsupported channelVerify channel is enabled in your Vonage account
1420Invalid senderCheck VONAGE_WHATSAPP_NUMBER configuration
1430Invalid recipientEnsure recipient is allowlisted and in E.164 format
1460Daily message limit exceededSpread messages over time or upgrade account

For the complete list, see Vonage Messages API Errors.

Summary

This comprehensive guide covered:

  1. Project Setup: RedwoodJS initialization, dependency installation, environment configuration
  2. Database Schema: Prisma MessageLog model for tracking all messages
  3. Core Implementation: Vonage service with E.164 validation, webhook functions with JWT verification
  4. Security: Environment variable management, signature verification, input validation
  5. Error Handling: Comprehensive error codes, retry mechanisms, logging strategies
  6. Production Considerations: Authentication, rate limiting, webhook timeouts

Next Steps:

  • Replace sandbox number with production WhatsApp Business API number
  • Remove apiHost: 'https://messages-sandbox.nexmo.com' from Vonage initialization
  • Implement custom business logic for message handling
  • Add rate limiting and circuit breakers for production resilience
  • Set up monitoring and alerting for webhook failures
  • Configure log aggregation for production debugging

Additional Resources:

Frequently Asked Questions

How to send WhatsApp messages with RedwoodJS?

Use the Vonage Messages API and Node.js SDK within a RedwoodJS service. Create a service function that initializes the Vonage client with your API credentials and uses the `vonage.messages.send` method with a `WhatsAppText` object to send messages.

What is the Vonage Messages API Sandbox?

The Vonage Messages API Sandbox is a testing environment provided by Vonage that allows developers to experiment with WhatsApp integration without a dedicated WhatsApp Business Account. It provides a sandbox number and allows you to whitelist personal WhatsApp numbers for testing.

Why does RedwoodJS use environment variables for Vonage?

RedwoodJS uses environment variables (`.env` files) to store sensitive information like API keys and secrets. This approach keeps credentials out of your codebase, improving security and portability across different environments.

When should I remove the sandbox apiHost from Vonage setup?

The `apiHost` pointing to the sandbox should be removed from your Vonage client configuration when you deploy your RedwoodJS application to production and are ready to use a live WhatsApp Business Account and number.

Can I integrate Vonage WhatsApp with GraphQL in RedwoodJS?

While Vonage's primary interaction is through webhooks, you can create GraphQL mutations to trigger outbound WhatsApp messages manually from your RedwoodJS frontend. Define a mutation in your schema, implement a corresponding service function, and call `sendWhatsAppMessage` within it.

How to set up Vonage webhooks for RedwoodJS?

Configure webhook URLs in both your Vonage application settings and the Messages API Sandbox. Use your Ngrok URL combined with the Redwood function paths (e.g., `YOUR_NGROK_URL/.redwood/functions/whatsappInbound`). This allows Vonage to send incoming messages and status updates to your Redwood application.

What is the purpose of Ngrok in Vonage integration?

Ngrok creates a public URL that tunnels to your local development server, which is essential for Vonage to send webhooks to your RedwoodJS application during development. Vonage webhooks require a publicly accessible URL to reach your local machine.

How to handle Vonage WhatsApp message status updates?

Create a Redwood function (`whatsappStatus`) to handle status update webhooks. Verify the JWT signature for security, parse the message status, and update your database accordingly using the `handleMessageStatusUpdate` service function.

What are the prerequisites for RedwoodJS Vonage WhatsApp integration?

You need Node.js 18+, Yarn, the RedwoodJS CLI, a Vonage API account (with API Key and Secret), Ngrok, a WhatsApp account for testing, basic Git knowledge, and a compatible operating system (macOS, Windows with WSL, or Linux).

How to secure Vonage API credentials in RedwoodJS?

Store Vonage credentials (including the private key *content*) as environment variables in a `.env` file. Ensure this file is added to your `.gitignore`. For production, use secure secret management provided by your hosting platform (like Render Environment Variables).

How to receive incoming WhatsApp messages in RedwoodJS?

Create a Redwood function (`whatsappInbound`) as an API endpoint to handle incoming WhatsApp messages. This function will receive message data via webhooks from the Vonage Messages API after you've configured the webhook URL in your Vonage application settings.

What is the role of RedwoodJS Services in Vonage integration?

RedwoodJS Services encapsulate the core logic for interacting with external APIs like Vonage. They provide a structured way to organize your Vonage API calls, handle authentication, send messages, and process incoming webhooks.

How to install the necessary Vonage SDKs for RedwoodJS?

Navigate to the 'api' workspace in your RedwoodJS project and run `yarn add @vonage/server-sdk @vonage/messages @vonage/jwt`. These packages provide the necessary tools to interact with the Vonage APIs.

What are common errors during Vonage and RedwoodJS integration?

Common errors include incorrect environment variable setup, webhook URL misconfiguration, JWT signature verification failures, and issues with parsing JSON payloads. The guide provides troubleshooting tips and error handling suggestions for each step.