Twilio WhatsApp Integration with RedwoodJS: Complete Implementation Guide
Build WhatsApp messaging into RedwoodJS applications with Twilio API. Includes GraphQL mutations, webhook handling, media support, Prisma database logging, and production deployment.
Build robust WhatsApp messaging into your RedwoodJS applications using Twilio's API. Create a complete system that sends and receives WhatsApp messages through a GraphQL API, handles media, logs interactions in a database, and deploys to production.
This guide shows you how to build a system that sends outbound WhatsApp messages through a GraphQL API and processes incoming messages via webhooks. RedwoodJS's architecture provides clean separation of concerns while Twilio's infrastructure ensures reliable message delivery.
Project Overview and Goals
What You're Building:
A RedwoodJS application with backend services for sending WhatsApp messages
A secure webhook endpoint to receive incoming WhatsApp messages and replies from Twilio
A database model (MessageLog) to store records of sent and received messages
A GraphQL mutation to trigger outbound messages
Basic media handling for sending and receiving images
Problem Solved: This integration enables applications to directly engage users via WhatsApp for customer support, appointment reminders, order notifications, or two-way customer service conversations – all without requiring users to leave the WhatsApp platform.
Important WhatsApp Policy Context:
24-Hour Customer Service Window: When a user sends your business a WhatsApp message, you have 24 hours to send free-form replies without requiring pre-approved templates. During this window, you can respond with any text or media content.
Message Templates Required: To initiate conversations or send messages outside the 24-hour window, you must use pre-approved message templates. Submit templates through Twilio Console and wait for WhatsApp approval (review takes up to 48 hours).
User Opt-In Required: WhatsApp requires explicit user consent before your application can send messages. Collect opt-ins via web forms, mobile apps, SMS, or other channels during sign-up or in account settings.
Technologies Used:
RedwoodJS: A full-stack JavaScript/TypeScript framework built on React, GraphQL, and Prisma. Chosen for its integrated structure, developer experience, and conventions that simplify full-stack development.
Twilio API for WhatsApp: Provides the programmable interface to send and receive WhatsApp messages. Chosen for its robustness, scalability, and extensive documentation.
Node.js: The underlying runtime environment for RedwoodJS
Prisma: RedwoodJS's default ORM for database interactions
GraphQL: API layer interaction between the web frontend and the API backend
ngrok (for development): A tool to expose local development servers to the internet for webhook testing
System Architecture:
graph TD
subgraph User Interaction
User[User via Browser/Client] --> RedwoodWeb[RedwoodJS Web Side (React)]
end
subgraph RedwoodJS Application
RedwoodWeb -- GraphQL Mutation --> RedwoodAPI[RedwoodJS API Side (GraphQL)]
RedwoodAPI -- Calls Service --> TwilioService[Twilio Service Logic]
RedwoodAPI -- Stores/Retrieves --> Database[(Prisma / Database)]
TwilioWebhook[Twilio Webhook Function] -- Processes Request --> RedwoodAPI
TwilioWebhook -- Stores/Retrieves --> Database
end
subgraph Third-Party Services
TwilioService -- Sends Message --> TwilioAPI[Twilio API]
TwilioAPI -- Delivers Message --> WhatsAppUser[WhatsApp User]
WhatsAppUser -- Sends Reply --> TwilioAPI
TwilioAPI -- POST Request --> TwilioWebhook
end
%% Styling (Optional)
classDef redwood fill:#BF4722,stroke:#333,stroke-width:2px,color:#fff;
classDef twilio fill:#F22F46,stroke:#333,stroke-width:2px,color:#fff;
classDef db fill:#3989cf,stroke:#333,stroke-width:2px,color:#fff;
class RedwoodWeb,RedwoodAPI,TwilioService,TwilioWebhook redwood;
class TwilioAPI twilio;
class Database db;
Prerequisites:
Requirement
Version/Details
Node.js
v20 or later (RedwoodJS v8.x requires >=20.x)
Yarn
v1.22.21 or later
RedwoodJS CLI
Install with yarn global add redwoodjs-cli
Twilio Account
With activated WhatsApp Sandbox
ngrok
For local development webhook testing
WhatsApp Account
Personal account for testing
Final Outcome: A RedwoodJS application capable of sending text and image messages via WhatsApp through a GraphQL mutation and receiving/replying to messages via a webhook, with basic logging and security measures in place.
1. Set Up a RedwoodJS Project for WhatsApp Integration
Initialize your RedwoodJS project and install necessary dependencies.
Create RedwoodJS Project:
Open your terminal and run:
Bash
yarn create redwood-app redwood-whatsapp-twilio
cd redwood-whatsapp-twilio
Follow the prompts (choose JavaScript or TypeScript). This guide uses TypeScript examples where applicable, but JavaScript equivalents are similar.
Install Twilio SDK:
Navigate to the API workspace and install the Twilio Node.js helper library:
Bash
yarn workspace api add twilio
Configure Environment Variables:
Store Twilio credentials securely in environment variables. Create a .env file in the root of your project (Redwood automatically loads variables from here).
Add the following lines, replacing the placeholder values with your actual Twilio credentials from the Twilio Console:
Text
# .env
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=your_auth_token_xxxxxxxxxxxxxx
TWILIO_WHATSAPP_NUMBER=whatsapp:+14155238886 # Replace with your Twilio Sandbox number in E.164 format
Important: Add .env to your .gitignore file to prevent committing secrets. Redwood's template usually includes this.
Environment variables defined in .env are automatically available in the api side via process.env
2. Implement WhatsApp Message Sending with Twilio
Create a RedwoodJS service to encapsulate the logic for interacting with the Twilio API.
Generate Twilio Service:
Use the Redwood CLI to generate a service file for Twilio logic:
Bash
yarn rw g service twilio
This creates api/src/services/twilio/twilio.ts (and corresponding test/scenario files).
Implement sendWhatsAppMessageInternal Function:
Open api/src/services/twilio/twilio.ts and add the following code:
TypeScript
// api/src/services/twilio/twilio.tsimport { Twilio } from'twilio'importtype { MessageInstance } from'twilio/lib/rest/api/v2010/account/message'// Type importimport { logger } from'src/lib/logger'// Redwood's logger// Initialize Twilio Client (outside function for potential reuse)// Ensure environment variables are loadedconst accountSid = process.env.TWILIO_ACCOUNT_SIDconst authToken = process.env.TWILIO_AUTH_TOKENconst twilioWhatsAppNumber = process.env.TWILIO_WHATSAPP_NUMBERif (!accountSid || !authToken || !twilioWhatsAppNumber) {
thrownewError(
'Twilio credentials (Account SID, Auth Token, WhatsApp Number) are not configured in environment variables.'
)
}
const client = newTwilio(accountSid, authToken)
interfaceSendWhatsAppMessageArgs {
to: string// Recipient number, e.g., +15551234567body?: string// Message text content (optional if mediaUrl provided)mediaUrl?: string// Optional URL for media attachment
}
// Renamed to avoid conflict with resolver export nameconst sendWhatsAppMessageInternal = async ({
to,
body,
mediaUrl,
}: SendWhatsAppMessageArgs): Promise<MessageInstance> => {
logger.info(
{ targetNumber: to, mediaUrl: mediaUrl },
`Attempting to send WhatsApp message via Twilio`
)
// Ensure recipient number is prefixed correctly for WhatsAppconst formattedTo = to.startsWith('whatsapp:') ? to : `whatsapp:${to}`const formattedFrom = twilioWhatsAppNumber // Already includes 'whatsapp:' prefix from .envtry {
// Construct message payloadconstmessageData: {
from: stringto: stringbody?: stringmediaUrl?: string[]
} = {
from: formattedFrom,
to: formattedTo,
}
// --- WhatsApp Media/Body Rules Handling ---// Rule: Cannot have body if media is video, audio, document, contact, or location.// Image/PDFs *can* have captions (sent as 'body').// This code *prioritizes mediaUrl* and omits body if mediaUrl exists.if (mediaUrl) {
messageData.mediaUrl = [mediaUrl]
// To send a caption *with* an image/PDF:// 1. Verify the mediaUrl points to a supported type (image/PDF).// 2. Modify the logic here to *include* `messageData.body = body`// if `body` is provided *and* the media type supports captions.// Example modification (pseudo-code):// if (mediaUrl) {// messageData.mediaUrl = [mediaUrl];// if (body && isImageType(mediaUrl)) { // Check if media type allows caption// messageData.body = body;// }// } else if (body) { ... }
} elseif (body) {
// Only set body if no mediaUrl is provided (current behavior)
messageData.body = body
} else {
thrownewError('Message must have either body or mediaUrl.')
}
// --- End Handling ---const message = await client.messages.create(messageData)
logger.info(
{ messageSid: message.sid, status: message.status },
'Successfully sent WhatsApp message via Twilio'
)
// Optional: Log message to database here (See Section 6)return message
} catch (error) {
logger.error({ error, targetNumber: to }, 'Failed to send WhatsApp message')
// Consider re-throwing or returning a specific error structurethrownewError(`Twilio API Error: ${error.message}`)
}
}
// Keep the default generated service functions or remove if unused// export const twilios = () => { ... }// export const twilio = ({ id }) => { ... }// ... etc ...
Supported Media Types and Limits:
Media Type
Max Size
Caption Support
Images (JPEG, PNG)
5 MB
Yes
PDFs
100 MB
Yes
Videos
16 MB
No
Audio
16 MB
No
Documents
100 MB
No
Why This Approach?
Service Layer: Encapsulates third-party API logic, making it reusable and testable separate from the GraphQL resolvers or functions
Error Handling: Includes basic try/catch and logging using Redwood's logger
Type Safety: Uses TypeScript types for better developer experience and catching errors early
Input Formatting: Ensures the whatsapp: prefix is correctly applied
Media Handling: Includes mediaUrl parameter and notes WhatsApp constraints, clarifying the caption behavior
3. Build a GraphQL API for WhatsApp Messaging
Expose the sendWhatsAppMessageInternal functionality through Redwood's GraphQL API.
Define GraphQL Schema (SDL):
Create or update the GraphQL schema definition for Twilio operations. Open or create api/src/graphql/twilio.sdl.ts:
TypeScript
// api/src/graphql/twilio.sdl.tsexportconst schema = gql`type TwilioMessage {sid: String!status: String!to: String!from: String!body: String
# Add other relevant fields from MessageInstance if needed}typeMutation{"""Sends a WhatsApp message using Twilio. Requires authentication."""
sendWhatsAppMessage(to: String!, body: String, mediaUrl: String): TwilioMessage!@requireAuth# Note: Sending body AND mediaUrl behavior depends on the service logic# and WhatsApp rules for the specific media type. See Section 2.}
`
Input Validation Requirements:
Field
Validation
Example
to
E.164 format phone number
+15551234567
body
Optional if mediaUrl provided; max 1,600 characters
"Hello from Twilio"
mediaUrl
Valid HTTPS URL to supported media type
"https://example.com/image.jpg"
Implement the Resolver (Direct Mapping):
Redwood convention maps the GraphQL mutation name (sendWhatsAppMessage) directly to an exported function with the same name in the corresponding service file (api/src/services/twilio/twilio.ts).
Modify api/src/services/twilio/twilio.ts to add the resolver function:
TypeScript
// api/src/services/twilio/twilio.tsimport { Twilio } from'twilio'importtype { MessageInstance } from'twilio/lib/rest/api/v2010/account/message'import { requireAuth } from'src/lib/auth'// Import Redwood's auth helperimport { logger } from'src/lib/logger'import { db } from'src/lib/db'// Import Prisma clientimport { Prisma } from'@prisma/client'// Import Prisma types// ... (Twilio client initialization and sendWhatsAppMessageInternal function from previous step) ...interfaceSendWhatsAppMessageInput { // Input type for the resolverto: stringbody?: string// Optional: depends on whether mediaUrl is also sentmediaUrl?: string
}
// Internal function (ensure it's not exported if only used internally)const sendWhatsAppMessageInternal = async ({
to,
body,
mediaUrl,
}: SendWhatsAppMessageArgs): Promise<MessageInstance> => {
logger.info({ targetNumber: to, mediaUrl }, `Internal send attempt`)
const formattedTo = to.startsWith('whatsapp:') ? to : `whatsapp:${to}`const formattedFrom = twilioWhatsAppNumber
if (!formattedFrom) {
thrownewError('Twilio WhatsApp Number not configured.')
}
try {
constmessageData: any = { from: formattedFrom, to: formattedTo }
// --- Media/Body Logic (as described in Section 2) ---if (mediaUrl) {
messageData.mediaUrl = [mediaUrl]
// If logic modified to allow captions:// if (body && isImageType(mediaUrl)) { messageData.body = body; }
} elseif (body) {
messageData.body = body
} else {
thrownewError('Message must have either body or mediaUrl.')
}
// --- End Logic ---const message = await client.messages.create(messageData)
logger.info({ messageSid: message.sid, status: message.status }, 'Internal send success')
return message
} catch (error) {
logger.error({ error, targetNumber: to }, 'Internal send failed')
thrownewError(`Twilio API Error: ${error.message}`)
}
}
// --- Resolver Implementation ---// This function name matches the 'sendWhatsAppMessage' mutation in the SDL.// Redwood automatically uses this as the resolver.exportconst sendWhatsAppMessage = async ({
to,
body,
mediaUrl,
}: SendWhatsAppMessageInput): Promise<Partial<MessageInstance>> => { // Return type matches GraphQL type fieldsrequireAuth() // Ensure user is authenticated
logger.info({ targetNumber: to, mediaUrl }, `Attempting send via GraphQL mutation`)
// Optional: Add more validation here (e.g., phone number format check – see Section 7)try {
// Call the internal function that interacts with Twilioconst message = awaitsendWhatsAppMessageInternal({ to, body, mediaUrl })
logger.info({ messageSid: message.sid, status: message.status }, 'Sent via GraphQL')
// --- Add DB Logging (See Section 6) ---try {
await db.messageLog.create({
data: {
twilioSid: message.sid,
status: message.status,
direction: 'outbound',
fromNumber: message.from,
toNumber: message.to,
body: message.body, // Will be null if only media sent & captions not enabledmediaUrl: mediaUrl, // Log the URL we attempted to sendnumSegments: message.numSegments ? parseInt(message.numSegments) : null,
price: message.price ? newPrisma.Decimal(message.price) : null,
priceUnit: message.priceUnit,
errorCode: message.errorCode,
errorMessage: message.errorMessage,
// userId: context.currentUser?.id // Uncomment if User relation exists
},
})
logger.info({ twilioSid: message.sid }, 'Outbound message logged to DB')
} catch (dbError) {
logger.error({ dbError, twilioSid: message.sid }, 'Failed to log outbound message to DB')
// Decide if DB log failure should fail the mutation
}
// --- End DB Logging ---// Return only the fields defined in the GraphQL 'TwilioMessage' typereturn {
sid: message.sid,
status: message.status,
to: message.to,
from: message.from,
body: message.body,
}
} catch (error) {
logger.error({ error, targetNumber: to }, 'Failed GraphQL send')
// Let Redwood handle throwing the error to the clientthrow error // Re-throw the original error (or a more specific GraphQL error)
}
}
// Remove or keep other generated service functions as needed
Authentication:
The @requireAuth directive in the SDL ensures only authenticated users can call this mutation. Set up Redwood's authentication (e.g., yarn rw setup auth dbAuth). See the RedwoodJS Auth Docs for setup instructions.
Test the Mutation:
Start your development server: yarn rw dev
Open the GraphQL playground (usually http://localhost:8911/graphql)
Log in using the login mutation from your auth setup, or temporarily remove @requireAuth for initial testing (remember to add it back)
Execute the mutation:
GraphQL
# Example sending text onlymutation SendWAMessageText {
sendWhatsAppMessage(to:"+15551234567", body:"Hello from RedwoodJS!"){# Replace with your test number
sid
status
to
from
body
}}# Example sending media only (current default behavior)mutation SendWAMessageMedia {
sendWhatsAppMessage(to:"+15551234567", mediaUrl:"https://images.unsplash.com/photo-1518717758536-85ae29035b6d?ixlib=rb-1.2.1&auto=format&fit=crop&w=668&q=80"){# Replace with your test number
sid
status
to
from
body # Body will likely be null here based on default logic}}# Example sending media *and* caption (requires modifying service logic as per Section 2)# mutation SendWAMessageMediaWithCaption {# sendWhatsAppMessage(# to: "+15551234567",# body: "Here is a cute dog!",# mediaUrl: "https://images.unsplash.com/photo-1518717758536-85ae29035b6d?ixlib=rb-1.2.1&auto=format&fit=crop&w=668&q=80"# ) {# sid# status# to# from# body # Should contain the caption if logic is updated# }# }
Check your WhatsApp for the message and verify the behavior matches your service logic.
Expected Responses:
Success:
JSON
{"data":{"sendWhatsAppMessage":{"sid":"SM1234567890abcdef","status":"queued","to":"whatsapp:+15551234567","from":"whatsapp:+14155238886","body":"Hello from RedwoodJS!"}}}
Common Errors:
Error Code
Meaning
Solution
21211
Invalid 'To' phone number
Verify E.164 format (+country code)
21408
Recipient not opted in
Send join code to Sandbox number
20003
Authentication failed
Check TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN
21610
Unverified number
Verify number in Twilio Console for trial accounts
4. Configure Twilio Webhooks for Incoming WhatsApp Messages
Set up Twilio Console and create the RedwoodJS function to handle incoming messages.
Activate WhatsApp Sandbox: Navigate to Messaging → Try it out → Send a WhatsApp message. Follow the instructions to select a Sandbox number and activate it
Sandbox Opt-In: Send the specified join code (e.g., join <your-keyword>) from your personal WhatsApp number to your Twilio Sandbox number. You'll receive a confirmation. This is required for the Sandbox to send messages to your number and for you to send messages from your number to the Sandbox
Gather Credentials: Note your Account SID and Auth Token from the Console dashboard. Note your Twilio Sandbox WhatsApp Number (e.g., whatsapp:+14155238886). These should already be in your .env file
Create RedwoodJS Webhook Function:
Redwood functions handle HTTP requests directly. Create one to receive POST requests from Twilio when a message arrives.
Bash
yarn rw g function whatsappWebhook --typescript # or --javascript
This creates api/src/functions/whatsappWebhook.ts.
Implement Webhook Logic:
This function parses the incoming request body, validates the request came from Twilio, processes the message, and responds with TwiML (Twilio Markup Language).
TypeScript
// api/src/functions/whatsappWebhook.tsimporttype { APIGatewayEvent, Context } from'aws-lambda'import { URLSearchParams } from'url'// Node.js built-inimport { validateRequest } from'twilio'// For request validationimport { MessagingResponse } from'twilio.twiml'// For TwiML repliesimport { logger } from'src/lib/logger'import { db } from'src/lib/db'// Import DB clientimport { Prisma } from'@prisma/client'// For Decimal type if neededexportconsthandler = async (event: APIGatewayEvent, context: Context) => {
logger.info('Incoming WhatsApp Webhook Request')
// 1. Validate Request Signature (Security – See Section 7)const twilioSignature = event.headers['x-twilio-signature']
const webhookUrl = process.env.WEBHOOK_URL// You *must* define this ENV varconst rawBody = event.body// Raw body needed for validationif (!process.env.TWILIO_AUTH_TOKEN) {
logger.error('Twilio Auth Token not configured. Cannot validate request.')
return { statusCode: 500, body: 'Internal configuration error.' }
}
if (!webhookUrl) {
logger.error('WEBHOOK_URL environment variable not set. Cannot validate request.')
return { statusCode: 500, body: 'Internal configuration error.' }
}
if (!twilioSignature) {
logger.warn('Request received without X-Twilio-Signature header.')
return { statusCode: 400, body: 'Missing signature.' } // Bad Request
}
// IMPORTANT: Twilio validation requires the *exact* URL Twilio used to call your webhook.// Using an ENV var set during deployment (or via ngrok for local dev) is the most reliable way.// Constructing it dynamically can fail behind proxies/gateways.// Log headers/path/domain in dev if validation fails unexpectedly to debug the URL structure.
logger.debug({ headers: event.headers, path: event.path, url: webhookUrl }, 'Webhook details for validation');
let requestIsValid = false;
try {
requestIsValid = validateRequest(
process.env.TWILIO_AUTH_TOKEN,
twilioSignature,
webhookUrl, // Use the ENV var
rawBody || ''// Pass the raw body string, ensure it's not null/undefined
);
} catch (validationError) {
logger.error({ error: validationError }, 'Error during Twilio signature validation process.');
// Treat validation error as invalid request for security
requestIsValid = false;
}
// --- Validation Check ---// STRONGLY RECOMMENDED: Enforce validation even in development.// Bypassing this check (`process.env.NODE_ENV === 'development'`) is risky// as it prevents testing a critical security feature locally.// Ensure your ngrok setup and WEBHOOK_URL env var are correct to make validation work.if (!requestIsValid) {
logger.error('Invalid Twilio signature. Request denied.')
return {
statusCode: 403, // Forbiddenbody: 'Invalid Twilio signature.',
}
}
logger.info('Twilio signature validation passed.');
// 2. Parse the incoming request body (Twilio sends form-urlencoded)const bodyParams = newURLSearchParams(event.body || '')
constfrom = bodyParams.get('From') // Sender's WhatsApp number (whatsapp:+1...)const to = bodyParams.get('To') // Your Twilio number (whatsapp:+1...)const messageBody = bodyParams.get('Body') // Text contentconst messageSid = bodyParams.get('MessageSid') // Twilio Message SIDconst numMedia = parseInt(bodyParams.get('NumMedia') || '0', 10) // Number of media items
logger.info(
{ from, to, messageSid, body: messageBody, numMedia },
'Received WhatsApp message details'
)
// 3. Process Media (if any)constmediaItems: { url: string; contentType: string | null }[] = []
if (numMedia > 0) {
for (let i = 0; i < numMedia; i++) {
const mediaUrl = bodyParams.get(`MediaUrl${i}`)
const contentType = bodyParams.get(`MediaContentType${i}`)
if (mediaUrl) {
mediaItems.push({ url: mediaUrl, contentType })
logger.info(
{ index: i, url: mediaUrl, contentType },
'Received media item'
)
}
}
}
// 4. Log to Database (See Section 6)if (messageSid) { // Only log if we have a SIDtry {
await db.messageLog.create({
data: {
twilioSid: messageSid,
status: 'received', // Set initial status for inbounddirection: 'inbound',
fromNumber: from, // Sender's numbertoNumber: to, // Your Twilio numberbody: messageBody,
mediaUrl: mediaItems.length > 0 ? mediaItems[0].url : null, // Log first media URL// Other fields like price/segments usually aren't relevant for inbound
},
})
logger.info({ twilioSid: messageSid }, 'Inbound message logged to DB')
} catch (dbError) {
// Log errors, but generally don't fail the webhook response for DB errors// unless absolutely necessary. Twilio expects a quick 200 OK.
logger.error({ dbError, twilioSid: messageSid }, 'Failed to log inbound message to DB')
}
} else {
logger.warn('No MessageSid found in webhook payload, skipping DB log.')
}
// 5. Prepare TwiML Response (Example: Simple Echo Bot)const twiml = newMessagingResponse()
if (numMedia > 0) {
// Example: Reply if media was received
twiml.message('Thanks for sending the media!')
// Example: Send back a fixed image// twiml// .message()// .media(// 'https://images.unsplash.com/photo-1518717758536-85ae29035b6d?ixlib=rb-1.2.1&auto=format&fit=crop&w=1350&q=80'// ) // Example media URL
} elseif (messageBody) {
// Simple echo for text messages
twiml.message(`You said: ${messageBody}`)
} else {
// Fallback if message is empty (e.g., location message without text)
twiml.message('Received your message.')
}
// 6. Return Response to Twilioreturn {
statusCode: 200,
headers: { 'Content-Type': 'text/xml' },
body: twiml.toString(), // Convert TwiML object to XML string
}
}
Key Points:
Request Validation: Enforces validation by default. Ensure WEBHOOK_URL is correct for local ngrok testing. Includes a try-catch around validateRequest
Parsing: Uses URLSearchParams to parse the form-encoded data, handling potentially null body
TwiML Response: Uses twilio.twiml.MessagingResponse to build the XML response
Media Handling: Iterates through media parameters if NumMedia > 0
Expose Webhook Locally with ngrok:
Ensure your Redwood dev server is running (yarn rw dev). Your function is available at http://localhost:8911/whatsappWebhook
In a new terminal window, start ngrok:
Bash
ngrok http 8911
ngrok provides a public HTTPS URL (e.g., https://<unique-id>.ngrok.io). Copy this URL
Set WEBHOOK_URL: Add the full function URL to your .env file. This is crucial for local validation testing. Restart yarn rw dev after changing .env
Text
# .env
# ... other vars
WEBHOOK_URL=https://<unique-id>.ngrok.io/whatsappWebhook
Configure Twilio Webhook URL:
Go to Twilio Console → Messaging → Try it out → Send a WhatsApp message → Sandbox settings
In the "WHEN A MESSAGE COMES IN" field, paste your ngrok function URL (e.g., https://<unique-id>.ngrok.io/whatsappWebhook)
Ensure the method is set to HTTP POST
Click Save
Test Incoming Messages:
Send a WhatsApp message (text or image) from your personal number to your Twilio Sandbox number
Watch the terminal running yarn rw dev for logs. Check if validation passes (it should if WEBHOOK_URL matches the ngrok URL)
Check your WhatsApp – you should receive the TwiML reply
Check the ngrok terminal (http://localhost:4040) for request details if validation fails
Troubleshooting Webhook Validation:
Issue
Cause
Solution
Validation fails locally
WEBHOOK_URL doesn't match ngrok URL
Verify exact URL in .env matches ngrok output
403 Forbidden
Signature mismatch
Check TWILIO_AUTH_TOKEN is correct in .env
No webhook received
Twilio not configured
Verify webhook URL in Twilio Console Sandbox settings
Timeout
Slow processing
Return 200 OK within 15 seconds; process async tasks separately
5. Implement Error Handling and Retry Logic for WhatsApp Messages
Build robust applications with proper error handling and logging.
Error Handling Strategy:
Layer
Approach
Implementation
Services/Functions
Use try...catch blocks
Wrap API calls and DB operations
Logging
Use Redwood's logger.error()
Provide context in catch blocks
GraphQL Errors
Let errors bubble up
Redwood formats them; throw specific errors if needed
Webhook Errors
Return 200 OK quickly
Log errors internally; avoid Twilio retries
Logging:
Use Redwood's logger (info, debug, warn, error). Configure levels in api/src/lib/logger.ts
Log key events, errors with context, and debug data (avoid logging sensitive information)
Use log analysis tools in production
Retry Mechanisms (Conceptual):
Twilio Retries (Webhook): Twilio retries on non-200 responses. Make your webhook idempotent (check MessageSid in DB before processing) to handle retries safely
Outbound Message Retries: Implement retries with exponential backoff in your service for client.messages.create failures (network, temporary Twilio issues). Use libraries like async-retry or p-retry. Consider background job queues (BullMQ, SQS) for high volume/reliability
Example using async-retry (install with yarn workspace api add async-retry @types/async-retry). Note: For a more actively maintained alternative, consider p-retry (v7.0.0, updated 2024).
TypeScript
// api/src/services/twilio/twilio.ts (Inside sendWhatsAppMessageInternal function)import retry from'async-retry';
import { Twilio } from'twilio'; // Ensure Twilio types are available if needed for error checking// ... inside try block where client.messages.create is called ...const message = awaitretry(
async (bail, attemptNumber) => { // Added attemptNumber for logging
logger.info(`Attempting Twilio API call, attempt number: ${attemptNumber}`);
try {
const result = await client.messages.create(messageData);
logger.info(`Twilio API call successful on attempt ${attemptNumber}.`);
return result;
} catch (error: any) { // Use 'any' or a more specific error type if available// Don't retry on non-recoverable errors (e.g., bad request, auth failure)// Twilio errors often have a 'status' propertyif (error.status === 400 || error.status === 401 || error.status === 404) {
logger.warn({ status: error.status, message: error.message }, 'Unrecoverable Twilio error, not retrying.');
bail(error); // Stop retrying by calling bailreturn; // Needed for type checking / control flow
}
logger.warn({ attempt: attemptNumber, message: error.message, status: error.status }, 'Twilio API call failed, retrying…');
throw error; // Throw error to signal retry is needed
}
},
{
retries: 3, // Number of retriesminTimeout: 1000, // Initial delay 1 sfactor: 2, // Delay multiplier (1 s, 2 s, 4 s)onRetry: (error, attempt) => {
logger.warn(`Retrying Twilio API call. Attempt ${attempt}. Error: ${error.message}`);
}
}
);
// ... rest of function ...
6. Create a Database Schema for WhatsApp Message Logging
Log message details to the database using Prisma.
Define Prisma Schema:
Open api/db/schema.prisma and add a MessageLog model:
Prisma
// api/db/schema.prisma
datasource db {
provider = "postgresql" // Or "sqlite", "mysql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
binaryTargets = ["native"] // Add others like "rhel-openssl-1.0.x" if needed
}
// Example User model if using dbAuth
model User {
id Int @id @default(autoincrement())
email String @unique
hashedPassword String
salt String
resetToken String?
resetTokenExpiresAt DateTime?
messageLogs MessageLog[] // Relation to message logs
// Add other fields as needed
}
model MessageLog {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
twilioSid String @unique // Twilio's unique message identifier
status String? // e.g., queued, sent, delivered, failed, received
direction String // 'inbound' or 'outbound'
fromNumber String? // Sender number (Twilio for outbound, User for inbound)
toNumber String? // Recipient number (User for outbound, Twilio for inbound)
body String? // Text content of the message
mediaUrl String? // URL of the media sent/received (log first one if multiple)
numSegments Int? // Number of segments for outbound SMS/MMS (may apply to WhatsApp pricing)
price Decimal? // Cost of the message segment
priceUnit String? // Currency (e.g., USD)
errorCode Int? // Twilio error code if failed
errorMessage String? // Twilio error message if failed
userId Int? // Optional: Link to the User who sent/received (if applicable)
user User? @relation(fields: [userId], references: [id])
@@index([createdAt])
@@index([direction])
@@index([userId]) // Index if querying by user often
}
Common Queries for Reporting:
TypeScript
// Get all messages for a specific userconst userMessages = await db.messageLog.findMany({
where: { userId: 123 },
orderBy: { createdAt: 'desc' }
})
// Get failed outbound messagesconst failedMessages = await db.messageLog.findMany({
where: {
direction: 'outbound',
status: 'failed'
}
})
// Calculate total message costconst totalCost = await db.messageLog.aggregate({
_sum: { price: true },
where: { direction: 'outbound' }
})
Migrate Database:
Apply the schema changes to your database:
Bash
yarn rw prisma migrate dev --name add_message_log
This creates a new migration file and updates your database schema.
Integrate Logging:
We already added the database logging logic in the sendWhatsAppMessage resolver (Section 3) and the whatsappWebhook function (Section 4). Ensure the db.messageLog.create calls correctly map the available data (like message.sid, status, direction, etc.) to the corresponding fields in your MessageLog model.
Outbound Logging (in sendWhatsAppMessage resolver): Logs details after a successful API call to Twilio
Inbound Logging (in whatsappWebhook handler): Logs details parsed from the incoming webhook request
Review the db.messageLog.create calls in api/src/services/twilio/twilio.ts and api/src/functions/whatsappWebhook.ts to confirm they align with the final MessageLog model definition. Pay attention to optional fields and data types (like Decimal for price).
7. Follow Security Best Practices for WhatsApp Integration
Protect your application and user data.
Security Checklist:
Priority
Practice
Implementation
Critical
Webhook Request Validation
Always use twilio.validateRequest
Critical
HTTPS Only
Use HTTPS for webhook URL (ngrok provides this locally)
Critical
Environment Variables
Never commit secrets; use platform secrets in production
High
GraphQL Authentication
Protect mutations with @requireAuth
High
Input Validation
Validate phone numbers, message content, media URLs
Medium
Rate Limiting
Implement on GraphQL API and webhook endpoints
Medium
Logging Sensitive Data
Avoid logging PII; mask sensitive data
Webhook Request Validation:
Mandatory: Always validate incoming webhook requests using twilio.validateRequest as shown in Section 4. This verifies the request genuinely originated from Twilio using your Auth Token as a secret key
WEBHOOK_URL Environment Variable: Use an environment variable (WEBHOOK_URL) set during deployment (or via ngrok locally) to provide the exact URL for validation. Constructing it dynamically is unreliable
HTTPS: Always use HTTPS for your webhook URL. ngrok provides this locally; ensure your production deployment uses HTTPS
Environment Variables:
Never commit secrets: Keep .env out of version control (.gitignore)
Use secure environment variable management in production (e.g., platform secrets, HashiCorp Vault)
Authentication & Authorization:
GraphQL Mutations: Protect mutations like sendWhatsAppMessage with @requireAuth (or role-based directives like @requireAuth(roles: ["admin"])) as shown in Section 3. Ensure your Redwood auth is properly configured
Webhook: Webhooks are typically public but validated. Avoid exposing sensitive data or actions directly via the webhook response unless necessary and secured
Input Validation:
Phone Numbers: Validate to numbers in the GraphQL mutation using libraries like libphonenumber-js to ensure proper E.164 format and prevent errors or potential abuse
Message Content: Sanitize or validate message body content if it's user-generated to prevent injection attacks (though less common via WhatsApp text, still good practice)
Media URLs: If accepting mediaUrl from users, validate that the URL points to expected domains or content types to prevent Server-Side Request Forgery (SSRF) or abuse
Rate Limiting:
GraphQL API: Implement rate limiting on your GraphQL endpoint (e.g., using graphql-shield or API gateway features) to prevent abuse of the sendWhatsAppMessage mutation
Webhook: While Twilio validation helps, consider rate limiting incoming webhook calls if you experience high traffic or abuse patterns
Logging Sensitive Data:
Be cautious about logging full message bodies or personally identifiable information (PII) unless necessary and compliant with privacy regulations (GDPR, CCPA). Mask or omit sensitive data in logs where possible
8. Deploy Your RedwoodJS WhatsApp Application to Production
Deploy your RedwoodJS application with proper configuration and public webhook access.
Deployment Hosting Options:
Provider
Best For
Key Features
Vercel
Quick deployment, serverless
Auto-scaling, edge functions, zero config
Netlify
JAMstack apps, static sites
CDN, branch previews, instant rollbacks
Render
Full-stack apps, databases
Managed PostgreSQL, automatic HTTPS
AWS Serverless
Enterprise, custom infrastructure
Lambda functions, API Gateway, full AWS integration
Choose a Hosting Provider:
RedwoodJS supports various platforms like Vercel, Netlify, Render, AWS Serverless, etc. See the RedwoodJS Deployment Docs.
Configure Environment Variables:
Set TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_WHATSAPP_NUMBER in your hosting provider's environment variable settings
Crucially: Set the WEBHOOK_URL environment variable to the final, deployed URL of your whatsappWebhook function (e.g., https://your-app-domain.com/api/functions/whatsappWebhook). This is needed for Twilio request validation in production
Set DATABASE_URL for your production database
Build and Deploy:
Follow your chosen provider's deployment process. Typically:
Run Database Migrations:
After deployment, run Prisma migrations against your production database:
Bash
yarn rw prisma migrate deploy
Update Twilio Webhook URL:
Go to Twilio Console → Messaging → Try it out → Send a WhatsApp message → Sandbox settings
Update the "WHEN A MESSAGE COMES IN" URL to your productionWEBHOOK_URL
Save the changes
Test in Production:
Test sending messages via your deployed GraphQL API
Test receiving messages by sending a WhatsApp message to your Twilio number and checking logs/replies
Frequently Asked Questions About Twilio WhatsApp Integration with RedwoodJS
Do I need a WhatsApp Business account for Twilio integration?
For the Twilio Sandbox (development/testing), you don't need a separate WhatsApp Business account. However, for production use with your own phone number, you need a Facebook Business Manager account linked to a WhatsApp Business Account. Meta requires business verification for accounts handling more than 2 phone numbers.
What is the 24-hour customer service window in WhatsApp?
When a user sends your business a WhatsApp message, you have 24 hours to reply with free-form messages without requiring pre-approved templates. After the 24-hour window expires, you can only send messages using pre-approved message templates. Template approval takes up to 48 hours.
How do I validate Twilio webhook requests in RedwoodJS?
Use the twilio.validateRequest() function with your Auth Token, the incoming X-Twilio-Signature header, your webhook URL (from environment variable), and the raw request body. Always validate requests to prevent unauthorized access. Set the WEBHOOK_URL environment variable to your deployed function URL for reliable validation.
Can I send images and videos through Twilio WhatsApp API?
Yes, Twilio supports sending media through WhatsApp including images, videos, PDFs, audio files, and documents. Use the mediaUrl parameter in your message payload. Images and PDFs can include captions (sent as the body parameter), but videos, audio, and documents cannot have accompanying text.
What Node.js version does RedwoodJS require for WhatsApp integration?
RedwoodJS v8.x requires Node.js v20 or later. If you're running Node.js v21.0.0 or higher, ensure compatibility with your deployment target, as some platforms like AWS Lambda may have restrictions on newer Node versions.
How do I handle message delivery failures in production?
Implement retry logic with exponential backoff using libraries like async-retry or p-retry. Make your webhook idempotent by checking MessageSid in your database before processing. Use background job queues (BullMQ, SQS) for high-volume scenarios. Configure Twilio status callbacks to track delivery status asynchronously.
What are the rate limits for Twilio WhatsApp messages?
Rate limits depend on your phone number type and Meta's sending limits. Twilio Sandbox numbers have restricted rates for testing. Production numbers start with lower limits (250 messages/day) and increase based on message quality ratings. High-quality conversations can unlock higher tiers (1,000, 10,000, or 100,000+ messages per day).
How much does it cost to send WhatsApp messages through Twilio?
WhatsApp charges conversation-based pricing starting at $0.005 – $0.10 per conversation depending on the region and message category (Marketing, Utility, or Authentication). Messages within the 24-hour customer service window are free. Utility templates sent during the service window don't incur Meta fees (as of July 2025). Check Twilio's WhatsApp pricing page for current rates.
Conclusion
You have successfully integrated Twilio WhatsApp messaging into your RedwoodJS application. This setup allows you to send outbound messages via a secure GraphQL API and process inbound messages using a validated webhook, complete with database logging and basic media handling.
Further Enhancements:
Advanced TwiML: Explore more complex TwiML for interactive replies, menus, or gathering user input
Status Callbacks: Configure Twilio status callbacks to track message delivery status (sent, delivered, failed) asynchronously
Background Jobs: Use job queues (e.g., BullMQ integrated with RedwoodJS) for sending messages reliably, especially for bulk operations or retries
User Association: Link MessageLog entries to specific User records using the userId field
Frontend Integration: Build React components in the web side to interact with the sendWhatsAppMessage GraphQL mutation
Error Alerting: Integrate error tracking services (Sentry, LogRocket) for better visibility into production issues
Testing: Write comprehensive unit and integration tests for your services and functions
Frequently asked questions
You can send WhatsApp messages within your RedwoodJS application by creating a GraphQL API endpoint that leverages the Twilio API for WhatsApp. This involves setting up a Twilio service in your RedwoodJS api side and connecting it to a GraphQL mutation, allowing you to trigger messages through your application logic.
Twilio provides the necessary infrastructure and API to connect your RedwoodJS application to the WhatsApp platform. This enables your app to send and receive WhatsApp messages, facilitating direct user engagement for notifications, customer support, and other interactive messaging features.
RedwoodJS offers a structured, full-stack JavaScript framework that simplifies development by providing conventions and tools for building APIs, services, and web frontends. This streamlines the integration process with Twilio's WhatsApp API.
Always validate incoming webhook requests from Twilio. This is crucial for security and should be done in your RedwoodJS function handler using the twilio.validateRequest method to ensure that requests genuinely originate from Twilio.
Yes, the provided integration supports basic media handling. You can include a mediaUrl parameter in your GraphQL mutation to send images or PDFs via WhatsApp, with additional code modifications allowing you to include captions.
Activate your WhatsApp Sandbox in the Twilio Console, obtain your Sandbox number, and gather your Account SID and Auth Token. Configure these credentials as environment variables in your RedwoodJS project and use ngrok to expose your webhook function during development.
The webhook acts as a receiver for incoming WhatsApp messages. It's a RedwoodJS function that receives message data from Twilio when a user sends a message to your WhatsApp Sandbox number. The webhook processes the message and can send back automatic replies.
Create a RedwoodJS function (e.g., whatsappWebhook) that will act as your webhook endpoint. Inside this function, parse the incoming message data from Twilio, validate the request's authenticity, process the message content, and generate a TwiML response if you want to send a reply back to the user.
Store your Twilio Account SID, Auth Token, and Sandbox number as environment variables in a .env file in the root of your project. Ensure that this .env file is added to your .gitignore to prevent sensitive information from being committed to version control.
Define a MessageLog model in your schema.prisma file to store message details like sender/receiver, content, status, etc. Then, within your RedwoodJS service and webhook function, use db.messageLog.create to record message data to your database using Prisma Client.
Use ngrok to expose your local development server and configure your Twilio Sandbox to send webhook requests to your ngrok URL. This enables testing both sending and receiving WhatsApp messages within your development environment.
Implement try...catch blocks in your service and function code to handle errors during Twilio API calls and database interactions. Use Redwood's logger to record error details. Ensure your webhook responds with 200 OK even on error (log errors internally) to prevent Twilio retries.
Validating user inputs, especially phone numbers and potentially message content or media URLs, helps prevent errors, abuse, and security vulnerabilities like injection attacks or server-side request forgery (SSRF).
Choose a hosting provider (e.g., Vercel, Netlify) and configure your production environment variables, including TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_WHATSAPP_NUMBER, and crucially, your production WEBHOOK_URL. Run yarn rw build and then follow your provider's deployment instructions.
Besides webhook validation and environment variable best practices, consider implementing rate limiting on your GraphQL API and webhook, validating phone number formats with libraries like libphonenumber-js, and being cautious about logging sensitive data like PII.
Build robust WhatsApp messaging into your RedwoodJS applications using Twilio's API. Create a complete system that sends and receives WhatsApp messages through a GraphQL API, handles media, logs interactions in a database, and deploys to production.
This guide shows you how to build a system that sends outbound WhatsApp messages through a GraphQL API and processes incoming messages via webhooks. RedwoodJS's architecture provides clean separation of concerns while Twilio's infrastructure ensures reliable message delivery.
Project Overview and Goals
What You're Building:
MessageLog) to store records of sent and received messagesProblem Solved: This integration enables applications to directly engage users via WhatsApp for customer support, appointment reminders, order notifications, or two-way customer service conversations – all without requiring users to leave the WhatsApp platform.
Important WhatsApp Policy Context:
Technologies Used:
System Architecture:
Prerequisites:
yarn global add redwoodjs-cliFinal Outcome: A RedwoodJS application capable of sending text and image messages via WhatsApp through a GraphQL mutation and receiving/replying to messages via a webhook, with basic logging and security measures in place.
1. Set Up a RedwoodJS Project for WhatsApp Integration
Initialize your RedwoodJS project and install necessary dependencies.
Create RedwoodJS Project: Open your terminal and run:
yarn create redwood-app redwood-whatsapp-twilio cd redwood-whatsapp-twilioFollow the prompts (choose JavaScript or TypeScript). This guide uses TypeScript examples where applicable, but JavaScript equivalents are similar.
Install Twilio SDK: Navigate to the API workspace and install the Twilio Node.js helper library:
Configure Environment Variables: Store Twilio credentials securely in environment variables. Create a
.envfile in the root of your project (Redwood automatically loads variables from here).Add the following lines, replacing the placeholder values with your actual Twilio credentials from the Twilio Console:
Important: Add
.envto your.gitignorefile to prevent committing secrets. Redwood's template usually includes this.RedwoodJS Architecture Reminder:
web/: Frontend code (React components, pages, layouts)api/: Backend code (GraphQL schema, services, functions, database schema).envare automatically available in theapiside viaprocess.env2. Implement WhatsApp Message Sending with Twilio
Create a RedwoodJS service to encapsulate the logic for interacting with the Twilio API.
Generate Twilio Service: Use the Redwood CLI to generate a service file for Twilio logic:
This creates
api/src/services/twilio/twilio.ts(and corresponding test/scenario files).Implement
sendWhatsAppMessageInternalFunction: Openapi/src/services/twilio/twilio.tsand add the following code:// api/src/services/twilio/twilio.ts import { Twilio } from 'twilio' import type { MessageInstance } from 'twilio/lib/rest/api/v2010/account/message' // Type import import { logger } from 'src/lib/logger' // Redwood's logger // Initialize Twilio Client (outside function for potential reuse) // Ensure environment variables are loaded const accountSid = process.env.TWILIO_ACCOUNT_SID const authToken = process.env.TWILIO_AUTH_TOKEN const twilioWhatsAppNumber = process.env.TWILIO_WHATSAPP_NUMBER if (!accountSid || !authToken || !twilioWhatsAppNumber) { throw new Error( 'Twilio credentials (Account SID, Auth Token, WhatsApp Number) are not configured in environment variables.' ) } const client = new Twilio(accountSid, authToken) interface SendWhatsAppMessageArgs { to: string // Recipient number, e.g., +15551234567 body?: string // Message text content (optional if mediaUrl provided) mediaUrl?: string // Optional URL for media attachment } // Renamed to avoid conflict with resolver export name const sendWhatsAppMessageInternal = async ({ to, body, mediaUrl, }: SendWhatsAppMessageArgs): Promise<MessageInstance> => { logger.info( { targetNumber: to, mediaUrl: mediaUrl }, `Attempting to send WhatsApp message via Twilio` ) // Ensure recipient number is prefixed correctly for WhatsApp const formattedTo = to.startsWith('whatsapp:') ? to : `whatsapp:${to}` const formattedFrom = twilioWhatsAppNumber // Already includes 'whatsapp:' prefix from .env try { // Construct message payload const messageData: { from: string to: string body?: string mediaUrl?: string[] } = { from: formattedFrom, to: formattedTo, } // --- WhatsApp Media/Body Rules Handling --- // Rule: Cannot have body if media is video, audio, document, contact, or location. // Image/PDFs *can* have captions (sent as 'body'). // This code *prioritizes mediaUrl* and omits body if mediaUrl exists. if (mediaUrl) { messageData.mediaUrl = [mediaUrl] // To send a caption *with* an image/PDF: // 1. Verify the mediaUrl points to a supported type (image/PDF). // 2. Modify the logic here to *include* `messageData.body = body` // if `body` is provided *and* the media type supports captions. // Example modification (pseudo-code): // if (mediaUrl) { // messageData.mediaUrl = [mediaUrl]; // if (body && isImageType(mediaUrl)) { // Check if media type allows caption // messageData.body = body; // } // } else if (body) { ... } } else if (body) { // Only set body if no mediaUrl is provided (current behavior) messageData.body = body } else { throw new Error('Message must have either body or mediaUrl.') } // --- End Handling --- const message = await client.messages.create(messageData) logger.info( { messageSid: message.sid, status: message.status }, 'Successfully sent WhatsApp message via Twilio' ) // Optional: Log message to database here (See Section 6) return message } catch (error) { logger.error({ error, targetNumber: to }, 'Failed to send WhatsApp message') // Consider re-throwing or returning a specific error structure throw new Error(`Twilio API Error: ${error.message}`) } } // Keep the default generated service functions or remove if unused // export const twilios = () => { ... } // export const twilio = ({ id }) => { ... } // ... etc ...Supported Media Types and Limits:
Why This Approach?
whatsapp:prefix is correctly appliedmediaUrlparameter and notes WhatsApp constraints, clarifying the caption behavior3. Build a GraphQL API for WhatsApp Messaging
Expose the
sendWhatsAppMessageInternalfunctionality through Redwood's GraphQL API.Define GraphQL Schema (SDL): Create or update the GraphQL schema definition for Twilio operations. Open or create
api/src/graphql/twilio.sdl.ts:// api/src/graphql/twilio.sdl.ts export const schema = gql` type TwilioMessage { sid: String! status: String! to: String! from: String! body: String # Add other relevant fields from MessageInstance if needed } type Mutation { """Sends a WhatsApp message using Twilio. Requires authentication.""" sendWhatsAppMessage(to: String!, body: String, mediaUrl: String): TwilioMessage! @requireAuth # Note: Sending body AND mediaUrl behavior depends on the service logic # and WhatsApp rules for the specific media type. See Section 2. } `Input Validation Requirements:
to+15551234567bodymediaUrlprovided; max 1,600 characters"Hello from Twilio"mediaUrl"https://example.com/image.jpg"Implement the Resolver (Direct Mapping): Redwood convention maps the GraphQL mutation name (
sendWhatsAppMessage) directly to an exported function with the same name in the corresponding service file (api/src/services/twilio/twilio.ts).Modify
api/src/services/twilio/twilio.tsto add the resolver function:// api/src/services/twilio/twilio.ts import { Twilio } from 'twilio' import type { MessageInstance } from 'twilio/lib/rest/api/v2010/account/message' import { requireAuth } from 'src/lib/auth' // Import Redwood's auth helper import { logger } from 'src/lib/logger' import { db } from 'src/lib/db' // Import Prisma client import { Prisma } from '@prisma/client' // Import Prisma types // ... (Twilio client initialization and sendWhatsAppMessageInternal function from previous step) ... interface SendWhatsAppMessageInput { // Input type for the resolver to: string body?: string // Optional: depends on whether mediaUrl is also sent mediaUrl?: string } // Internal function (ensure it's not exported if only used internally) const sendWhatsAppMessageInternal = async ({ to, body, mediaUrl, }: SendWhatsAppMessageArgs): Promise<MessageInstance> => { logger.info({ targetNumber: to, mediaUrl }, `Internal send attempt`) const formattedTo = to.startsWith('whatsapp:') ? to : `whatsapp:${to}` const formattedFrom = twilioWhatsAppNumber if (!formattedFrom) { throw new Error('Twilio WhatsApp Number not configured.') } try { const messageData: any = { from: formattedFrom, to: formattedTo } // --- Media/Body Logic (as described in Section 2) --- if (mediaUrl) { messageData.mediaUrl = [mediaUrl] // If logic modified to allow captions: // if (body && isImageType(mediaUrl)) { messageData.body = body; } } else if (body) { messageData.body = body } else { throw new Error('Message must have either body or mediaUrl.') } // --- End Logic --- const message = await client.messages.create(messageData) logger.info({ messageSid: message.sid, status: message.status }, 'Internal send success') return message } catch (error) { logger.error({ error, targetNumber: to }, 'Internal send failed') throw new Error(`Twilio API Error: ${error.message}`) } } // --- Resolver Implementation --- // This function name matches the 'sendWhatsAppMessage' mutation in the SDL. // Redwood automatically uses this as the resolver. export const sendWhatsAppMessage = async ({ to, body, mediaUrl, }: SendWhatsAppMessageInput): Promise<Partial<MessageInstance>> => { // Return type matches GraphQL type fields requireAuth() // Ensure user is authenticated logger.info({ targetNumber: to, mediaUrl }, `Attempting send via GraphQL mutation`) // Optional: Add more validation here (e.g., phone number format check – see Section 7) try { // Call the internal function that interacts with Twilio const message = await sendWhatsAppMessageInternal({ to, body, mediaUrl }) logger.info({ messageSid: message.sid, status: message.status }, 'Sent via GraphQL') // --- Add DB Logging (See Section 6) --- try { await db.messageLog.create({ data: { twilioSid: message.sid, status: message.status, direction: 'outbound', fromNumber: message.from, toNumber: message.to, body: message.body, // Will be null if only media sent & captions not enabled mediaUrl: mediaUrl, // Log the URL we attempted to send numSegments: message.numSegments ? parseInt(message.numSegments) : null, price: message.price ? new Prisma.Decimal(message.price) : null, priceUnit: message.priceUnit, errorCode: message.errorCode, errorMessage: message.errorMessage, // userId: context.currentUser?.id // Uncomment if User relation exists }, }) logger.info({ twilioSid: message.sid }, 'Outbound message logged to DB') } catch (dbError) { logger.error({ dbError, twilioSid: message.sid }, 'Failed to log outbound message to DB') // Decide if DB log failure should fail the mutation } // --- End DB Logging --- // Return only the fields defined in the GraphQL 'TwilioMessage' type return { sid: message.sid, status: message.status, to: message.to, from: message.from, body: message.body, } } catch (error) { logger.error({ error, targetNumber: to }, 'Failed GraphQL send') // Let Redwood handle throwing the error to the client throw error // Re-throw the original error (or a more specific GraphQL error) } } // Remove or keep other generated service functions as neededAuthentication: The
@requireAuthdirective in the SDL ensures only authenticated users can call this mutation. Set up Redwood's authentication (e.g.,yarn rw setup auth dbAuth). See the RedwoodJS Auth Docs for setup instructions.Test the Mutation:
yarn rw devhttp://localhost:8911/graphql)loginmutation from your auth setup, or temporarily remove@requireAuthfor initial testing (remember to add it back)# Example sending text only mutation SendWAMessageText { sendWhatsAppMessage(to: "+15551234567", body: "Hello from RedwoodJS!") { # Replace with your test number sid status to from body } } # Example sending media only (current default behavior) mutation SendWAMessageMedia { sendWhatsAppMessage(to: "+15551234567", mediaUrl: "https://images.unsplash.com/photo-1518717758536-85ae29035b6d?ixlib=rb-1.2.1&auto=format&fit=crop&w=668&q=80") { # Replace with your test number sid status to from body # Body will likely be null here based on default logic } } # Example sending media *and* caption (requires modifying service logic as per Section 2) # mutation SendWAMessageMediaWithCaption { # sendWhatsAppMessage( # to: "+15551234567", # body: "Here is a cute dog!", # mediaUrl: "https://images.unsplash.com/photo-1518717758536-85ae29035b6d?ixlib=rb-1.2.1&auto=format&fit=crop&w=668&q=80" # ) { # sid # status # to # from # body # Should contain the caption if logic is updated # } # }Check your WhatsApp for the message and verify the behavior matches your service logic.
Expected Responses:
Success:
{ "data": { "sendWhatsAppMessage": { "sid": "SM1234567890abcdef", "status": "queued", "to": "whatsapp:+15551234567", "from": "whatsapp:+14155238886", "body": "Hello from RedwoodJS!" } } }Common Errors:
TWILIO_ACCOUNT_SIDandTWILIO_AUTH_TOKEN4. Configure Twilio Webhooks for Incoming WhatsApp Messages
Set up Twilio Console and create the RedwoodJS function to handle incoming messages.
Twilio Account Setup Recap:
Messaging→Try it out→Send a WhatsApp message. Follow the instructions to select a Sandbox number and activate itjoin <your-keyword>) from your personal WhatsApp number to your Twilio Sandbox number. You'll receive a confirmation. This is required for the Sandbox to send messages to your number and for you to send messages from your number to the SandboxAccount SIDandAuth Tokenfrom the Console dashboard. Note yourTwilio Sandbox WhatsApp Number(e.g.,whatsapp:+14155238886). These should already be in your.envfileCreate RedwoodJS Webhook Function: Redwood functions handle HTTP requests directly. Create one to receive POST requests from Twilio when a message arrives.
yarn rw g function whatsappWebhook --typescript # or --javascriptThis creates
api/src/functions/whatsappWebhook.ts.Implement Webhook Logic: This function parses the incoming request body, validates the request came from Twilio, processes the message, and responds with TwiML (Twilio Markup Language).
// api/src/functions/whatsappWebhook.ts import type { APIGatewayEvent, Context } from 'aws-lambda' import { URLSearchParams } from 'url' // Node.js built-in import { validateRequest } from 'twilio' // For request validation import { MessagingResponse } from 'twilio.twiml' // For TwiML replies import { logger } from 'src/lib/logger' import { db } from 'src/lib/db' // Import DB client import { Prisma } from '@prisma/client' // For Decimal type if needed export const handler = async (event: APIGatewayEvent, context: Context) => { logger.info('Incoming WhatsApp Webhook Request') // 1. Validate Request Signature (Security – See Section 7) const twilioSignature = event.headers['x-twilio-signature'] const webhookUrl = process.env.WEBHOOK_URL // You *must* define this ENV var const rawBody = event.body // Raw body needed for validation if (!process.env.TWILIO_AUTH_TOKEN) { logger.error('Twilio Auth Token not configured. Cannot validate request.') return { statusCode: 500, body: 'Internal configuration error.' } } if (!webhookUrl) { logger.error('WEBHOOK_URL environment variable not set. Cannot validate request.') return { statusCode: 500, body: 'Internal configuration error.' } } if (!twilioSignature) { logger.warn('Request received without X-Twilio-Signature header.') return { statusCode: 400, body: 'Missing signature.' } // Bad Request } // IMPORTANT: Twilio validation requires the *exact* URL Twilio used to call your webhook. // Using an ENV var set during deployment (or via ngrok for local dev) is the most reliable way. // Constructing it dynamically can fail behind proxies/gateways. // Log headers/path/domain in dev if validation fails unexpectedly to debug the URL structure. logger.debug({ headers: event.headers, path: event.path, url: webhookUrl }, 'Webhook details for validation'); let requestIsValid = false; try { requestIsValid = validateRequest( process.env.TWILIO_AUTH_TOKEN, twilioSignature, webhookUrl, // Use the ENV var rawBody || '' // Pass the raw body string, ensure it's not null/undefined ); } catch (validationError) { logger.error({ error: validationError }, 'Error during Twilio signature validation process.'); // Treat validation error as invalid request for security requestIsValid = false; } // --- Validation Check --- // STRONGLY RECOMMENDED: Enforce validation even in development. // Bypassing this check (`process.env.NODE_ENV === 'development'`) is risky // as it prevents testing a critical security feature locally. // Ensure your ngrok setup and WEBHOOK_URL env var are correct to make validation work. if (!requestIsValid) { logger.error('Invalid Twilio signature. Request denied.') return { statusCode: 403, // Forbidden body: 'Invalid Twilio signature.', } } logger.info('Twilio signature validation passed.'); // 2. Parse the incoming request body (Twilio sends form-urlencoded) const bodyParams = new URLSearchParams(event.body || '') const from = bodyParams.get('From') // Sender's WhatsApp number (whatsapp:+1...) const to = bodyParams.get('To') // Your Twilio number (whatsapp:+1...) const messageBody = bodyParams.get('Body') // Text content const messageSid = bodyParams.get('MessageSid') // Twilio Message SID const numMedia = parseInt(bodyParams.get('NumMedia') || '0', 10) // Number of media items logger.info( { from, to, messageSid, body: messageBody, numMedia }, 'Received WhatsApp message details' ) // 3. Process Media (if any) const mediaItems: { url: string; contentType: string | null }[] = [] if (numMedia > 0) { for (let i = 0; i < numMedia; i++) { const mediaUrl = bodyParams.get(`MediaUrl${i}`) const contentType = bodyParams.get(`MediaContentType${i}`) if (mediaUrl) { mediaItems.push({ url: mediaUrl, contentType }) logger.info( { index: i, url: mediaUrl, contentType }, 'Received media item' ) } } } // 4. Log to Database (See Section 6) if (messageSid) { // Only log if we have a SID try { await db.messageLog.create({ data: { twilioSid: messageSid, status: 'received', // Set initial status for inbound direction: 'inbound', fromNumber: from, // Sender's number toNumber: to, // Your Twilio number body: messageBody, mediaUrl: mediaItems.length > 0 ? mediaItems[0].url : null, // Log first media URL // Other fields like price/segments usually aren't relevant for inbound }, }) logger.info({ twilioSid: messageSid }, 'Inbound message logged to DB') } catch (dbError) { // Log errors, but generally don't fail the webhook response for DB errors // unless absolutely necessary. Twilio expects a quick 200 OK. logger.error({ dbError, twilioSid: messageSid }, 'Failed to log inbound message to DB') } } else { logger.warn('No MessageSid found in webhook payload, skipping DB log.') } // 5. Prepare TwiML Response (Example: Simple Echo Bot) const twiml = new MessagingResponse() if (numMedia > 0) { // Example: Reply if media was received twiml.message('Thanks for sending the media!') // Example: Send back a fixed image // twiml // .message() // .media( // 'https://images.unsplash.com/photo-1518717758536-85ae29035b6d?ixlib=rb-1.2.1&auto=format&fit=crop&w=1350&q=80' // ) // Example media URL } else if (messageBody) { // Simple echo for text messages twiml.message(`You said: ${messageBody}`) } else { // Fallback if message is empty (e.g., location message without text) twiml.message('Received your message.') } // 6. Return Response to Twilio return { statusCode: 200, headers: { 'Content-Type': 'text/xml' }, body: twiml.toString(), // Convert TwiML object to XML string } }Key Points:
WEBHOOK_URLis correct for localngroktesting. Includes a try-catch aroundvalidateRequestURLSearchParamsto parse the form-encoded data, handling potentially null bodytwilio.twiml.MessagingResponseto build the XML responseNumMedia> 0Expose Webhook Locally with ngrok:
yarn rw dev). Your function is available athttp://localhost:8911/whatsappWebhookhttps://<unique-id>.ngrok.io). Copy this URLWEBHOOK_URL: Add the full function URL to your.envfile. This is crucial for local validation testing. Restartyarn rw devafter changing.envConfigure Twilio Webhook URL:
https://<unique-id>.ngrok.io/whatsappWebhook)HTTP POSTSaveTest Incoming Messages:
yarn rw devfor logs. Check if validation passes (it should ifWEBHOOK_URLmatches the ngrok URL)http://localhost:4040) for request details if validation failsTroubleshooting Webhook Validation:
WEBHOOK_URLdoesn't match ngrok URL.envmatches ngrok outputTWILIO_AUTH_TOKENis correct in.env5. Implement Error Handling and Retry Logic for WhatsApp Messages
Build robust applications with proper error handling and logging.
Error Handling Strategy:
try...catchblockslogger.error()Logging:
logger(info,debug,warn,error). Configure levels inapi/src/lib/logger.tsRetry Mechanisms (Conceptual):
MessageSidin DB before processing) to handle retries safelyclient.messages.createfailures (network, temporary Twilio issues). Use libraries likeasync-retryorp-retry. Consider background job queues (BullMQ, SQS) for high volume/reliabilityExample using
async-retry(install withyarn workspace api add async-retry @types/async-retry). Note: For a more actively maintained alternative, considerp-retry(v7.0.0, updated 2024).// api/src/services/twilio/twilio.ts (Inside sendWhatsAppMessageInternal function) import retry from 'async-retry'; import { Twilio } from 'twilio'; // Ensure Twilio types are available if needed for error checking // ... inside try block where client.messages.create is called ... const message = await retry( async (bail, attemptNumber) => { // Added attemptNumber for logging logger.info(`Attempting Twilio API call, attempt number: ${attemptNumber}`); try { const result = await client.messages.create(messageData); logger.info(`Twilio API call successful on attempt ${attemptNumber}.`); return result; } catch (error: any) { // Use 'any' or a more specific error type if available // Don't retry on non-recoverable errors (e.g., bad request, auth failure) // Twilio errors often have a 'status' property if (error.status === 400 || error.status === 401 || error.status === 404) { logger.warn({ status: error.status, message: error.message }, 'Unrecoverable Twilio error, not retrying.'); bail(error); // Stop retrying by calling bail return; // Needed for type checking / control flow } logger.warn({ attempt: attemptNumber, message: error.message, status: error.status }, 'Twilio API call failed, retrying…'); throw error; // Throw error to signal retry is needed } }, { retries: 3, // Number of retries minTimeout: 1000, // Initial delay 1 s factor: 2, // Delay multiplier (1 s, 2 s, 4 s) onRetry: (error, attempt) => { logger.warn(`Retrying Twilio API call. Attempt ${attempt}. Error: ${error.message}`); } } ); // ... rest of function ...6. Create a Database Schema for WhatsApp Message Logging
Log message details to the database using Prisma.
Define Prisma Schema: Open
api/db/schema.prismaand add aMessageLogmodel:Common Queries for Reporting:
// Get all messages for a specific user const userMessages = await db.messageLog.findMany({ where: { userId: 123 }, orderBy: { createdAt: 'desc' } }) // Get failed outbound messages const failedMessages = await db.messageLog.findMany({ where: { direction: 'outbound', status: 'failed' } }) // Calculate total message cost const totalCost = await db.messageLog.aggregate({ _sum: { price: true }, where: { direction: 'outbound' } })Migrate Database: Apply the schema changes to your database:
This creates a new migration file and updates your database schema.
Integrate Logging: We already added the database logging logic in the
sendWhatsAppMessageresolver (Section 3) and thewhatsappWebhookfunction (Section 4). Ensure thedb.messageLog.createcalls correctly map the available data (likemessage.sid,status,direction, etc.) to the corresponding fields in yourMessageLogmodel.sendWhatsAppMessageresolver): Logs details after a successful API call to TwiliowhatsappWebhookhandler): Logs details parsed from the incoming webhook requestReview the
db.messageLog.createcalls inapi/src/services/twilio/twilio.tsandapi/src/functions/whatsappWebhook.tsto confirm they align with the finalMessageLogmodel definition. Pay attention to optional fields and data types (likeDecimalforprice).7. Follow Security Best Practices for WhatsApp Integration
Protect your application and user data.
Security Checklist:
twilio.validateRequest@requireAuthWebhook Request Validation:
twilio.validateRequestas shown in Section 4. This verifies the request genuinely originated from Twilio using your Auth Token as a secret keyWEBHOOK_URLEnvironment Variable: Use an environment variable (WEBHOOK_URL) set during deployment (or via ngrok locally) to provide the exact URL for validation. Constructing it dynamically is unreliableEnvironment Variables:
.envout of version control (.gitignore)Authentication & Authorization:
sendWhatsAppMessagewith@requireAuth(or role-based directives like@requireAuth(roles: ["admin"])) as shown in Section 3. Ensure your Redwood auth is properly configuredInput Validation:
tonumbers in the GraphQL mutation using libraries likelibphonenumber-jsto ensure proper E.164 format and prevent errors or potential abusebodycontent if it's user-generated to prevent injection attacks (though less common via WhatsApp text, still good practice)mediaUrlfrom users, validate that the URL points to expected domains or content types to prevent Server-Side Request Forgery (SSRF) or abuseRate Limiting:
graphql-shieldor API gateway features) to prevent abuse of thesendWhatsAppMessagemutationLogging Sensitive Data:
8. Deploy Your RedwoodJS WhatsApp Application to Production
Deploy your RedwoodJS application with proper configuration and public webhook access.
Deployment Hosting Options:
Choose a Hosting Provider: RedwoodJS supports various platforms like Vercel, Netlify, Render, AWS Serverless, etc. See the RedwoodJS Deployment Docs.
Configure Environment Variables:
TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN, andTWILIO_WHATSAPP_NUMBERin your hosting provider's environment variable settingsWEBHOOK_URLenvironment variable to the final, deployed URL of yourwhatsappWebhookfunction (e.g.,https://your-app-domain.com/api/functions/whatsappWebhook). This is needed for Twilio request validation in productionDATABASE_URLfor your production databaseBuild and Deploy: Follow your chosen provider's deployment process. Typically:
yarn rw build # ... provider-specific deployment command (e.g., vercel deploy, netlify deploy) ...Run Database Migrations: After deployment, run Prisma migrations against your production database:
Update Twilio Webhook URL:
WEBHOOK_URLTest in Production:
Frequently Asked Questions About Twilio WhatsApp Integration with RedwoodJS
Do I need a WhatsApp Business account for Twilio integration?
For the Twilio Sandbox (development/testing), you don't need a separate WhatsApp Business account. However, for production use with your own phone number, you need a Facebook Business Manager account linked to a WhatsApp Business Account. Meta requires business verification for accounts handling more than 2 phone numbers.
What is the 24-hour customer service window in WhatsApp?
When a user sends your business a WhatsApp message, you have 24 hours to reply with free-form messages without requiring pre-approved templates. After the 24-hour window expires, you can only send messages using pre-approved message templates. Template approval takes up to 48 hours.
How do I validate Twilio webhook requests in RedwoodJS?
Use the
twilio.validateRequest()function with your Auth Token, the incomingX-Twilio-Signatureheader, your webhook URL (from environment variable), and the raw request body. Always validate requests to prevent unauthorized access. Set theWEBHOOK_URLenvironment variable to your deployed function URL for reliable validation.Can I send images and videos through Twilio WhatsApp API?
Yes, Twilio supports sending media through WhatsApp including images, videos, PDFs, audio files, and documents. Use the
mediaUrlparameter in your message payload. Images and PDFs can include captions (sent as thebodyparameter), but videos, audio, and documents cannot have accompanying text.What Node.js version does RedwoodJS require for WhatsApp integration?
RedwoodJS v8.x requires Node.js v20 or later. If you're running Node.js v21.0.0 or higher, ensure compatibility with your deployment target, as some platforms like AWS Lambda may have restrictions on newer Node versions.
How do I handle message delivery failures in production?
Implement retry logic with exponential backoff using libraries like
async-retryorp-retry. Make your webhook idempotent by checkingMessageSidin your database before processing. Use background job queues (BullMQ, SQS) for high-volume scenarios. Configure Twilio status callbacks to track delivery status asynchronously.What are the rate limits for Twilio WhatsApp messages?
Rate limits depend on your phone number type and Meta's sending limits. Twilio Sandbox numbers have restricted rates for testing. Production numbers start with lower limits (250 messages/day) and increase based on message quality ratings. High-quality conversations can unlock higher tiers (1,000, 10,000, or 100,000+ messages per day).
How much does it cost to send WhatsApp messages through Twilio?
WhatsApp charges conversation-based pricing starting at $0.005 – $0.10 per conversation depending on the region and message category (Marketing, Utility, or Authentication). Messages within the 24-hour customer service window are free. Utility templates sent during the service window don't incur Meta fees (as of July 2025). Check Twilio's WhatsApp pricing page for current rates.
Conclusion
You have successfully integrated Twilio WhatsApp messaging into your RedwoodJS application. This setup allows you to send outbound messages via a secure GraphQL API and process inbound messages using a validated webhook, complete with database logging and basic media handling.
Further Enhancements:
sent,delivered,failed) asynchronouslyMessageLogentries to specificUserrecords using theuserIdfieldwebside to interact with thesendWhatsAppMessageGraphQL mutation