code examples
code examples
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.
Twilio WhatsApp Integration with RedwoodJS
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:
bashyarn 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:
bashyarn workspace api add twilio -
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:
plaintext# .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 formatImportant: 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)- Environment variables defined in
.envare automatically available in theapiside viaprocess.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:
bashyarn rw g service twilioThis creates
api/src/services/twilio/twilio.ts(and corresponding test/scenario files). -
Implement
sendWhatsAppMessageInternalFunction: Openapi/src/services/twilio/twilio.tsand add the following code:typescript// 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:
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
- Environment Variables: Securely handles credentials
- 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
mediaUrlparameter 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.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:
Field Validation Example toE.164 format phone number +15551234567bodyOptional if mediaUrlprovided; max 1,600 characters"Hello from Twilio"mediaUrlValid 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.tsto add the resolver function:typescript// 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 needed -
Authentication: 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:
- Start your development server:
yarn rw dev - Open the GraphQL playground (usually
http://localhost:8911/graphql) - Log in using the
loginmutation from your auth setup, or temporarily remove@requireAuthfor initial testing (remember to add it back) - Execute the mutation:
graphql# 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:
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_SIDandTWILIO_AUTH_TOKEN21610 Unverified number Verify number in Twilio Console for trial accounts - Start your development server:
4. Configure Twilio Webhooks for Incoming WhatsApp Messages
Set up Twilio Console and create the RedwoodJS function to handle incoming messages.
-
Twilio Account Setup Recap:
- Sign Up/Log In: Access the Twilio Console
- 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 SIDandAuth Tokenfrom the Console dashboard. Note yourTwilio Sandbox WhatsApp Number(e.g.,whatsapp:+14155238886). These should already be in your.envfile
-
Create RedwoodJS Webhook Function: Redwood functions handle HTTP requests directly. Create one to receive POST requests from Twilio when a message arrives.
bashyarn 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).
typescript// 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:
- Request Validation: Enforces validation by default. Ensure
WEBHOOK_URLis correct for localngroktesting. Includes a try-catch aroundvalidateRequest - Parsing: Uses
URLSearchParamsto parse the form-encoded data, handling potentially null body - TwiML Response: Uses
twilio.twiml.MessagingResponseto build the XML response - Media Handling: Iterates through media parameters if
NumMedia> 0
- Request Validation: Enforces validation by default. Ensure
-
Expose Webhook Locally with ngrok:
- Ensure your Redwood dev server is running (
yarn rw dev). Your function is available athttp://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.envfile. This is crucial for local validation testing. Restartyarn rw devafter changing.envplaintext# .env # ... other vars WEBHOOK_URL=https://<unique-id>.ngrok.io/whatsappWebhook
- Ensure your Redwood dev server is running (
-
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 devfor logs. Check if validation passes (it should ifWEBHOOK_URLmatches 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_URLdoesn't match ngrok URLVerify exact URL in .envmatches ngrok output403 Forbidden Signature mismatch Check TWILIO_AUTH_TOKENis correct in.envNo 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 inapi/src/lib/logger.ts - Log key events, errors with context, and debug data (avoid logging sensitive information)
- Use log analysis tools in production
- Use Redwood's
-
Retry Mechanisms (Conceptual):
- Twilio Retries (Webhook): Twilio retries on non-200 responses. Make your webhook idempotent (check
MessageSidin DB before processing) to handle retries safely - Outbound Message Retries: Implement retries with exponential backoff in your service for
client.messages.createfailures (network, temporary Twilio issues). Use libraries likeasync-retryorp-retry. Consider background job queues (BullMQ, SQS) for high volume/reliability
Example 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).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 = 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 ... - Twilio Retries (Webhook): Twilio retries on non-200 responses. Make your webhook idempotent (check
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: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 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:
bashyarn rw prisma migrate dev --name add_message_logThis 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.- Outbound Logging (in
sendWhatsAppMessageresolver): Logs details after a successful API call to Twilio - Inbound Logging (in
whatsappWebhookhandler): Logs details parsed from the incoming webhook request
Review 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). - Outbound Logging (in
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.validateRequestas shown in Section 4. This verifies the request genuinely originated from Twilio using your Auth Token as a secret key WEBHOOK_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 unreliable- HTTPS: Always use HTTPS for your webhook URL. ngrok provides this locally; ensure your production deployment uses HTTPS
- Mandatory: Always validate incoming webhook requests using
-
Environment Variables:
- Never commit secrets: Keep
.envout of version control (.gitignore) - Use secure environment variable management in production (e.g., platform secrets, HashiCorp Vault)
- Never commit secrets: Keep
-
Authentication & Authorization:
- GraphQL Mutations: Protect mutations like
sendWhatsAppMessagewith@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
- GraphQL Mutations: Protect mutations like
-
Input Validation:
- Phone Numbers: Validate
tonumbers in the GraphQL mutation using libraries likelibphonenumber-jsto ensure proper E.164 format and prevent errors or potential abuse - Message Content: Sanitize or validate message
bodycontent if it's user-generated to prevent injection attacks (though less common via WhatsApp text, still good practice) - Media URLs: If accepting
mediaUrlfrom users, validate that the URL points to expected domains or content types to prevent Server-Side Request Forgery (SSRF) or abuse
- Phone Numbers: Validate
-
Rate Limiting:
- GraphQL API: Implement rate limiting on your GraphQL endpoint (e.g., using
graphql-shieldor API gateway features) to prevent abuse of thesendWhatsAppMessagemutation - Webhook: While Twilio validation helps, consider rate limiting incoming webhook calls if you experience high traffic or abuse patterns
- GraphQL API: Implement rate limiting on your GraphQL endpoint (e.g., using
-
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, andTWILIO_WHATSAPP_NUMBERin your hosting provider's environment variable settings - Crucially: Set the
WEBHOOK_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 production - Set
DATABASE_URLfor your production database
- Set
-
Build and Deploy: Follow your chosen provider's deployment process. Typically:
bashyarn rw build # ... provider-specific deployment command (e.g., vercel deploy, netlify deploy) ... -
Run Database Migrations: After deployment, run Prisma migrations against your production database:
bashyarn 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 production
WEBHOOK_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
MessageLogentries to specificUserrecords using theuserIdfield - Frontend Integration: Build React components in the
webside to interact with thesendWhatsAppMessageGraphQL 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
How to send WhatsApp messages with RedwoodJS?
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.
What is the purpose of using Twilio with RedwoodJS for WhatsApp?
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.
Why use RedwoodJS for a Twilio WhatsApp integration?
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.
When should I validate Twilio webhook requests in RedwoodJS?
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.
Can I send media messages via WhatsApp with this integration?
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.
How to set up a Twilio WhatsApp sandbox for RedwoodJS development?
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.
What is the role of a webhook in the Twilio/RedwoodJS WhatsApp setup?
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.
How to handle incoming WhatsApp messages in RedwoodJS?
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.
What is the recommended way to store Twilio credentials in a RedwoodJS project?
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.
How to log WhatsApp messages in RedwoodJS with Prisma?
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.
How can I test my Twilio WhatsApp integration during development?
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.
What are some best practices for error handling in this integration?
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.
Why is input validation important when integrating Twilio WhatsApp with RedwoodJS?
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).
How to deploy a RedwoodJS application with Twilio WhatsApp integration?
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.
What are some ways to enhance the security of my Twilio WhatsApp integration?
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.