code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Article

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:

mermaid
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:

RequirementVersion/Details
Node.jsv20 or later (RedwoodJS v8.x requires >=20.x)
Yarnv1.22.21 or later
RedwoodJS CLIInstall with yarn global add redwoodjs-cli
Twilio AccountWith activated WhatsApp Sandbox
ngrokFor local development webhook testing
WhatsApp AccountPersonal 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.

  1. 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.

  2. Install Twilio SDK: Navigate to the API workspace and install the Twilio Node.js helper library:

    bash
    yarn workspace api add twilio
  3. 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:

    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 format

    Important: Add .env to your .gitignore file to prevent committing secrets. Redwood's template usually includes this.

  4. RedwoodJS Architecture Reminder:

    • web/: Frontend code (React components, pages, layouts)
    • api/: Backend code (GraphQL schema, services, functions, database schema)
    • 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.

  1. 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).

  2. Implement sendWhatsAppMessageInternal Function: Open api/src/services/twilio/twilio.ts and 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 TypeMax SizeCaption Support
    Images (JPEG, PNG)5 MBYes
    PDFs100 MBYes
    Videos16 MBNo
    Audio16 MBNo
    Documents100 MBNo

    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 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.

  1. 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:

    FieldValidationExample
    toE.164 format phone number+15551234567
    bodyOptional if mediaUrl provided; max 1,600 characters"Hello from Twilio"
    mediaUrlValid HTTPS URL to supported media type"https://example.com/image.jpg"
  2. 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.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
  3. 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.

  4. 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 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 CodeMeaningSolution
    21211Invalid 'To' phone numberVerify E.164 format (+country code)
    21408Recipient not opted inSend join code to Sandbox number
    20003Authentication failedCheck TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN
    21610Unverified numberVerify 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.

  1. Twilio Account Setup Recap:

    • Sign Up/Log In: Access the Twilio Console
    • Activate WhatsApp Sandbox: Navigate to MessagingTry it outSend 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
  2. 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.

  3. 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_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
  4. 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
      plaintext
      # .env
      # ... other vars
      WEBHOOK_URL=https://<unique-id>.ngrok.io/whatsappWebhook
  5. 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
  6. 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:

    IssueCauseSolution
    Validation fails locallyWEBHOOK_URL doesn't match ngrok URLVerify exact URL in .env matches ngrok output
    403 ForbiddenSignature mismatchCheck TWILIO_AUTH_TOKEN is correct in .env
    No webhook receivedTwilio not configuredVerify webhook URL in Twilio Console Sandbox settings
    TimeoutSlow processingReturn 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:

LayerApproachImplementation
Services/FunctionsUse try...catch blocksWrap API calls and DB operations
LoggingUse Redwood's logger.error()Provide context in catch blocks
GraphQL ErrorsLet errors bubble upRedwood formats them; throw specific errors if needed
Webhook ErrorsReturn 200 OK quicklyLog errors internally; avoid Twilio retries
  1. 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
  2. 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 = 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.

  1. 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 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' }
    })
  2. 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.

  3. 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:

PriorityPracticeImplementation
CriticalWebhook Request ValidationAlways use twilio.validateRequest
CriticalHTTPS OnlyUse HTTPS for webhook URL (ngrok provides this locally)
CriticalEnvironment VariablesNever commit secrets; use platform secrets in production
HighGraphQL AuthenticationProtect mutations with @requireAuth
HighInput ValidationValidate phone numbers, message content, media URLs
MediumRate LimitingImplement on GraphQL API and webhook endpoints
MediumLogging Sensitive DataAvoid logging PII; mask sensitive data
  1. 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
  2. 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)
  3. 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
  4. 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
  5. 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
  6. 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:

ProviderBest ForKey Features
VercelQuick deployment, serverlessAuto-scaling, edge functions, zero config
NetlifyJAMstack apps, static sitesCDN, branch previews, instant rollbacks
RenderFull-stack apps, databasesManaged PostgreSQL, automatic HTTPS
AWS ServerlessEnterprise, custom infrastructureLambda functions, API Gateway, full AWS integration
  1. Choose a Hosting Provider: RedwoodJS supports various platforms like Vercel, Netlify, Render, AWS Serverless, etc. See the RedwoodJS Deployment Docs.

  2. 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
  3. Build and Deploy: Follow your chosen provider's deployment process. Typically:

    bash
    yarn rw build
    # ... provider-specific deployment command (e.g., vercel deploy, netlify deploy) ...
  4. Run Database Migrations: After deployment, run Prisma migrations against your production database:

    bash
    yarn rw prisma migrate deploy
  5. 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
  6. 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

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.