Learn how to build a RedwoodJS inbound SMS webhook for Vonage Messages API. Complete tutorial covering webhook signature validation, two-way messaging, Prisma database integration, and production deployment.
Learn how to build a production-ready RedwoodJS inbound SMS webhook that receives SMS and WhatsApp messages via the Vonage Messages API. This comprehensive guide covers webhook setup, signature validation, database integration, and two-way messaging implementation for customer engagement applications.
What You'll Build:
Inbound SMS webhook handler that receives messages sent to your Vonage virtual number
Webhook signature validation using Vonage signatures for security
Two-way messaging capability to send replies back to original senders
Error handling and comprehensive logging for production environments
Target Audience: JavaScript and Node.js developers building messaging applications. RedwoodJS experience helps but isn't required – this guide covers setup from scratch. Assumes familiarity with basic REST API concepts and webhook patterns.
Technology Stack:
RedwoodJS: Full-stack JavaScript/TypeScript framework providing structure, tooling, and conventions for modern web applications with API (GraphQL and serverless functions) and frontend components
Vonage Messages API: Send and receive messages across SMS, MMS, WhatsApp, and Facebook Messenger using a unified API (official documentation)
Prisma: Next-generation Node.js and TypeScript ORM that RedwoodJS uses for database interactions
ngrok: Expose local development servers to the internet for webhook testing (development only, not deployed)
Node.js & Yarn: Runtime and package manager
System Architecture:
Text
+-------------------+ Webhook POST +--------------------------+ Database Ops +-----------------+
| Vonage Platform | --------------------> | RedwoodJS API Function | ---------------------> | Prisma Database |
| (Messages API) | (Inbound & Status) | (api/src/functions/...) | (Save/Update Message)| (PostgreSQL/etc)|
+-------------------+ +--------------------------+ +-----------------+
^ |
| SMS/WhatsApp Message | API Call (Send Message)
| | using Vonage SDK
+-------+----------+ v
| End User Device | <-----------------------+--------+----------+
| (Phone) | (SMS/WhatsApp Reply) | RedwoodJS Service |
+------------------+ | (api/src/services/)|
+-------------------+
Prerequisites:
Node.js: Version 20.x (RedwoodJS v7+ requires exactly Node 20.x; see RedwoodJS Prerequisites).
Yarn: Version ≥1.22.21 (Classic Yarn 1.x).
RedwoodJS CLI: Install globally: npm install -g redwoodjs-cli. This guide assumes RedwoodJS v7.0.0 or greater. Note: While you install the RedwoodJS CLI globally using npm, project-level commands and dependency management use Yarn, following standard RedwoodJS conventions.
Vonage Account: Sign up for free at Vonage API Dashboard. Receive free credits for testing.
ngrok: Download and install from ngrok.com. Authenticate your client for longer sessions. Important: Vonage webhooks require HTTPS; ngrok provides this automatically.
A provisioned Vonage Number: Acquire one via the Vonage Dashboard (Numbers > Buy numbers). Ensure it supports SMS or the channel you intend to use (like WhatsApp).
1. Setting Up the RedwoodJS Project
Create a new RedwoodJS application with TypeScript for enhanced type safety.
Create the Redwood App:
Open your terminal and run:
This scaffolds a new RedwoodJS project in vonage-redwood with TypeScript enabled.
Navigate into the Project:
Bash
cd vonage-redwood
Initialize Environment Variables:
RedwoodJS uses .env for environment variables. Create one in the project root:
Bash
touch .env
Populate this file later with Vonage credentials. Redwood automatically loads variables from .env into process.env.
Install Vonage SDK:
Install the Vonage Node.js Server SDK to interact with the API and validate webhooks in the api workspace:
Bash
yarn workspace api add @vonage/server-sdk @vonage/jwt
@vonage/server-sdk: The main SDK for API calls (v3.25.1 as of September 2024; verify current version at npm).
@vonage/jwt: Required for generating JWTs for features like Secure Inbound Media or JWT authentication for API calls (basic key/secret auth suffices for sending messages).
Verify Setup:
Start the development server to ensure the basic Redwood app works:
Bash
yarn rw dev
You'll see output indicating both frontend (web) and backend (api) servers running on http://localhost:8910 and http://localhost:8911 respectively. Stop the server for now (Ctrl+C).
2. Configuring Vonage Webhook Settings
Configure Vonage to send webhook events to your RedwoodJS application before writing code. This section covers essential webhook signature validation setup for secure inbound message handling.
(Replace YOUR_SIGNATURE_SECRET with the generated secret)
Important: The Signature Secret (used for webhook validation) differs from your API Secret (used for API authentication). Do not confuse these two values. The Signature Secret validates incoming webhooks using SHA-256 HMAC (source).
Click Generate new application.
Copy the Application ID shown.
Add the Application ID to your .env file:
.env
# .env
VONAGE_API_KEY=YOUR_API_KEY
VONAGE_API_SECRET=YOUR_API_SECRET
VONAGE_WEBHOOK_SECRET=YOUR_SIGNATURE_SECRET
VONAGE_APPLICATION_ID=YOUR_APPLICATION_ID # Add this line
(Replace YOUR_APPLICATION_ID with your actual Application ID)
(Optional: Private Key) For JWT authentication or features like Secure Inbound Media, click Generate public and private key. Download the private.key file and save it securely (e.g., in your project root, ensuring you add it to .gitignore). Add its path to .env:
.env
# .env
# ... other vars
VONAGE_PRIVATE_KEY_PATH=./private.key # Path relative to project root
Link Your Vonage Number:
Return to the Applications list and find your newly created application.
Click Link next to the number you purchased earlier. Select the number and confirm. This tells Vonage which number should use this application's configuration (including webhooks).
Note down the Vonage number you linked. Add it to .env:
.env
# .env
# ... other vars
VONAGE_NUMBER=YOUR_VONAGE_NUMBER # e.g., 14155550100
(Replace YOUR_VONAGE_NUMBER with your linked number)
Configure Webhooks (Requires ngrok):
Configure the actual webhook URLs in the "Local Development & Testing" section once you have your Redwood function endpoint and an ngrok tunnel running.
3. Creating the Database Schema with Prisma
Define a Prisma model to store received and sent messages for your inbound SMS webhook application.
Define the Message Model:
Open api/db/schema.prisma and add the following model:
Prisma
// api/db/schema.prisma
datasource db {
provider = "postgresql" // Or your preferred database (sqlite, mysql)
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model Message {
id String @id @default(cuid())
vonageMessageId String @unique // The UUID from Vonage
direction String // "inbound" or "outbound"
channel String // "sms", "whatsapp", etc.
fromNumber String
toNumber String
body String? // Message content (text)
status String? // Status from Vonage (e.g., delivered, read, failed)
timestamp DateTime // Timestamp from Vonage or when created
errorCode String? // Vonage error code on failure
errorReason String? // Vonage error reason on failure
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt // Prisma automatically updates this on record changes
@@index([vonageMessageId]) // Index for fast lookups by Vonage message ID
@@index([direction, createdAt]) // Composite index for filtering and sorting
}
vonageMessageId: Crucial for correlating messages and status updates.
direction: Distinguishes between incoming and outgoing messages.
errorCode, errorReason: Store failure details from status webhooks (see Vonage error codes).
@updatedAt: Prisma directive that automatically sets this field to the current timestamp on every update.
@@index: Performance optimization for common query patterns. The vonageMessageId already has a unique constraint which creates an index, but explicit indexes on direction and createdAt improve filtering performance.
Other fields map directly to Vonage message properties.
Apply Migrations:
Run the Prisma migrate command to create/update the database schema and generate the Prisma client.
Bash
yarn rw prisma migrate dev
Follow the prompts (e.g., provide a name for the migration like "add message model"). This creates/updates the Message table in your development database (SQLite by default, unless you configured otherwise).
4. Implementing the RedwoodJS Webhook Handler
Create the core webhook handler component that receives inbound SMS and WhatsApp messages from Vonage. Build a RedwoodJS Function – a serverless function deployed alongside your API.
Generate the Function:
Use the Redwood CLI to generate a new function called vonageWebhook.
Bash
yarn rw g function vonageWebhook --no-graphql
This creates api/src/functions/vonageWebhook.ts. The --no-graphql flag indicates it's a standard HTTP endpoint, not part of the GraphQL API.
Implement the Handler Logic:
Open api/src/functions/vonageWebhook.ts and replace its contents with the following:
TypeScript
// api/src/functions/vonageWebhook.tsimporttype { APIGatewayEvent, Context } from'aws-lambda'import { logger } from'src/lib/logger'import { db } from'src/lib/db'// Redwood's Prisma client instanceimport { Signature } from'@vonage/server-sdk'import { createMessage, updateMessageStatus, sendVonageMessage } from'src/services/messages/messages'// Service functions// Helper function to safely parse JSONconst safeJsonParse = (data: string | null): Record<string, any> | null => {
if (!data) returnnulltry {
returnJSON.parse(data)
} catch (error) {
logger.error({ error, body: data }, 'Failed to parse request body as JSON')
returnnull
}
}
exportconsthandler = async (event: APIGatewayEvent, _context: Context) => {
logger.info('Vonage webhook received')
// 1. Verify Signature (Security)const signatureSecret = process.env.VONAGE_WEBHOOK_SECRETif (!signatureSecret) {
logger.error('VONAGE_WEBHOOK_SECRET not configured. Cannot verify signature.')
return { statusCode: 500, body: 'Internal Server Error: Webhook secret missing' }
}
const vonageSignature = newSignature(
{ apiSecret: signatureSecret }, // Use the webhook signature secret here
{ algorithm: 'sha256', header: 'X-Vonage-Signature' } // Vonage uses sha256 HMAC
)
const headers = Object.entries(event.headers).reduce((acc, [key, value]) => {
acc[key.toLowerCase()] = value // Ensure lowercase header keysreturn acc
}, {})
const body = safeJsonParse(event.body)
if (!body) {
logger.warn('Received empty or invalid JSON body')
return { statusCode: 400, body: 'Bad Request: Invalid JSON body' }
}
// IMPORTANT: Vonage signature check uses the raw body string before parsingconst isSignatureValid = vonageSignature.checkSignature(event.body, headers)
if (!isSignatureValid) {
logger.warn('Invalid Vonage signature received.')
// In production, implement stricter checks or logging here// For testing, headers/body might differ slightly when passing through proxies// Log details to debug if needed: logger.debug({ headers, body: event.body }, 'Signature validation details');// If validation consistently fails, double-check your VONAGE_WEBHOOK_SECRET and how ngrok/proxies handle headers/body.return { statusCode: 401, body: 'Unauthorized: Invalid signature' }
// !! Commenting out the return above is ONLY for debugging signature issues and should never happen in production.// logger.warn('!!! Proceeding with invalid signature (DEBUG ONLY) !!!'); // Keep this commented out
} else {
logger.info('Vonage signature verified successfully.')
}
// 2. Determine Webhook Type (Inbound Message or Status Update)// This check is basic; Vonage payloads can vary. Adapt as needed.if (body.message_uuid && body.status) {
// Likely a Status Webhook
logger.info({ data: body }, 'Processing Status Webhook')
try {
awaitupdateMessageStatus({
vonageMessageId: body.message_uuid,
status: body.status,
timestamp: body.timestamp ? newDate(body.timestamp) : newDate(),
errorCode: body.error?.code, // Pass error code if presenterrorReason: body.error?.reason, // Pass error reason if present
})
} catch (error) {
logger.error({ error, data: body }, 'Error processing status webhook')
// Don't fail the webhook response for DB errors if possible
}
} elseif (body.message_uuid && body.from && body.to && body.message?.content?.type === 'text') {
// Likely an Inbound Text Message Webhook
logger.info({ data: body }, 'Processing Inbound Message Webhook')
try {
awaitcreateMessage({
vonageMessageId: body.message_uuid,
direction: 'inbound',
channel: body.channel || 'unknown', // e.g., 'sms', 'whatsapp'fromNumber: body.from.number || body.from.id, // Structure varies by channeltoNumber: body.to.number || body.to.id,
body: body.message.content.text,
timestamp: body.timestamp ? newDate(body.timestamp) : newDate(),
status: 'delivered', // Inbound messages are inherently delivered to us
})
// --- Optional: Send an automated reply ---// Uncomment the block below to enable simple auto-reply/*
try {
const replyText = `Thanks for your message! We received: "${body.message.content.text}"`;
await sendVonageMessage({
to: body.from.number || body.from.id, // Reply to the sender
from: body.to.number || body.to.id, // Send from the number that received the message
text: replyText,
channel: body.channel || 'sms' // Use the same channel if possible
});
logger.info(`Sent auto-reply to ${body.from.number || body.from.id}`);
} catch (replyError) {
logger.error({ error: replyError }, 'Failed to send auto-reply');
}
*/// --- End Optional Reply ---
} catch (error) {
logger.error({ error, data: body }, 'Error processing inbound message webhook')
// Don't fail the webhook response for DB errors if possible
}
} else {
logger.warn({ data: body }, 'Received unrecognized Vonage webhook format')
}
// 3. Always Respond with 200 OK// Vonage expects a quick confirmation. Process data asynchronously if needed.return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'Webhook received successfully' }),
}
}
Explanation:
Signature Verification: Critical for security. Ensures the request genuinely came from Vonage and hasn't been tampered with. Uses VONAGE_WEBHOOK_SECRET (Signature Secret from the Vonage Application settings) and the @vonage/server-sdk's Signature class. Important: The signature check requires the raw, unparsed request body string and the request headers.
Webhook Type Detection: Differentiates between inbound messages and status updates based on common payload properties (message_uuid, status, message.content.type). Refine this based on the specific Vonage channels and events you handle.
Service Calls: Delegates database operations (createMessage, updateMessageStatus) and sending replies (sendVonageMessage) to Redwood Services (created next). This keeps the function focused on handling the HTTP request/response lifecycle.
Error Handling: Basic try...catch blocks log errors using Redwood's built-in logger. For production, implement more robust error tracking. Error details from status webhooks pass to the service.
200 OK Response: Vonage requires a 200 OK response within specific timeouts to acknowledge receipt. The timeout for establishing the HTTP connection is 3 seconds, and the timeout for receiving a response once the connection establishes is 15 seconds (source). Failure to respond within these timeouts causes Vonage to retry the webhook. Vonage retries webhooks that respond with HTTP 429 or 5xx codes using exponential backoff starting at 5 seconds, doubling with each retry up to 15 minutes maximum, for up to 24 hours before discarding them (source). Handle time-consuming processing after sending the response or asynchronously.
Optional Auto-Reply: The commented-out section shows where to trigger an outbound message using the imported sendVonageMessage service function.
5. Implementing RedwoodJS Services for Two-Way Messaging
Services encapsulate business logic, including database interactions and calls to external APIs like Vonage. This section implements the two-way messaging functionality.
Generate the Service:
Bash
yarn rw g service message
This creates api/src/services/messages/messages.ts and related test/scenario files.
Implement Service Logic:
Open api/src/services/messages/messages.ts and add the necessary functions:
TypeScript
// api/src/services/messages/messages.tsimporttype { Prisma } from'@prisma/client'import { db } from'src/lib/db'import { logger } from'src/lib/logger'import { Vonage } from'@vonage/server-sdk'// Import Vonage SDKimport { Auth } from'@vonage/auth'// Import Auth for credentials// Type definition for message data (can be refined)interfaceMessageInputextendsPrisma.MessageCreateInput {
// Add any specific types if needed, Prisma.MessageCreateInput is quite broad
}
interfaceStatusUpdateInput {
vonageMessageId: stringstatus: stringtimestamp: DateerrorCode?: string | number// Vonage error codes can be numberserrorReason?: string
}
interfaceSendMessageInput {
to: stringfrom: stringtext: stringchannel?: 'sms' | 'whatsapp' | string// Allow specific channels or general string
}
// Initialize Vonage Client (only once)letvonage: Vonage | null = nulltry {
const credentials = newAuth({
apiKey: process.env.VONAGE_API_KEY,
apiSecret: process.env.VONAGE_API_SECRET,
// Add application details if using JWT or specific features// applicationId: process.env.VONAGE_APPLICATION_ID,// privateKey: process.env.VONAGE_PRIVATE_KEY_PATH, // Or handle content from env var
})
vonage = newVonage(credentials)
logger.info('Vonage SDK initialized successfully.')
} catch (error) {
logger.error({ error }, 'Failed to initialize Vonage SDK. Check API Key/Secret.')
// Depending on your app's needs, you might throw here or handle later
}
/**
* Creates a new message record in the database.
*/exportconstcreateMessage = async (input: MessageInput) => {
logger.debug({ custom: input }, 'Creating message record')
try {
returnawait db.message.create({
data: input,
})
} catch (error) {
logger.error({ error, custom: input }, 'Error creating message in DB')
// Rethrow or handle as appropriate for your applicationthrow error
}
}
/**
* Updates the status of an existing message based on Vonage status webhook.
*/exportconstupdateMessageStatus = async ({
vonageMessageId,
status,
timestamp,
errorCode,
errorReason,
}: StatusUpdateInput) => {
logger.debug({ vonageMessageId, status, errorCode }, 'Updating message status')
try {
returnawait db.message.update({
where: { vonageMessageId },
data: {
status,
errorCode: errorCode ? String(errorCode) : null, // Ensure errorCode stores as stringerrorReason: errorReason,
updatedAt: timestamp, // Use timestamp from Vonage if available
},
})
} catch (error) {
// Handle cases where the message might not exist (e.g., race condition)if (error instanceofPrisma.PrismaClientKnownRequestError && error.code === 'P2025') {
logger.warn({ vonageMessageId }, 'Message not found for status update (P2025: Record not found).');
returnnull; // Or handle differently
}
logger.error({ error, vonageMessageId, status }, 'Error updating message status in DB')
// Rethrow or handlethrow error
}
}
/**
* Sends an outbound message using the Vonage Messages API.
*/exportconstsendVonageMessage = async ({ to, from, text, channel = 'sms' }: SendMessageInput) => {
if (!vonage) {
logger.error('Vonage SDK not initialized. Cannot send message.');
thrownewError('Vonage service unavailable');
}
logger.debug({ to, from, channel }, 'Attempting to send Vonage message');
try {
// Use the generic Messages API endpointconst resp = await vonage.messages.send({
message_type: "text", // Standard string quotes for message typetext: text,
to: to,
from: from, // This should be your Vonage numberchannel: channel, // 'sms' or 'whatsapp', etc.
})
logger.info({ response: resp }, `Message sent via Vonage to ${to}. Message UUID: ${resp.messageUuid}`);
// Optionally, immediately create an 'outbound' message record in your DBawaitcreateMessage({
vonageMessageId: resp.messageUuid,
direction: 'outbound',
channel: channel,
fromNumber: from,
toNumber: to,
body: text,
status: 'submitted', // Initial status – Vonage will send 'delivered', 'failed', etc. latertimestamp: newDate(),
});
return resp; // Contains message_uuid
} catch (error) {
// Log detailed error from Vonage if availableconst vonageError = error?.response?.data || error?.message || error;
logger.error({ error: vonageError, to, from }, 'Failed to send message via Vonage');
// Common Vonage error codes (see https://developer.vonage.com/en/api-errors/messages):// 1020: Invalid params// 1170: Invalid or Missing Msisdn Param (invalid phone number)// 1320: Message already sent// 1360: TTL expired// 1380: Invalid resource// 1420: Invalid sender (from parameter)// 1430: Invalid recipient (to parameter)// Rethrow or handle error appropriatelythrow error;
}
}
Explanation:
Vonage SDK Initialization: Initialize the Vonage client using credentials from .env. This happens only once. Error handling includes cases where credentials are missing or invalid.
createMessage: Straightforward function using db.message.create to save message data.
updateMessageStatus: Uses db.message.update to find a message by its unique vonageMessageId and update its status, errorCode, and errorReason. Includes basic handling for cases where the message might not exist (e.g., if the status webhook arrives before the inbound message webhook processes fully). Ensures errorCode stores as a string.
sendVonageMessage:
Checks if the Vonage SDK initialized.
Uses vonage.messages.send() which is the unified endpoint for various channels.
Specifies message_type: "text", to, from (your Vonage number), text, and channel.
Logs the response from Vonage, which includes the messageUuid.
Optionally creates a corresponding outbound record in the database immediately with status 'submitted'. The actual delivery status arrives later via the Status webhook.
Includes improved error logging for Vonage API failures with references to common error codes.
6. Local Development & Testing with ngrok
Expose your Redwood development server to the public internet using ngrok to test your inbound SMS webhook locally.
Start Redwood Dev Server:
Open a terminal in your project root and run:
Bash
yarn rw dev
Note the API server port (usually 8911).
Start ngrok:
Open a second terminal and run ngrok, pointing it to your Redwood API server's port:
Bash
ngrok http 8911
ngrok displays forwarding URLs (e.g., https://<random-subdomain>.ngrok-free.app). Copy the https URL.
The full URL for Vonage is this ngrok HTTPS URL plus the path to your function: /api/vonageWebhook.
Update Vonage Webhook URLs:
Return to your Vonage Application settings in the dashboard.
Paste the full ngrok HTTPS URL including the path into both the Inbound URL and Status URL fields. The final URL should look like: https://<random-subdomain>.ngrok-free.app/api/vonageWebhook.
Click Save changes.
Send a Test Message:
Using your mobile phone, send an SMS (or WhatsApp message, if configured) to your Vonage virtual number.
Verify:
ngrok Console: Check the ngrok terminal window for a POST /api/vonageWebhook request with a 200 OK response.
Redwood Console: Check the terminal running yarn rw dev for logs from the vonageWebhook function:
"Vonage webhook received"
"Vonage signature verified successfully."
"Processing Inbound Message Webhook"
Logs from the createMessage service.
If auto-reply is enabled: "Attempting to send Vonage message", "Message sent via Vonage…", logs from createMessage for the outbound record.
Database: Check your Message table (e.g., using yarn rw prisma studio) for a new record for the inbound message (and potentially an outbound one if reply is enabled).
Your Phone: If auto-reply is enabled, receive the reply message back on your phone.
Troubleshooting ngrok:
If requests don't arrive, double-check the ngrok URL in Vonage (HTTPS, correct path /api/vonageWebhook).
Ensure ngrok is still running. Free ngrok sessions time out.
Check that firewalls aren't blocking ngrok.
If signature validation fails, log the headers and raw body in your function (logger.debug) and compare them carefully with what Vonage expects. Ensure VONAGE_WEBHOOK_SECRET is correct. Sometimes proxies (including ngrok under certain configurations) can subtly alter headers or body encoding.
7. Security Considerations for Webhook Signature Validation
Webhook Signature Validation: Paramount. Always verify the signature using VONAGE_WEBHOOK_SECRET as shown in the function handler. Never disable this check in production. Vonage uses SHA-256 HMAC for signature generation.
Environment Variables: Keep API keys, secrets, and signature secrets out of your codebase. Use the .env file and ensure it's in your .gitignore. Use your deployment platform's secret management for production.
Input Sanitization: While less critical for simply storing/relaying messages, sanitize message content appropriately if you use it elsewhere (displaying in UI, further processing) to prevent XSS or other injection attacks. Prisma helps prevent SQL injection at the database layer.
Rate Limiting: Your webhook endpoint is publicly accessible. Consider adding rate limiting (e.g., using middleware or platform features) to prevent abuse, although Vonage itself has rate limits.
HTTPS Required: Vonage webhooks require HTTPS endpoints. Use ngrok for local development and ensure production deployments use HTTPS.
8. Error Handling and Logging
Consistent Logging: Use Redwood's built-in logger (import { logger } from 'src/lib/logger'). Log key events (webhook received, signature verified, message processed/sent) and errors. Use appropriate log levels (info, warn, error, debug).
Vonage API Errors: The Vonage SDK throws errors for API failures. Catch these errors in your service functions (sendVonageMessage) and log relevant details (often found in error.response.data or error.message). Refer to Vonage Messages API error codes for detailed descriptions.
Database Errors: Catch errors during Prisma operations (createMessage, updateMessageStatus). Log details and decide how to respond (e.g., retry, notify admin, or ignore if non-critical like a duplicate status update).
Status Webhooks: Utilize the status webhook to track delivery success or failure of outbound messages. Update your database accordingly, including errorCode and errorReason. Implement logic to handle specific error codes from Vonage if needed (e.g., number blocked, invalid number).
Retry Mechanisms: For transient errors (e.g., temporary network issues when calling Vonage API), implement a simple retry strategy with exponential backoff within your service function, potentially using a library like async-retry. However, avoid blocking the webhook response. If sending fails, log it and potentially queue it for later retry via a background job system (like RedwoodJS Background Jobs).
Deploy your RedwoodJS inbound SMS webhook application following these key steps for Vonage integration:
Choose a Platform: Select a deployment provider supporting Node.js applications (e.g., Vercel, Netlify, Render, Fly.io, AWS Lambda). RedwoodJS provides deployment guides for popular platforms.
Function URL Pattern: After deployment, access your webhook function at https://your-domain.com/.redwood/functions/vonageWebhook (or /api/vonageWebhook depending on your deployment configuration). Update your Vonage Application's Inbound URL and Status URL with this production URL.
Configure Environment Variables: Set these environment variables securely in your deployment platform's settings:
DATABASE_URL (pointing to your production database)
VONAGE_API_KEY
VONAGE_API_SECRET
VONAGE_WEBHOOK_SECRET
VONAGE_APPLICATION_ID
VONAGE_NUMBER
VONAGE_PRIVATE_KEY_PATH (if using JWT/Secure Media): For production, store the content of the private key directly in a secure environment variable (e.g., VONAGE_PRIVATE_KEY_CONTENT base64 encoded) instead of deploying the key file. This avoids file system complexities and enhances security. Adjust your SDK initialization logic (Section 5.2) to read the key content from the environment variable if the path variable isn't set. Example:
TypeScript
// In services/messages/messages.tsconst credentials = newAuth({
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
? Buffer.from(process.env.VONAGE_PRIVATE_KEY_CONTENT, 'base64')
: process.env.VONAGE_PRIVATE_KEY_PATH, // Fallback to file path for local dev
})
Redwood's standard environment variables (like SESSION_SECRET if using auth). Check RedwoodJS deployment docs for specifics.
Run Tests and Verification:
Ensure production webhooks function correctly by:
Monitoring logs for webhook events and errors.
Verifying database records create correctly for inbound messages.
Testing that status updates reflect in your database.
Confirming outbound messages send successfully if reply functionality is enabled.
Production Monitoring:
Set up monitoring and alerting for:
Vonage API errors (rate limits, authentication issues).
Database connection failures.
Use tools like Sentry, LogRocket, or your hosting platform's monitoring features.
Frequently Asked Questions
How do I receive inbound SMS messages with Vonage and RedwoodJS?
Create a RedwoodJS serverless function to handle webhook POST requests from Vonage. Configure your Vonage Application's Inbound URL to point to your function endpoint (e.g., https://your-domain.com/.redwood/functions/vonageWebhook). The function validates the signature, parses the message payload, and stores it in your Prisma database.
What is webhook signature validation and why is it critical?
Webhook signature validation verifies that incoming webhook requests genuinely came from Vonage and haven't been tampered with. Vonage signs each webhook using SHA-256 HMAC with your Signature Secret. Always validate signatures using the @vonage/server-sdk's Signature class. Never disable this check in production – it prevents unauthorized access and malicious payloads.
What's the difference between Vonage API Secret and Signature Secret?
The API Secret (VONAGE_API_SECRET) authenticates your API calls when sending messages to Vonage. The Signature Secret (VONAGE_WEBHOOK_SECRET) validates incoming webhooks from Vonage to your application. These are completely different values – do not confuse them. Generate the Signature Secret in your Vonage Application settings under "Signed webhooks."
How do I implement two-way SMS messaging with Vonage?
Store the sender's number from inbound webhooks and use the sendVonageMessage service function to reply. Set the sender's number as the to parameter and your Vonage number as from. The webhook payload includes a channel field – use the same channel (SMS or WhatsApp) for replies to ensure proper delivery.
How do I handle both SMS and WhatsApp messages in the same webhook?
The webhook payload includes a channel field indicating the message type (sms, whatsapp, etc.). Store this in your database and use it when sending replies. The webhook handler code differentiates message types automatically. For WhatsApp, ensure your Vonage number has WhatsApp Business API enabled.
What Node.js version does RedwoodJS v7 require?
RedwoodJS v7+ requires exactly Node.js 20.x (not 18.x or 19.x). Use node -v to verify your version. Install Node 20.x via nodejs.org or use a version manager like nvm. Additionally, use Yarn ≥1.22.21 (Classic Yarn 1.x).
How do I test Vonage webhooks locally during development?
Use ngrok to expose your local RedwoodJS API server (port 8911) to the internet with HTTPS. Run ngrok http 8911 to get a public HTTPS URL. Configure this URL plus /api/vonageWebhook in your Vonage Application's Inbound URL and Status URL settings. Send a test SMS to your Vonage number and verify the webhook arrives in your local logs.
What database indexes should I create for message storage?
Create these indexes in your Prisma schema: 1) @@index([vonageMessageId]) for fast UUID lookups (though @unique already creates an index), 2) @@index([direction, createdAt]) for filtering by inbound/outbound and sorting by timestamp. These optimize common query patterns for message retrieval and status updates.
How do I send automatic replies to inbound messages?
In your webhook handler function, after storing the inbound message, call the sendVonageMessage service function with the sender's number as the to parameter and your Vonage number as the from parameter. Use the same channel (SMS or WhatsApp) for the reply. The example code includes a commented-out auto-reply section you can enable.
What are Vonage's webhook timeout and retry policies?
Vonage requires a 200 OK response within 3 seconds for connection establishment and 15 seconds for the actual response. If your endpoint responds with HTTP 429 or 5xx codes, Vonage retries using exponential backoff starting at 5 seconds, doubling with each retry up to 15 minutes maximum, continuing for up to 24 hours before discarding the message. Process time-consuming operations asynchronously after sending the 200 response to avoid timeouts.
How do I handle Vonage error codes for failed messages?
Status webhooks include errorCode and errorReason fields when messages fail. Store these in your database via the updateMessageStatus service. Common error codes: 1020 (invalid params), 1170 (invalid/missing phone number), 1320 (message already sent), 1360 (TTL expired), 1380 (invalid resource), 1420 (invalid sender), 1430 (invalid recipient). Implement logic to handle specific codes as needed. See the complete list at Vonage Messages API errors.
Should I store the private key file in production deployments?
No. Store the private key content in an environment variable (base64 encoded) instead of deploying the file. Example: VONAGE_PRIVATE_KEY_CONTENT. Read it with Buffer.from(process.env.VONAGE_PRIVATE_KEY_CONTENT, 'base64') in your Auth initialization. This avoids file system complexities and improves security across deployment platforms.
What's the Prisma error code P2025 in status update failures?
P2025 means "Record not found." This occurs when a status webhook arrives before the corresponding inbound message webhook processes (race condition). Handle this gracefully by logging a warning and returning null rather than throwing an error. The status update will succeed when retried or can be ignored if the message doesn't exist in your database yet.
Next Steps
You now have a fully functional RedwoodJS inbound SMS webhook application handling two-way messaging with Vonage. Consider these enhancements:
User Interface: Build a web UI using RedwoodJS cells and pages to display message history
Authentication: Add RedwoodJS authentication to secure your message management interface
Advanced Routing: Implement message routing logic based on keywords, sender, or time of day
Background Jobs: Use RedwoodJS Background Jobs for retry logic and asynchronous processing
Multi-Channel: Expand to handle MMS, Facebook Messenger, or Viber messages
Analytics: Track message volumes, response times, and delivery rates
Receive SMS messages by configuring a Vonage virtual number to send webhooks to your RedwoodJS application. Set up a webhook endpoint in your RedwoodJS app using a serverless function and expose it to the internet using a tool like ngrok during development. This endpoint will receive inbound message data from Vonage whenever an SMS is sent to your virtual number. Make sure to verify Vonage's webhook signatures for security and store the message data using Prisma.
The Vonage Messages API provides a unified way to send and receive messages across various channels, including SMS, MMS, WhatsApp, and Facebook Messenger. It simplifies the process of integrating messaging into your application by handling the complexities of different platforms.
Vonage uses webhook signatures to ensure the authenticity and integrity of incoming webhook requests. This cryptographic signature, generated using a shared secret, allows your application to verify that the request originated from Vonage and hasn't been tampered with, enhancing security.
Use ngrok during local development to create a secure tunnel that exposes your locally running RedwoodJS server to the public internet. This allows Vonage to deliver webhooks to your application for testing even though it's not yet deployed to a production environment.
Yes, you can send and receive WhatsApp messages using RedwoodJS with the Vonage Messages API. After setting up your Vonage account and linking a WhatsApp-enabled number, you can use the Vonage Node.js Server SDK within your RedwoodJS services to send and receive messages via the API's unified endpoint.
Set up Vonage webhooks by creating a serverless function in your RedwoodJS application. This function will act as the webhook endpoint, receiving inbound and status update messages. Configure your Vonage application's inbound and status URLs to point to this function's publicly accessible URL, usually via ngrok during development.
Prisma serves as the Object-Relational Mapper (ORM) in your RedwoodJS application, facilitating interactions with your database. When integrated with Vonage, Prisma allows you to efficiently store details about incoming and outgoing messages, including sender, recipient, content, timestamps, and message status.
Validate Vonage webhook signatures by using the @vonage/server-sdk's Signature class and your Vonage webhook secret. Compare the signature in the 'X-Vonage-Signature' header with the one generated using your secret and the raw request body. This step is crucial for ensuring the request genuinely comes from Vonage.
Vonage sends status updates for each message, indicating delivery success, failure, or other events. Your RedwoodJS application should handle these status updates by implementing a webhook endpoint that receives these updates and logs the status. You should save these status updates to your database using a dedicated service function.
Handle Vonage API errors within your RedwoodJS services, specifically when making calls to the Vonage API using the SDK. Implement robust error handling using try-catch blocks to catch potential errors during sending messages or other API interactions. Log the error details and implement appropriate retry mechanisms or user notifications.
The RedwoodJS service responsible for sending Vonage messages should be named 'messages' and be located at 'api/src/services/messages/messages.ts'. This service should contain a function that utilizes the Vonage Node.js Server SDK to interact with the Vonage Messages API, allowing your application to send outbound messages.
Store Vonage messages by creating a 'Message' model in your Prisma schema ('api/db/schema.prisma'). Define fields to capture essential message details like ID, direction, channel, sender, recipient, content, status, timestamps, and potential error codes. Use RedwoodJS services to interact with Prisma and perform create, read, update, and delete operations on message records.
Test Vonage webhooks locally by using ngrok to expose your RedwoodJS development server. Configure ngrok to forward requests to your webhook endpoint. Update your Vonage application settings to use the ngrok URL as your webhook URL. Send a test message to your Vonage number and observe logs in ngrok, your RedwoodJS console, and your database to verify functionality.
Using TypeScript in your RedwoodJS project when integrating Vonage enhances type safety and code maintainability. RedwoodJS supports TypeScript out-of-the-box, allowing you to define types for message data and API responses, which helps catch errors during development and improves overall code quality.
Learn how to build a production-ready RedwoodJS inbound SMS webhook that receives SMS and WhatsApp messages via the Vonage Messages API. This comprehensive guide covers webhook setup, signature validation, database integration, and two-way messaging implementation for customer engagement applications.
What You'll Build:
Target Audience: JavaScript and Node.js developers building messaging applications. RedwoodJS experience helps but isn't required – this guide covers setup from scratch. Assumes familiarity with basic REST API concepts and webhook patterns.
Technology Stack:
ngrok: Expose local development servers to the internet for webhook testing (development only, not deployed)System Architecture:
Prerequisites:
npm install -g redwoodjs-cli. This guide assumes RedwoodJS v7.0.0 or greater. Note: While you install the RedwoodJS CLI globally using npm, project-level commands and dependency management use Yarn, following standard RedwoodJS conventions.ngrok: Download and install from ngrok.com. Authenticate your client for longer sessions. Important: Vonage webhooks require HTTPS; ngrok provides this automatically.1. Setting Up the RedwoodJS Project
Create a new RedwoodJS application with TypeScript for enhanced type safety.
Create the Redwood App: Open your terminal and run:
This scaffolds a new RedwoodJS project in
vonage-redwoodwith TypeScript enabled.Navigate into the Project:
cd vonage-redwoodInitialize Environment Variables: RedwoodJS uses
.envfor environment variables. Create one in the project root:touch .envPopulate this file later with Vonage credentials. Redwood automatically loads variables from
.envintoprocess.env.Install Vonage SDK: Install the Vonage Node.js Server SDK to interact with the API and validate webhooks in the
apiworkspace:@vonage/server-sdk: The main SDK for API calls (v3.25.1 as of September 2024; verify current version at npm).@vonage/jwt: Required for generating JWTs for features like Secure Inbound Media or JWT authentication for API calls (basic key/secret auth suffices for sending messages).Verify Setup: Start the development server to ensure the basic Redwood app works:
You'll see output indicating both frontend (web) and backend (api) servers running on
http://localhost:8910andhttp://localhost:8911respectively. Stop the server for now (Ctrl+C).2. Configuring Vonage Webhook Settings
Configure Vonage to send webhook events to your RedwoodJS application before writing code. This section covers essential webhook signature validation setup for secure inbound message handling.
Log in to Vonage Dashboard: Access your Vonage API Dashboard.
Retrieve API Credentials:
.envfile:YOUR_API_KEYandYOUR_API_SECRETwith your actual credentials)Create a Vonage Application: Vonage Applications group settings like webhook URLs and capabilities.
ngrok..envfile:YOUR_SIGNATURE_SECRETwith the generated secret).envfile:YOUR_APPLICATION_IDwith your actual Application ID)private.keyfile and save it securely (e.g., in your project root, ensuring you add it to.gitignore). Add its path to.env:Link Your Vonage Number:
.env:YOUR_VONAGE_NUMBERwith your linked number)Configure Webhooks (Requires
ngrok): Configure the actual webhook URLs in the "Local Development & Testing" section once you have your Redwood function endpoint and anngroktunnel running.3. Creating the Database Schema with Prisma
Define a Prisma model to store received and sent messages for your inbound SMS webhook application.
Define the
MessageModel: Openapi/db/schema.prismaand add the following model:vonageMessageId: Crucial for correlating messages and status updates.direction: Distinguishes between incoming and outgoing messages.errorCode,errorReason: Store failure details from status webhooks (see Vonage error codes).@updatedAt: Prisma directive that automatically sets this field to the current timestamp on every update.@@index: Performance optimization for common query patterns. ThevonageMessageIdalready has a unique constraint which creates an index, but explicit indexes ondirectionandcreatedAtimprove filtering performance.Apply Migrations: Run the Prisma migrate command to create/update the database schema and generate the Prisma client.
Follow the prompts (e.g., provide a name for the migration like "add message model"). This creates/updates the
Messagetable in your development database (SQLite by default, unless you configured otherwise).4. Implementing the RedwoodJS Webhook Handler
Create the core webhook handler component that receives inbound SMS and WhatsApp messages from Vonage. Build a RedwoodJS Function – a serverless function deployed alongside your API.
Generate the Function: Use the Redwood CLI to generate a new function called
vonageWebhook.yarn rw g function vonageWebhook --no-graphqlThis creates
api/src/functions/vonageWebhook.ts. The--no-graphqlflag indicates it's a standard HTTP endpoint, not part of the GraphQL API.Implement the Handler Logic: Open
api/src/functions/vonageWebhook.tsand replace its contents with the following:// api/src/functions/vonageWebhook.ts import type { APIGatewayEvent, Context } from 'aws-lambda' import { logger } from 'src/lib/logger' import { db } from 'src/lib/db' // Redwood's Prisma client instance import { Signature } from '@vonage/server-sdk' import { createMessage, updateMessageStatus, sendVonageMessage } from 'src/services/messages/messages' // Service functions // Helper function to safely parse JSON const safeJsonParse = (data: string | null): Record<string, any> | null => { if (!data) return null try { return JSON.parse(data) } catch (error) { logger.error({ error, body: data }, 'Failed to parse request body as JSON') return null } } export const handler = async (event: APIGatewayEvent, _context: Context) => { logger.info('Vonage webhook received') // 1. Verify Signature (Security) const signatureSecret = process.env.VONAGE_WEBHOOK_SECRET if (!signatureSecret) { logger.error('VONAGE_WEBHOOK_SECRET not configured. Cannot verify signature.') return { statusCode: 500, body: 'Internal Server Error: Webhook secret missing' } } const vonageSignature = new Signature( { apiSecret: signatureSecret }, // Use the webhook signature secret here { algorithm: 'sha256', header: 'X-Vonage-Signature' } // Vonage uses sha256 HMAC ) const headers = Object.entries(event.headers).reduce((acc, [key, value]) => { acc[key.toLowerCase()] = value // Ensure lowercase header keys return acc }, {}) const body = safeJsonParse(event.body) if (!body) { logger.warn('Received empty or invalid JSON body') return { statusCode: 400, body: 'Bad Request: Invalid JSON body' } } // IMPORTANT: Vonage signature check uses the raw body string before parsing const isSignatureValid = vonageSignature.checkSignature(event.body, headers) if (!isSignatureValid) { logger.warn('Invalid Vonage signature received.') // In production, implement stricter checks or logging here // For testing, headers/body might differ slightly when passing through proxies // Log details to debug if needed: logger.debug({ headers, body: event.body }, 'Signature validation details'); // If validation consistently fails, double-check your VONAGE_WEBHOOK_SECRET and how ngrok/proxies handle headers/body. return { statusCode: 401, body: 'Unauthorized: Invalid signature' } // !! Commenting out the return above is ONLY for debugging signature issues and should never happen in production. // logger.warn('!!! Proceeding with invalid signature (DEBUG ONLY) !!!'); // Keep this commented out } else { logger.info('Vonage signature verified successfully.') } // 2. Determine Webhook Type (Inbound Message or Status Update) // This check is basic; Vonage payloads can vary. Adapt as needed. if (body.message_uuid && body.status) { // Likely a Status Webhook logger.info({ data: body }, 'Processing Status Webhook') try { await updateMessageStatus({ vonageMessageId: body.message_uuid, status: body.status, timestamp: body.timestamp ? new Date(body.timestamp) : new Date(), errorCode: body.error?.code, // Pass error code if present errorReason: body.error?.reason, // Pass error reason if present }) } catch (error) { logger.error({ error, data: body }, 'Error processing status webhook') // Don't fail the webhook response for DB errors if possible } } else if (body.message_uuid && body.from && body.to && body.message?.content?.type === 'text') { // Likely an Inbound Text Message Webhook logger.info({ data: body }, 'Processing Inbound Message Webhook') try { await createMessage({ vonageMessageId: body.message_uuid, direction: 'inbound', channel: body.channel || 'unknown', // e.g., 'sms', 'whatsapp' fromNumber: body.from.number || body.from.id, // Structure varies by channel toNumber: body.to.number || body.to.id, body: body.message.content.text, timestamp: body.timestamp ? new Date(body.timestamp) : new Date(), status: 'delivered', // Inbound messages are inherently delivered to us }) // --- Optional: Send an automated reply --- // Uncomment the block below to enable simple auto-reply /* try { const replyText = `Thanks for your message! We received: "${body.message.content.text}"`; await sendVonageMessage({ to: body.from.number || body.from.id, // Reply to the sender from: body.to.number || body.to.id, // Send from the number that received the message text: replyText, channel: body.channel || 'sms' // Use the same channel if possible }); logger.info(`Sent auto-reply to ${body.from.number || body.from.id}`); } catch (replyError) { logger.error({ error: replyError }, 'Failed to send auto-reply'); } */ // --- End Optional Reply --- } catch (error) { logger.error({ error, data: body }, 'Error processing inbound message webhook') // Don't fail the webhook response for DB errors if possible } } else { logger.warn({ data: body }, 'Received unrecognized Vonage webhook format') } // 3. Always Respond with 200 OK // Vonage expects a quick confirmation. Process data asynchronously if needed. return { statusCode: 200, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'Webhook received successfully' }), } }Explanation:
VONAGE_WEBHOOK_SECRET(Signature Secret from the Vonage Application settings) and the@vonage/server-sdk'sSignatureclass. Important: The signature check requires the raw, unparsed request body string and the request headers.message_uuid,status,message.content.type). Refine this based on the specific Vonage channels and events you handle.createMessage,updateMessageStatus) and sending replies (sendVonageMessage) to Redwood Services (created next). This keeps the function focused on handling the HTTP request/response lifecycle.try...catchblocks log errors using Redwood's built-inlogger. For production, implement more robust error tracking. Error details from status webhooks pass to the service.200 OKresponse within specific timeouts to acknowledge receipt. The timeout for establishing the HTTP connection is 3 seconds, and the timeout for receiving a response once the connection establishes is 15 seconds (source). Failure to respond within these timeouts causes Vonage to retry the webhook. Vonage retries webhooks that respond with HTTP 429 or 5xx codes using exponential backoff starting at 5 seconds, doubling with each retry up to 15 minutes maximum, for up to 24 hours before discarding them (source). Handle time-consuming processing after sending the response or asynchronously.sendVonageMessageservice function.5. Implementing RedwoodJS Services for Two-Way Messaging
Services encapsulate business logic, including database interactions and calls to external APIs like Vonage. This section implements the two-way messaging functionality.
Generate the Service:
This creates
api/src/services/messages/messages.tsand related test/scenario files.Implement Service Logic: Open
api/src/services/messages/messages.tsand add the necessary functions:// api/src/services/messages/messages.ts import type { Prisma } from '@prisma/client' import { db } from 'src/lib/db' import { logger } from 'src/lib/logger' import { Vonage } from '@vonage/server-sdk' // Import Vonage SDK import { Auth } from '@vonage/auth' // Import Auth for credentials // Type definition for message data (can be refined) interface MessageInput extends Prisma.MessageCreateInput { // Add any specific types if needed, Prisma.MessageCreateInput is quite broad } interface StatusUpdateInput { vonageMessageId: string status: string timestamp: Date errorCode?: string | number // Vonage error codes can be numbers errorReason?: string } interface SendMessageInput { to: string from: string text: string channel?: 'sms' | 'whatsapp' | string // Allow specific channels or general string } // Initialize Vonage Client (only once) let vonage: Vonage | null = null try { const credentials = new Auth({ apiKey: process.env.VONAGE_API_KEY, apiSecret: process.env.VONAGE_API_SECRET, // Add application details if using JWT or specific features // applicationId: process.env.VONAGE_APPLICATION_ID, // privateKey: process.env.VONAGE_PRIVATE_KEY_PATH, // Or handle content from env var }) vonage = new Vonage(credentials) logger.info('Vonage SDK initialized successfully.') } catch (error) { logger.error({ error }, 'Failed to initialize Vonage SDK. Check API Key/Secret.') // Depending on your app's needs, you might throw here or handle later } /** * Creates a new message record in the database. */ export const createMessage = async (input: MessageInput) => { logger.debug({ custom: input }, 'Creating message record') try { return await db.message.create({ data: input, }) } catch (error) { logger.error({ error, custom: input }, 'Error creating message in DB') // Rethrow or handle as appropriate for your application throw error } } /** * Updates the status of an existing message based on Vonage status webhook. */ export const updateMessageStatus = async ({ vonageMessageId, status, timestamp, errorCode, errorReason, }: StatusUpdateInput) => { logger.debug({ vonageMessageId, status, errorCode }, 'Updating message status') try { return await db.message.update({ where: { vonageMessageId }, data: { status, errorCode: errorCode ? String(errorCode) : null, // Ensure errorCode stores as string errorReason: errorReason, updatedAt: timestamp, // Use timestamp from Vonage if available }, }) } catch (error) { // Handle cases where the message might not exist (e.g., race condition) if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') { logger.warn({ vonageMessageId }, 'Message not found for status update (P2025: Record not found).'); return null; // Or handle differently } logger.error({ error, vonageMessageId, status }, 'Error updating message status in DB') // Rethrow or handle throw error } } /** * Sends an outbound message using the Vonage Messages API. */ export const sendVonageMessage = async ({ to, from, text, channel = 'sms' }: SendMessageInput) => { if (!vonage) { logger.error('Vonage SDK not initialized. Cannot send message.'); throw new Error('Vonage service unavailable'); } logger.debug({ to, from, channel }, 'Attempting to send Vonage message'); try { // Use the generic Messages API endpoint const resp = await vonage.messages.send({ message_type: "text", // Standard string quotes for message type text: text, to: to, from: from, // This should be your Vonage number channel: channel, // 'sms' or 'whatsapp', etc. }) logger.info({ response: resp }, `Message sent via Vonage to ${to}. Message UUID: ${resp.messageUuid}`); // Optionally, immediately create an 'outbound' message record in your DB await createMessage({ vonageMessageId: resp.messageUuid, direction: 'outbound', channel: channel, fromNumber: from, toNumber: to, body: text, status: 'submitted', // Initial status – Vonage will send 'delivered', 'failed', etc. later timestamp: new Date(), }); return resp; // Contains message_uuid } catch (error) { // Log detailed error from Vonage if available const vonageError = error?.response?.data || error?.message || error; logger.error({ error: vonageError, to, from }, 'Failed to send message via Vonage'); // Common Vonage error codes (see https://developer.vonage.com/en/api-errors/messages): // 1020: Invalid params // 1170: Invalid or Missing Msisdn Param (invalid phone number) // 1320: Message already sent // 1360: TTL expired // 1380: Invalid resource // 1420: Invalid sender (from parameter) // 1430: Invalid recipient (to parameter) // Rethrow or handle error appropriately throw error; } }Explanation:
.env. This happens only once. Error handling includes cases where credentials are missing or invalid.createMessage: Straightforward function usingdb.message.createto save message data.updateMessageStatus: Usesdb.message.updateto find a message by its uniquevonageMessageIdand update itsstatus,errorCode, anderrorReason. Includes basic handling for cases where the message might not exist (e.g., if the status webhook arrives before the inbound message webhook processes fully). EnsureserrorCodestores as a string.sendVonageMessage:vonage.messages.send()which is the unified endpoint for various channels.message_type: "text",to,from(your Vonage number),text, andchannel.messageUuid.outboundrecord in the database immediately with status 'submitted'. The actual delivery status arrives later via the Status webhook.6. Local Development & Testing with
ngrokExpose your Redwood development server to the public internet using
ngrokto test your inbound SMS webhook locally.Start Redwood Dev Server: Open a terminal in your project root and run:
Note the API server port (usually
8911).Start
ngrok: Open a second terminal and runngrok, pointing it to your Redwood API server's port:ngrokdisplays forwarding URLs (e.g.,https://<random-subdomain>.ngrok-free.app). Copy thehttpsURL.ngrokHTTPS URL plus the path to your function:/api/vonageWebhook.Update Vonage Webhook URLs:
ngrokHTTPS URL including the path into both the Inbound URL and Status URL fields. The final URL should look like:https://<random-subdomain>.ngrok-free.app/api/vonageWebhook.Send a Test Message:
Verify:
ngrokConsole: Check thengrokterminal window for aPOST /api/vonageWebhookrequest with a200 OKresponse.yarn rw devfor logs from thevonageWebhookfunction:createMessageservice.createMessagefor the outbound record.Messagetable (e.g., usingyarn rw prisma studio) for a new record for the inbound message (and potentially an outbound one if reply is enabled).Troubleshooting
ngrok:ngrokURL in Vonage (HTTPS, correct path/api/vonageWebhook).ngrokis still running. Freengroksessions time out.ngrok.logger.debug) and compare them carefully with what Vonage expects. EnsureVONAGE_WEBHOOK_SECRETis correct. Sometimes proxies (includingngrokunder certain configurations) can subtly alter headers or body encoding.7. Security Considerations for Webhook Signature Validation
VONAGE_WEBHOOK_SECRETas shown in the function handler. Never disable this check in production. Vonage uses SHA-256 HMAC for signature generation..envfile and ensure it's in your.gitignore. Use your deployment platform's secret management for production.ngrokfor local development and ensure production deployments use HTTPS.8. Error Handling and Logging
logger(import { logger } from 'src/lib/logger'). Log key events (webhook received, signature verified, message processed/sent) and errors. Use appropriate log levels (info,warn,error,debug).sendVonageMessage) and log relevant details (often found inerror.response.dataorerror.message). Refer to Vonage Messages API error codes for detailed descriptions.createMessage,updateMessageStatus). Log details and decide how to respond (e.g., retry, notify admin, or ignore if non-critical like a duplicate status update).errorCodeanderrorReason. Implement logic to handle specific error codes from Vonage if needed (e.g., number blocked, invalid number).async-retry. However, avoid blocking the webhook response. If sending fails, log it and potentially queue it for later retry via a background job system (like RedwoodJS Background Jobs).9. Production Deployment
Deploy your RedwoodJS inbound SMS webhook application following these key steps for Vonage integration:
Choose a Platform: Select a deployment provider supporting Node.js applications (e.g., Vercel, Netlify, Render, Fly.io, AWS Lambda). RedwoodJS provides deployment guides for popular platforms.
Function URL Pattern: After deployment, access your webhook function at
https://your-domain.com/.redwood/functions/vonageWebhook(or/api/vonageWebhookdepending on your deployment configuration). Update your Vonage Application's Inbound URL and Status URL with this production URL.Configure Environment Variables: Set these environment variables securely in your deployment platform's settings:
DATABASE_URL(pointing to your production database)VONAGE_API_KEYVONAGE_API_SECRETVONAGE_WEBHOOK_SECRETVONAGE_APPLICATION_IDVONAGE_NUMBERVONAGE_PRIVATE_KEY_PATH(if using JWT/Secure Media): For production, store the content of the private key directly in a secure environment variable (e.g.,VONAGE_PRIVATE_KEY_CONTENTbase64 encoded) instead of deploying the key file. This avoids file system complexities and enhances security. Adjust your SDK initialization logic (Section 5.2) to read the key content from the environment variable if the path variable isn't set. Example:// In services/messages/messages.ts const credentials = new Auth({ 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 ? Buffer.from(process.env.VONAGE_PRIVATE_KEY_CONTENT, 'base64') : process.env.VONAGE_PRIVATE_KEY_PATH, // Fallback to file path for local dev })SESSION_SECRETif using auth). Check RedwoodJS deployment docs for specifics.Run Tests and Verification: Ensure production webhooks function correctly by:
Production Monitoring: Set up monitoring and alerting for:
Frequently Asked Questions
How do I receive inbound SMS messages with Vonage and RedwoodJS? Create a RedwoodJS serverless function to handle webhook POST requests from Vonage. Configure your Vonage Application's Inbound URL to point to your function endpoint (e.g.,
https://your-domain.com/.redwood/functions/vonageWebhook). The function validates the signature, parses the message payload, and stores it in your Prisma database.What is webhook signature validation and why is it critical? Webhook signature validation verifies that incoming webhook requests genuinely came from Vonage and haven't been tampered with. Vonage signs each webhook using SHA-256 HMAC with your Signature Secret. Always validate signatures using the
@vonage/server-sdk'sSignatureclass. Never disable this check in production – it prevents unauthorized access and malicious payloads.What's the difference between Vonage API Secret and Signature Secret? The API Secret (
VONAGE_API_SECRET) authenticates your API calls when sending messages to Vonage. The Signature Secret (VONAGE_WEBHOOK_SECRET) validates incoming webhooks from Vonage to your application. These are completely different values – do not confuse them. Generate the Signature Secret in your Vonage Application settings under "Signed webhooks."How do I implement two-way SMS messaging with Vonage? Store the sender's number from inbound webhooks and use the
sendVonageMessageservice function to reply. Set the sender's number as thetoparameter and your Vonage number asfrom. The webhook payload includes achannelfield – use the same channel (SMS or WhatsApp) for replies to ensure proper delivery.How do I handle both SMS and WhatsApp messages in the same webhook? The webhook payload includes a
channelfield indicating the message type (sms,whatsapp, etc.). Store this in your database and use it when sending replies. The webhook handler code differentiates message types automatically. For WhatsApp, ensure your Vonage number has WhatsApp Business API enabled.What Node.js version does RedwoodJS v7 require? RedwoodJS v7+ requires exactly Node.js 20.x (not 18.x or 19.x). Use
node -vto verify your version. Install Node 20.x via nodejs.org or use a version manager likenvm. Additionally, use Yarn ≥1.22.21 (Classic Yarn 1.x).How do I test Vonage webhooks locally during development? Use
ngrokto expose your local RedwoodJS API server (port 8911) to the internet with HTTPS. Runngrok http 8911to get a public HTTPS URL. Configure this URL plus/api/vonageWebhookin your Vonage Application's Inbound URL and Status URL settings. Send a test SMS to your Vonage number and verify the webhook arrives in your local logs.What database indexes should I create for message storage? Create these indexes in your Prisma schema: 1)
@@index([vonageMessageId])for fast UUID lookups (though@uniquealready creates an index), 2)@@index([direction, createdAt])for filtering by inbound/outbound and sorting by timestamp. These optimize common query patterns for message retrieval and status updates.How do I send automatic replies to inbound messages? In your webhook handler function, after storing the inbound message, call the
sendVonageMessageservice function with the sender's number as thetoparameter and your Vonage number as thefromparameter. Use the samechannel(SMS or WhatsApp) for the reply. The example code includes a commented-out auto-reply section you can enable.What are Vonage's webhook timeout and retry policies? Vonage requires a
200 OKresponse within 3 seconds for connection establishment and 15 seconds for the actual response. If your endpoint responds with HTTP 429 or 5xx codes, Vonage retries using exponential backoff starting at 5 seconds, doubling with each retry up to 15 minutes maximum, continuing for up to 24 hours before discarding the message. Process time-consuming operations asynchronously after sending the 200 response to avoid timeouts.How do I handle Vonage error codes for failed messages? Status webhooks include
errorCodeanderrorReasonfields when messages fail. Store these in your database via theupdateMessageStatusservice. Common error codes: 1020 (invalid params), 1170 (invalid/missing phone number), 1320 (message already sent), 1360 (TTL expired), 1380 (invalid resource), 1420 (invalid sender), 1430 (invalid recipient). Implement logic to handle specific codes as needed. See the complete list at Vonage Messages API errors.Should I store the private key file in production deployments? No. Store the private key content in an environment variable (base64 encoded) instead of deploying the file. Example:
VONAGE_PRIVATE_KEY_CONTENT. Read it withBuffer.from(process.env.VONAGE_PRIVATE_KEY_CONTENT, 'base64')in your Auth initialization. This avoids file system complexities and improves security across deployment platforms.What's the Prisma error code P2025 in status update failures? P2025 means "Record not found." This occurs when a status webhook arrives before the corresponding inbound message webhook processes (race condition). Handle this gracefully by logging a warning and returning null rather than throwing an error. The status update will succeed when retried or can be ignored if the message doesn't exist in your database yet.
Next Steps
You now have a fully functional RedwoodJS inbound SMS webhook application handling two-way messaging with Vonage. Consider these enhancements:
Explore the RedwoodJS documentation and Vonage Messages API guides for more advanced features.