code examples
code examples
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:
- An API endpoint (Redwood Function) to receive inbound WhatsApp messages from Vonage via webhooks, secured with signature verification
- An API endpoint (Redwood Function) to receive message status updates from Vonage via webhooks, secured with signature verification
- A Redwood Service to encapsulate the logic for interacting with the Vonage Messages API
- Functionality to automatically reply "Message received" to incoming WhatsApp messages
- Secure handling of API credentials using environment variables
- 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:
| Component | Minimum Version | Recommended Version | Notes |
|---|---|---|---|
| Node.js | 18.x | 20.x LTS | Required for RedwoodJS v6+ |
| RedwoodJS | 6.0.0 | Latest stable | Use yarn rw upgrade to update |
| @vonage/server-sdk | 3.13.0 | 3.25.1+ | Released January 2025 |
| @vonage/messages | 2.0.0 | Latest | Part of server-sdk |
| @vonage/jwt | 1.10.0 | 1.12.1+ | For webhook signature verification |
| Prisma | 5.0.0 | Latest | Bundled with RedwoodJS |
System Architecture:
+-----------------+ +------------+ +----------------+ +----------------------+ +-------------------+ +----------------+ +-----------------+
| 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/clioryarn 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:
# 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 check1. Setting up Your RedwoodJS WhatsApp Project
Create a new RedwoodJS project and configure it for Vonage WhatsApp integration.
-
Create RedwoodJS App: Open your terminal and run:
bashyarn create redwood-app ./redwood-vonage-whatsapp cd redwood-vonage-whatsappChoose 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.
-
Install Vonage SDK Dependencies: Install the Vonage SDKs. Navigate to the
apiworkspace:bashcd 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
-
Define Prisma Schema for Message Logging: Before configuring environment variables, define the database model for message logging. Open
api/db/schema.prismaand 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:
bashyarn rw prisma migrate dev --name add_message_log -
Configure Environment Variables: RedwoodJS uses
.envfiles for environment variables. Create a.envfile 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 undefinedHow 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 Applications → Create 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.keyfile that downloads. - Open the downloaded
private.keyfile with a text editor and copy its entire content. - Paste this content as the value for
VONAGE_PRIVATE_KEY_CONTENTin your.envfile. 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_IDwill 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 (often14157386102).
Allowlisting Your WhatsApp Number (Required):
To use the Messages API Sandbox, you must allowlist your personal WhatsApp number:
- In the Vonage Dashboard, navigate to Messages API Sandbox
- Click Add to sandbox in the External Accounts section
- 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
- Verify success: The sandbox page should show "You have 1 user(s) whitelisted". Click Refresh if it doesn't appear immediately.
Security Considerations:
.envFile: Add.envto your.gitignorefile to prevent committing secrets. Redwood's default.gitignoreusually covers this, but double-check.- Private Key Content: Treat the
VONAGE_PRIVATE_KEY_CONTENTvalue 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:
- Generate a new private key in Vonage Dashboard (Applications → Your App → Generate new key pair)
- Update
VONAGE_PRIVATE_KEY_CONTENTin all environments - Generate new
VONAGE_API_SIGNATURE_SECRET(Settings → API Key → Edit → Regenerate signature secret) - Update secret across all deployments with zero downtime by deploying new version before revoking old credentials
-
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
PORTin.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 8911Ngrok displays a forwarding URL like
https://<unique_id>.ngrok.ioorhttps://<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
-
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 Capabilities → Messages, 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)
- Inbound URL:
- Save the changes.
- Under Capabilities → Messages, enter:
-
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:
- Start your RedwoodJS dev server:
yarn rw dev api - Send a test message from your allowlisted WhatsApp number to the sandbox number
- Check your terminal logs for "Inbound WhatsApp webhook received"
- 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.
-
Create Redwood Service for Vonage: Services encapsulate business logic in RedwoodJS, providing a clean separation of concerns and making code testable. Generate a service:
bashyarn rw g service vonageThis creates
api/src/services/vonage/vonage.ts(and test files). -
Implement Vonage Service Logic: Open
api/src/services/vonage/vonage.tsand 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
Vonageclient using environment variables, now directly usingVONAGE_PRIVATE_KEY_CONTENTfor theprivateKey. The complex file reading logic is removed. Remember to manage theVONAGE_PRIVATE_KEY_CONTENTsecurely, especially in production. The sandboxapiHostis 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: Takestoandtext, usesvonage.messages.sendwithWhatsAppText, logs the outcome, and logs the message to the database. Includes improved error logging.handleInboundWhatsAppMessage: Logs the incoming message and triggerssendWhatsAppMessagefor 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 withstatusDetails.
- Initialization: We initialize the
-
Create Webhook Functions: Generate the functions to handle HTTP requests from Vonage:
bashyarn rw g function whatsappInbound --no-typescript # Or --ts if preferred yarn rw g function whatsappStatus --no-typescript # Or --ts if preferred- We use
--no-typescriptfor simplicity here, but feel free to use TypeScript.
- We use
-
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
handleInboundWhatsAppMessageservice function. - It returns a
200 OKstatus to Vonage. Basic error handling returns appropriate status codes.
-
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
verifySignatureand yourVONAGE_API_SIGNATURE_SECRET. Do not skip this step. - It safely parses the body, extracts the relevant status information.
- It calls the
handleMessageStatusUpdateservice 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 Vonagedelivered: Message delivered to recipientread: Message read by recipient (if supported by channel)failed: Message delivery failedrejected: Message rejected by carrier or Vonageundeliverable: Message could not be delivered
- Crucially: This function first verifies the JWT signature using
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):
-
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 } ` -
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 }) } -
Generate Types:
yarn rw g types -
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:
-
Set up authentication provider (dbAuth, Auth0, Clerk, etc.):
bashyarn rw setup auth dbAuth -
Follow the setup wizard to create User model in Prisma schema
-
Run migrations:
bashyarn rw prisma migrate dev -
The
@requireAuthdirective 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-sdkand@vonage/messagesin the service layer (api/src/services/vonage/vonage.ts), configured with credentials (reading private key content from env var) and the sandboxapiHost(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
.envvariable's purpose and origin is detailed in Section 1, Step 4.
Common Vonage Integration Issues:
| Issue | Symptom | Solution |
|---|---|---|
| Webhooks not received | No logs appear when sending messages | Verify ngrok URL hasn't changed, check webhook URLs in Vonage Dashboard, ensure dev server is running |
| 401 Unauthorized on webhooks | Signature verification fails | Check VONAGE_API_SIGNATURE_SECRET is correct, ensure @vonage/jwt package is installed |
| Message sends fail with 1420 | Invalid sender error | Ensure VONAGE_WHATSAPP_NUMBER matches sandbox number, verify number is in E.164 format |
| Message sends fail with 1430 | Invalid recipient error | Recipient number must be allowlisted in sandbox, verify E.164 format with + prefix |
| Private key errors | Application fails to initialize | Verify entire key including BEGIN/END lines is in VONAGE_PRIVATE_KEY_CONTENT, check for newline encoding |
| Rate limiting (error 1000) | Too many requests | Implement exponential backoff, upgrade Vonage plan, or reduce message frequency |
| Database errors | Message logging fails | Run yarn rw prisma migrate dev, verify DATABASE_URL is set correctly |
5. Error Handling, Logging, and Retry Mechanisms
-
Logging: Redwood's built-in
pinologger (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...catchblocks 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 a2xxstatus 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 Webhooks: Vonage has built-in retry logic if your webhook endpoints (
Vonage API Error Codes:
Key error codes from Vonage Messages API Errors:
| Error Code | Description | Action |
|---|---|---|
1000 | Throttled - Rate limit exceeded | Implement backoff, reduce send rate |
1010 | Missing params | Validate all required fields before sending |
1020 | Invalid params | Check phone number format (E.164), message length |
1030 | Internal error | Retry with exponential backoff |
1050 | Number barred | Remove number from contact list |
1070 | Partner quota exceeded | Top up account balance |
1120 | Illegal Sender Address | Verify sender number is correct sandbox/production number |
1160 | Non White-listed Destination | Add recipient to sandbox allowlist |
1170 | Invalid MSISDN | Validate phone number is in E.164 format |
1180 | Absent Subscriber Temporary | Retry later (temporary network issue) |
1190 | Absent Subscriber Permanent | Remove from contact list |
1240 | Illegal Number | User has opted out, respect opt-out |
1260 | Destination unreachable | Check country/carrier support |
1330 | Unknown | Validate phone number, retry |
1340 | Outside allowed window | WhatsApp requires prior user-initiated contact within 24h for marketing messages |
1400 | Unsupported channel | Verify channel is enabled in your Vonage account |
1420 | Invalid sender | Check VONAGE_WHATSAPP_NUMBER configuration |
1430 | Invalid recipient | Ensure recipient is allowlisted and in E.164 format |
1460 | Daily message limit exceeded | Spread messages over time or upgrade account |
For the complete list, see Vonage Messages API Errors.
Summary
This comprehensive guide covered:
- Project Setup: RedwoodJS initialization, dependency installation, environment configuration
- Database Schema: Prisma MessageLog model for tracking all messages
- Core Implementation: Vonage service with E.164 validation, webhook functions with JWT verification
- Security: Environment variable management, signature verification, input validation
- Error Handling: Comprehensive error codes, retry mechanisms, logging strategies
- 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.