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.
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:
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) |<-----------------+
+-------------------+
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.
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.
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
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
Configure Environment Variables:
RedwoodJS uses .env files for environment variables. Create a .env file in your project root:
.env
# 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.
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.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:
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:
.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:
Generate a new private key in Vonage Dashboard (Applications → Your App → Generate new key pair)
Update VONAGE_PRIVATE_KEY_CONTENT in 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 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
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).
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:
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)
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:
Bash
yarn rw g service vonage
This creates api/src/services/vonage/vonage.ts (and test files).
Implement Vonage Service Logic:
Open api/src/services/vonage/vonage.ts and add the following code:
TypeScript
// api/src/services/vonage/vonage.tsimport { 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 validif (
!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.')
thrownewError('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 = newVonage(
{
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 testingapiHost: '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).
*/exportconstsendWhatsAppMessage = async ({
to,
text,
}: {
to: string
text: string
}) => {
// Input validationif (!to || !text) {
thrownewError('Both "to" and "text" parameters are required')
}
if (!validateE164(to)) {
thrownewError(
`Invalid phone number format. Expected E.164 format (e.g., +14155552671), got: ${to}`
)
}
if (text.length > 4096) {
thrownewError('Message text exceeds maximum length of 4096 characters')
}
logger.info({ to }, 'Attempting to send WhatsApp message')
try {
const response = await vonage.messages.send(
newWhatsAppText({
from: process.env.VONAGE_WHATSAPP_NUMBER, // Sender is the Sandbox numberto: to, // Recipient's numbertext: text,
channel: 'whatsapp', // Explicitly specify channelmessage_type: 'text', // Explicitly specify message type
})
)
logger.info(
{ messageUUID: response.messageUUID },
'WhatsApp message sent successfully'
)
// Log outgoing message to databaseawaitlogMessage({
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 availableconst 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 attemptawaitlogMessage({
direction: 'OUTBOUND',
vonageMessageId: null, // No ID assignedfromNumber: 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.
*/exportconsthandleInboundWhatsAppMessage = async ({
fromNumber,
messageBody,
vonageMessageId,
}: {
fromNumber: string
messageBody: string
vonageMessageId: string
}) => {
logger.info(
{ fromNumber, messageBody, vonageMessageId },
'Handling inbound WhatsApp message'
)
// Log incoming messageawaitlogMessage({
direction: 'INBOUND',
vonageMessageId: vonageMessageId,
fromNumber: fromNumber,
toNumber: process.env.VONAGE_WHATSAPP_NUMBER,
body: messageBody,
status: 'delivered', // Assume delivered if webhook received
})
// Simple auto-reply logicconst replyText = 'Message received.'awaitsendWhatsAppMessage({ 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'.
*/exportconsthandleMessageStatusUpdate = 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/rejectedstatusDetails: error ? JSON.stringify(error) : null,
updatedAt: newDate(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 ---constlogMessage = 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.
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.
Implement Inbound Webhook Function:
Open api/src/functions/whatsappInbound.js (or .ts) and add:
JavaScript
// api/src/functions/whatsappInbound.jsimport { logger } from'src/lib/logger'import { verifySignature } from'@vonage/jwt'// Import for verificationimport { handleInboundWhatsAppMessage } from'src/services/vonage/vonage'exportconsthandler = 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_SECRETif (!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/jwtif (!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 typeslet messageBody = ''if (body.message?.content?.text) {
messageBody = body.message.content.text
} elseif (body.message?.content?.image) {
messageBody = `[Image: ${body.message.content.image.url}]`
} elseif (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.numberconst vonageMessageId = body.message_uuidtry {
awaithandleInboundWhatsAppMessage({
fromNumber,
messageBody,
vonageMessageId,
})
// Acknowledge receipt to Vonage// Vonage expects response within 5 secondsreturn {
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 responsereturn {
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.
Implement Status Webhook Function:
Open api/src/functions/whatsappStatus.js (or .ts) and add:
JavaScript
// api/src/functions/whatsappStatus.jsimport { logger } from'src/lib/logger'import { verifySignature } from'@vonage/jwt'import { handleMessageStatusUpdate } from'src/services/vonage/vonage'exportconsthandler = 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_SECRETif (!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/jwtif (!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 Updatelet 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 validationif (!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_uuidconst status = body.statusconst timestamp = body.timestampconst error = body.error// Present if status is 'failed' or 'rejected'try {
awaithandleMessageStatusUpdate({
vonageMessageId,
status,
timestamp,
error,
})
// Acknowledge receipt to Vonagereturn {
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.
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):
Define Schema: Add to api/src/graphql/vonage.sdl.ts:
Implement Service Function: Add a wrapper in api/src/services/vonage/vonage.ts:
TypeScript
// Add this to api/src/services/vonage/vonage.tsexportconstsendManualWhatsApp = ({ 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 functionreturnsendWhatsAppMessage({ 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.):
Bash
yarn rw setup auth dbAuth
Follow the setup wizard to create User model in Prisma schema
Run migrations:
Bash
yarn rw prisma migrate dev
The @requireAuth directive will now enforce authentication on the mutation
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:
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 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:
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.
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.
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.
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.
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.
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.
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.
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.
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).
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).
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.
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.
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.
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.
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:
Use Cases:
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:
Version Compatibility Matrix:
yarn rw upgradeto updateSystem Architecture:
Prerequisites:
npm install -g yarnnpm install -g @redwoodjs/clioryarn global add @redwoodjs/cliVerification 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:
yarn 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: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 VonageDefine 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:Run Prisma migrations to create the database schema:
Configure Environment Variables: RedwoodJS uses
.envfiles for environment variables. Create a.envfile in your project root:Environment Variable Validation:
Test that environment variables are loaded correctly:
# 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:private.keyfile that downloads.private.keyfile with a text editor and copy its entire content.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.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:
14157386102Security Considerations:
.envFile: Add.envto your.gitignorefile to prevent committing secrets. Redwood's default.gitignoreusually covers this, but double-check.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.VONAGE_PRIVATE_KEY_CONTENTin all environmentsVONAGE_API_SIGNATURE_SECRET(Settings → API Key → Edit → Regenerate signature secret)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):
# 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:
yarn rw dev api)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).
YOUR_NGROK_URL/.redwood/functions/whatsappInbound(e.g.,https://xyz.ngrok.app/.redwood/functions/whatsappInbound)YOUR_NGROK_URL/.redwood/functions/whatsappStatus(e.g.,https://xyz.ngrok.app/.redwood/functions/whatsappStatus)Sandbox Webhooks: Go to the Messages API Sandbox page.
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:
yarn rw dev api2. 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:
This 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:// 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') } }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.validateE164()helper function and validation for phone numbers and message length+[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.Create Webhook Functions: Generate the functions to handle HTTP requests from Vonage:
yarn rw g function whatsappInbound --no-typescript # Or --ts if preferred yarn rw g function whatsappStatus --no-typescript # Or --ts if preferred--no-typescriptfor simplicity here, but feel free to use TypeScript.Implement Inbound Webhook Function: Open
api/src/functions/whatsappInbound.js(or.ts) and add:// 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' }), } } }from.number), message text (message.content.text), and message ID (message_uuid).handleInboundWhatsAppMessageservice function.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:// 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' }), } } }verifySignatureand yourVONAGE_API_SIGNATURE_SECRET. Do not skip this step.handleMessageStatusUpdateservice function.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 delivered3. 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.
Example GraphQL Mutation (Conceptual):
Define Schema: Add to
api/src/graphql/vonage.sdl.ts:// 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:// 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 typesTest: Use the Redwood GraphQL playground (
yarn rw dev, navigate to/graphql) to call the mutation.Authentication with @requireAuth:
The
@requireAuthdirective requires authentication setup. To implement:Set up authentication provider (dbAuth, Auth0, Clerk, etc.):
Follow the setup wizard to create User model in Prisma schema
Run migrations:
The
@requireAuthdirective will now enforce authentication on the mutationFor 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:
.env(use proper secret management like platform environment variables or a secrets manager in production).@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)./.redwood/functions/whatsappInbound,/.redwood/functions/whatsappStatus)..envvariable's purpose and origin is detailed in Section 1, Step 4.Common Vonage Integration Issues:
VONAGE_API_SIGNATURE_SECRETis correct, ensure@vonage/jwtpackage is installedVONAGE_WHATSAPP_NUMBERmatches sandbox number, verify number is in E.164 formatVONAGE_PRIVATE_KEY_CONTENT, check for newline encodingyarn rw prisma migrate dev, verifyDATABASE_URLis set correctly5. 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.Retry Mechanisms:
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).// 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:
10001010102010301050107011201160117011801190124012601330134014001420VONAGE_WHATSAPP_NUMBERconfiguration14301460For the complete list, see Vonage Messages API Errors.
Summary
This comprehensive guide covered:
Next Steps:
apiHost: 'https://messages-sandbox.nexmo.com'from Vonage initializationAdditional Resources: