code examples

Sent logo
Sent TeamMar 8, 2026 / code examples / Article

Plivo WhatsApp API Integration with RedwoodJS: Step-by-Step Tutorial 2025

Learn how to integrate Plivo WhatsApp Business API with RedwoodJS. Complete guide covering setup, sending template messages, webhook handling, GraphQL, and Prisma for Node.js 20 applications.

Integrate Plivo's WhatsApp Business API into your RedwoodJS application with this comprehensive tutorial. Learn how to send WhatsApp messages (including approved template messages), receive inbound messages via webhooks, and build a complete messaging system using GraphQL, Prisma, and Node.js 20. This integration enables businesses to leverage WhatsApp's 2+ billion users for customer communication, notifications, support, and engagement.

What You'll Build:

  • Set up a RedwoodJS project configured for Plivo WhatsApp API integration
  • Send text and templated WhatsApp messages via the Plivo API
  • Receive incoming WhatsApp messages through secure webhook endpoints
  • Store message history in a database using Prisma ORM
  • Implement error handling, logging, and security best practices
  • Test, deploy, and troubleshoot your WhatsApp integration

Time Estimate: 2–3 hours Skill Level: Intermediate (requires Node.js, GraphQL, and API experience)

Technologies Used:

  • RedwoodJS: Full-stack, serverless-friendly JavaScript/TypeScript framework (v8.8.1 current, v7.0.0+ required). Chosen for its conventions, integrated tooling (GraphQL, Prisma, Jest), and developer experience.
  • Plivo: Cloud communications platform providing APIs for SMS, Voice, and WhatsApp messaging. Chosen for its reliable WhatsApp Business API offering (as a registered Meta Solution Provider) and developer-friendly SDKs.
  • Node.js: Runtime environment for RedwoodJS. Requires Node.js 20.x (LTS Active until October 2026, Maintenance until April 2027).
  • Prisma: Next-generation ORM for Node.js and TypeScript (v6.16.3 current), used by RedwoodJS for database access.
  • GraphQL: Used by RedwoodJS for API layer communication between the frontend and backend.
  • WhatsApp Business API: The underlying Meta platform enabling programmatic messaging.

System Architecture:

text
+-----------------+      +---------------------+      +-----------------+      +----------------+      +-----------+
|  User / Client  |----->| RedwoodJS Frontend  |----->| RedwoodJS API   |----->|   Plivo API    |----->|  WhatsApp |
| (Web Interface) |      | (React Components)  |      | (GraphQL/Service)|      | (Send Message) |      |  Network  |
+-----------------+      +---------------------+      +-----------------+      +----------------+      +-----------+
        ^                                                    |  ^                          |  |
        |                                                    |  | (Save Message)           |  | (Webhook)
        | (Display Messages)                                 v  |                          v  |
        |                                              +----------+                  +-----------------+
        +----------------------------------------------|  Database |<-----------------| RedwoodJS API   |
                                                       | (Prisma) |                  | (Webhook Func)  |
                                                       +----------+                  +-----------------+

Flow:

DirectionFlow Description
SendingYour user triggers an action that calls the RedwoodJS API (GraphQL). The API service uses the Plivo Node.js SDK to send the message via Plivo's API, which relays it to WhatsApp. The system saves message details to the database.
ReceivingWhatsApp delivers incoming messages to Plivo. Plivo triggers a webhook POST request to your RedwoodJS API function endpoint. This function validates the request, processes the message, saves it to the database, and triggers any additional actions you define.
Error HandlingFailed messages generate error logs. Implement retry logic in your service layer and configure status callbacks in Plivo for delivery tracking.

Prerequisites:

  • Node.js 20.x (LTS Active until October 2026, Maintenance until April 2027) – RedwoodJS requires Node.js =20.x. Node.js 21+ may cause compatibility issues with some deploy targets like AWS Lambdas. Node.js 22.x (LTS Active until October 2025, Maintenance until April 2027) is also supported but verify compatibility with your deployment environment.
  • Yarn 1.22.21 or higher – RedwoodJS requires yarn >=1.22.21
  • A Plivo account (Sign up at Plivo) – Plivo is a registered Meta Solution Provider for WhatsApp Business API
  • A Meta Business Manager account with admin access – Required to set up WhatsApp Business Account (WABA)
  • A phone number capable of receiving SMS/voice calls (without IVR) for WhatsApp registration – Must be able to receive OTP for verification
  • Basic understanding of RedwoodJS, GraphQL, and Node.js.
  • ngrok or similar tunneling service for local webhook development. This is needed to expose your local RedwoodJS API server (running on port 8911 typically) to the public internet, allowing Plivo's webhook service to reach it. For production deployments, replace the ngrok URL with your application's stable public URL.

Cost Considerations:

Plivo charges per message sent/received. WhatsApp Business API pricing varies by country and message type (template vs. session messages). Review Plivo's WhatsApp Pricing and Meta's WhatsApp Business Pricing before deploying to production.


1. Set Up Your RedwoodJS Project

Create a new RedwoodJS project and install the required dependencies.

  1. Create RedwoodJS App: Open your terminal and run:

    bash
    yarn create redwood-app ./redwood-plivo-whatsapp
    cd redwood-plivo-whatsapp

    Follow the prompts (choose TypeScript or JavaScript). This guide uses TypeScript syntax where applicable, but concepts are the same for JavaScript. TypeScript is recommended for improved type safety and IDE support.

  2. Install Plivo SDK: Navigate to the API workspace and add the Plivo Node.js SDK:

    bash
    yarn workspace api add plivo

    Current SDK version: 4.x (verify compatibility with your Node.js version). Check Plivo Node.js SDK Releases for updates.

  3. Configure Environment Variables: Store your Plivo authentication credentials (AUTH_ID and AUTH_TOKEN) and registered WhatsApp number securely in environment variables.

    • Create a .env file in your project root:

      bash
      touch .env
    • Add the following variables to .env. Find your AUTH_ID and AUTH_TOKEN on the Plivo Console dashboard (https://console.plivo.com/dashboard/).

      Code
      # .env
      PLIVO_AUTH_ID=YOUR_PLIVO_AUTH_ID
      PLIVO_AUTH_TOKEN=YOUR_PLIVO_AUTH_TOKEN
      PLIVO_WHATSAPP_NUMBER=YOUR_PLIVO_WHATSAPP_ENABLED_NUMBER # e.g., +14155551234
    • Important: Add .env to your .gitignore file to prevent committing secrets. RedwoodJS's default .gitignore usually includes this.

    • Validate Environment Variables: Add startup validation in api/src/lib/env.ts:

      typescript
      // api/src/lib/env.ts
      export const validateEnv = () => {
        const required = ['PLIVO_AUTH_ID', 'PLIVO_AUTH_TOKEN', 'PLIVO_WHATSAPP_NUMBER']
        const missing = required.filter(key => !process.env[key])
        if (missing.length > 0) {
          throw new Error(`Missing required environment variables: ${missing.join(', ')}`)
        }
      }
    • Purpose of Variables:

      • PLIVO_AUTH_ID / PLIVO_AUTH_TOKEN: Used to authenticate requests to the Plivo API. Treat these like passwords.
      • PLIVO_WHATSAPP_NUMBER: The source phone number registered with Plivo/WhatsApp for sending messages. Must be in E.164 format.
  4. RedwoodJS Project Structure: Familiarize yourself with the key directories:

    • api/: Backend code (GraphQL API, services, functions, database).
      • api/src/functions/: Serverless functions (like our webhook).
      • api/src/services/: Business logic, interacts with database and external APIs (like Plivo).
      • api/src/graphql/: GraphQL schema definitions.
      • api/src/lib/: Shared library code (database client, logger).
      • api/db/: Database schema (schema.prisma) and migrations.
    • web/: Frontend code (React components, pages, layouts).

2. Connect Your WhatsApp Business Account to Plivo

Connect your WhatsApp Business Account (WABA) to Plivo before writing code. This section covers the official WhatsApp Business API setup process through Plivo's Meta Solution Provider integration.

  1. Plivo Account & Credits: Ensure your Plivo account is created and funded. New accounts receive $10 in trial credits for testing. Review Plivo Pricing for production costs.

  2. Connect WABA to Plivo: Follow Plivo's onboarding guide: Plivo's WhatsApp API: Onboarding Made Simple

    • Navigate to the WhatsApp section in the Plivo Console.
    • Initiate the Meta embedded signup flow.
    • Log in to your Facebook account linked to your Meta Business Manager (ensure you have 'Full Control' access).
    • Choose or create a WABA.
    • Define your WABA name (internal) and WhatsApp Business Display Name (customer-facing, follow guidelines).
    • Select your business category.
    • Provide and verify the phone number you want to use for WhatsApp.
    • Verify the connection in your Meta Business Manager settings under WhatsApp Accounts > Partners. Plivo should be listed.

    Common Troubleshooting:

    • Phone number already registered: Unregister the number from any existing WhatsApp accounts (personal or business).
    • Verification code not received: Ensure the number can receive SMS/voice calls without IVR systems.
    • Access denied: Verify you have 'Admin' or 'Full Control' permissions in Meta Business Manager.
  3. Create WhatsApp Message Templates (Crucial for Initiating Conversations): WhatsApp requires businesses to use pre-approved message templates to initiate conversations with users or send messages outside the 24-hour customer service window.

    • Go to your Meta Business Manager → WhatsApp Manager → Message Templates.
    • Create templates relevant to your use case (e.g., order confirmation, shipping update, appointment reminder). Choose appropriate categories (Marketing, Utility, Authentication).
    • Templates can include placeholders ({{1}}, {{2}}) for dynamic content and media headers.
    • Submit templates for approval (can take up to 24 hours).
    • Once approved in Meta, go to the Plivo Console → Messaging → WhatsApp Templates and click Sync Templates from WhatsApp to make them available via the Plivo API. Note the exact name and language code (e.g., order_confirmation, en_US) of your approved templates.

    Template Best Practices:

    • Use clear, actionable language that provides value to recipients
    • Keep templates concise (under 1024 characters for body text)
    • Use Utility category for transactional messages (higher approval rate)
    • Avoid promotional language in non-Marketing categories
    • Test templates with actual customer data formats before approval

    Example Template Structures:

    Template TypeCategoryStructure Example
    Order ConfirmationUtilityHeader: "Order Confirmed" / Body: "Hi {{1}}, your order {{2}} is confirmed. Delivery by {{3}}."
    Appointment ReminderUtilityHeader: Image/Video / Body: "Hi {{1}}, reminder: your appointment on {{2}} at {{3}}."
    OTP AuthenticationAuthenticationBody: "Your verification code is {{1}}. Valid for {{2}} minutes."
  4. Configure Plivo Webhook for Incoming Messages: To receive messages in RedwoodJS, Plivo needs an endpoint to send data to.

    • In the Plivo Console, go to Messaging → WhatsApp Numbers.
    • Select your configured WhatsApp number.
    • Find the "Messaging Settings" or "Application Configuration". You might need to create a Plivo "Application".
    • Set the Message URL to the endpoint we will create later. For local development, you'll need a public URL using ngrok.
      • Start ngrok: ngrok http 8911 (RedwoodJS API typically runs on 8911).
      • Copy the HTTPS forwarding URL provided by ngrok (e.g., https://<random_string>.ngrok.io).
      • Your development Message URL will be https://<random_string>.ngrok.io/api/whatsappWebhook.
    • Set the HTTP Method to POST.
    • Important: For production, use your deployed application's stable public URL.

    Security Considerations:

    • Always implement signature validation (covered in webhook section)
    • Use HTTPS endpoints only (required by Plivo)
    • Consider IP whitelisting in production (Plivo publishes webhook IP ranges)
    • Implement rate limiting to prevent abuse

    Alternative Tunneling Options:


3. Implement WhatsApp Message Sending and Receiving

Create a RedwoodJS service to handle sending messages and a function to handle incoming webhooks.

A. Sending WhatsApp Messages

  1. Generate Service: Create a service to encapsulate Plivo interactions.

    bash
    yarn rw g service whatsapp

    This creates api/src/services/whatsapp/whatsapp.ts and associated test/scenario files.

  2. Implement Sending Logic: Open api/src/services/whatsapp/whatsapp.ts and add the following:

    typescript
    // api/src/services/whatsapp/whatsapp.ts
    import { PlivoClient } from 'plivo-node';
    import type { MessageCreateResponse } from 'plivo-node/dist/resources/message'; // Adjust import path based on SDK version if needed
    
    import { db } from 'src/lib/db'; // Import Prisma client
    import { logger } from 'src/lib/logger'; // Redwood's logger
    
    // Initialize Plivo client (consider moving to lib for reuse)
    // Ensure environment variables are loaded. Redwood does this automatically.
    const plivoClient = new PlivoClient(
      process.env.PLIVO_AUTH_ID,
      process.env.PLIVO_AUTH_TOKEN
    );
    
    interface SendWhatsAppTextParams {
      to: string; // Recipient number in E.164 format
      text: string;
    }
    
    interface SendWhatsAppTemplateParams {
      to: string; // Recipient number in E.164 format
      templateName: string;
      languageCode: string; // e.g., 'en_US'
      headerComponents?: any[]; // Optional: For template header variables/media
      bodyComponents?: any[];   // Optional: For template body variables
      // Add buttonComponents if needed
    }
    
    /**
     * Validate phone number format (E.164)
     */
    const validatePhoneNumber = (phone: string): boolean => {
      const e164Regex = /^\+[1-9]\d{1,14}$/;
      return e164Regex.test(phone);
    };
    
    /**
     * Sanitize text input to prevent injection attacks
     */
    const sanitizeText = (text: string): string => {
      // Remove null bytes and control characters except newlines/tabs
      return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '').trim();
    };
    
    /**
     * Sends a standard text WhatsApp message.
     * Only works if within the 24-hour customer service window
     * or as a reply to an incoming message.
     */
    export const sendWhatsAppText = async ({
      to,
      text,
    }: SendWhatsAppTextParams): Promise<MessageCreateResponse | null> => {
      const sourceNumber = process.env.PLIVO_WHATSAPP_NUMBER;
      if (!sourceNumber) {
        logger.error('PLIVO_WHATSAPP_NUMBER environment variable not set.');
        throw new Error('WhatsApp source number not configured.');
      }
      if (!to || !text) {
        logger.error('Missing "to" or "text" parameter for sending WhatsApp text.');
        throw new Error('Recipient number and text message are required.');
      }
    
      // Validate and sanitize input
      if (!validatePhoneNumber(to)) {
        logger.error(`Invalid phone number format: ${to}`);
        throw new Error('Invalid phone number format. Use E.164 format (e.g., +14155551234).');
      }
      const sanitizedText = sanitizeText(text);
      if (sanitizedText.length === 0) {
        logger.error('Message text cannot be empty after sanitization.');
        throw new Error('Message text cannot be empty.');
      }
    
      logger.info(`Attempting to send WhatsApp text to ${to}`);
      try {
        const response = await plivoClient.messages.create(
          sourceNumber, // src
          to,           // dst
          sanitizedText, // text
          { type: 'whatsapp' }, // Specify WhatsApp channel
          // Optional: Add status callback URL if needed
          // 'https://<your-domain.com>/api/whatsappStatusCallback'
        );
    
        logger.info(`WhatsApp text message sent successfully to ${to}. Message UUID: ${response.messageUuid[0]}`);
    
        // Save message details to database
        try {
            await db.whatsAppMessage.create({
                data: {
                    plivoUuid: response.messageUuid[0],
                    fromNumber: sourceNumber,
                    toNumber: to,
                    body: sanitizedText,
                    direction: 'OUTBOUND',
                    status: 'queued', // Initial status
                }
            });
            logger.info(`Saved outbound text message ${response.messageUuid[0]} to database.`);
        } catch (dbError) {
            logger.error({ error: dbError, plivoUuid: response.messageUuid[0] }, 'Failed to save outbound text message to database');
            // Decide if failure to save should throw error or just be logged
        }
    
        return response;
      } catch (error) {
        logger.error({ error, to }, 'Failed to send WhatsApp text message via Plivo');
    
        // Handle specific Plivo error codes
        if (error.status) {
          switch (error.status) {
            case 401:
              throw new Error('Plivo authentication failed. Check your AUTH_ID and AUTH_TOKEN.');
            case 404:
              throw new Error('WhatsApp number not found or not configured in Plivo.');
            case 429:
              throw new Error('Rate limit exceeded. Implement retry logic with exponential backoff.');
            case 500:
            case 503:
              throw new Error('Plivo service error. Retry after a delay.');
            default:
              throw new Error(`Plivo API error (${error.status}): ${error.message}`);
          }
        }
    
        throw error; // Re-throw for the caller (e.g., GraphQL resolver) to handle
      }
    };
    
    /**
     * Sends a WhatsApp message using a pre-approved template.
     * Required for initiating conversations or sending outside the 24-hour window.
     */
    export const sendWhatsAppTemplate = async ({
      to,
      templateName,
      languageCode,
      headerComponents,
      bodyComponents,
    }: SendWhatsAppTemplateParams): Promise<MessageCreateResponse | null> => {
      const sourceNumber = process.env.PLIVO_WHATSAPP_NUMBER;
       if (!sourceNumber) {
        logger.error('PLIVO_WHATSAPP_NUMBER environment variable not set.');
        throw new Error('WhatsApp source number not configured.');
      }
      if (!to || !templateName || !languageCode) {
         logger.error('Missing "to", "templateName", or "languageCode" parameter.');
        throw new Error('Recipient, template name, and language code are required.');
      }
    
      // Validate phone number
      if (!validatePhoneNumber(to)) {
        logger.error(`Invalid phone number format: ${to}`);
        throw new Error('Invalid phone number format. Use E.164 format (e.g., +14155551234).');
      }
    
      const template = {
        name: templateName,
        language: languageCode,
        components: [],
      };
    
      if (headerComponents) {
        template.components.push(...headerComponents);
      }
      if (bodyComponents) {
        template.components.push(...bodyComponents);
      }
    
      // Template component structure examples:
      // Header with image:
      //   { type: 'header', parameters: [{ type: 'image', image: { link: 'https://example.com/image.jpg' } }] }
      // Header with text:
      //   { type: 'header', parameters: [{ type: 'text', text: 'Your Dynamic Header' }] }
      // Body with variables (matches template placeholders {{1}}, {{2}}):
      //   { type: 'body', parameters: [{ type: 'text', text: 'Value1' }, { type: 'text', text: 'Value2' }] }
      // Button with URL (for dynamic URL templates):
      //   { type: 'button', sub_type: 'url', index: 0, parameters: [{ type: 'text', text: 'unique-id-123' }] }
    
      logger.info(`Attempting to send WhatsApp template "${templateName}" to ${to}`);
      try {
        const response = await plivoClient.messages.create(
          sourceNumber, // src
          to,           // dst
          undefined,    // text (not used for templates)
          {
            type: 'whatsapp',
            template: template, // Pass the structured template object
          }
          // Optional: Status callback URL
        );
    
        logger.info(`WhatsApp template message sent successfully to ${to}. Message UUID: ${response.messageUuid[0]}`);
    
        // Save message details to database
        try {
            await db.whatsAppMessage.create({
                data: {
                    plivoUuid: response.messageUuid[0],
                    fromNumber: sourceNumber,
                    toNumber: to,
                    templateName: templateName,
                    templateData: { // Store the components used
                        header: headerComponents,
                        body: bodyComponents,
                        // Add buttons if applicable
                    },
                    direction: 'OUTBOUND',
                    status: 'queued', // Initial status
                }
            });
            logger.info(`Saved outbound template message ${response.messageUuid[0]} to database.`);
        } catch (dbError) {
            logger.error({ error: dbError, plivoUuid: response.messageUuid[0] }, 'Failed to save outbound template message to database');
             // Decide if failure to save should throw error or just be logged
        }
    
        return response;
      } catch (error) {
        logger.error({ error, to, templateName }, 'Failed to send WhatsApp template message via Plivo');
    
        // Handle specific Plivo error codes
        if (error.status) {
          switch (error.status) {
            case 401:
              throw new Error('Plivo authentication failed. Check your AUTH_ID and AUTH_TOKEN.');
            case 404:
              throw new Error('Template not found or not approved. Sync templates in Plivo Console.');
            case 429:
              throw new Error('Rate limit exceeded. Implement retry logic with exponential backoff.');
            case 500:
            case 503:
              throw new Error('Plivo service error. Retry after a delay.');
            default:
              throw new Error(`Plivo API error (${error.status}): ${error.message}`);
          }
        }
    
        throw error;
      }
    };
    
    // Placeholder for service functions required by GraphQL schema
    // RedwoodJS requires these even if logic is elsewhere
    export const whatsapp = () => {
      // This might not be directly used if calling sendWhatsAppText/Template
      // directly from mutations, but keeps Redwood happy.
      return { id: 'whatsapp-service' };
    };

B. Receiving WhatsApp Messages (Webhook)

  1. Generate Function: Create a RedwoodJS function to handle incoming POST requests from Plivo.

    bash
    yarn rw g function whatsappWebhook

    This creates api/src/functions/whatsappWebhook.ts.

  2. Implement Webhook Logic: Open api/src/functions/whatsappWebhook.ts and add the following:

    typescript
    // api/src/functions/whatsappWebhook.ts
    import type { APIGatewayEvent, Context } from 'aws-lambda';
    import { logger } from 'src/lib/logger';
    import { db } from 'src/lib/db'; // Import Prisma client
    import crypto from 'crypto'; // Import crypto for validation
    
    /**
     * Validates the incoming webhook signature from Plivo.
     * CRITICAL for security to ensure the request actually came from Plivo.
     *
     * **Critical Security Note:** Verify this implementation against the current Plivo V3
     * signature validation documentation before using in production. Plivo SDKs may or may
     * not provide a helper utility for this.
     * See: https://www.plivo.com/docs/getting-started/security-best-practices#check-the-plivo-signature
     */
    const validatePlivoSignature = (
      event: APIGatewayEvent,
      authToken: string
    ): boolean => {
      const signature = event.headers['X-Plivo-Signature-V3'] || event.headers['x-plivo-signature-v3'];
      const nonce = event.headers['X-Plivo-Signature-V3-Nonce'] || event.headers['x-plivo-signature-v3-nonce'];
      // Note: Plivo V3 validation uses the *full* URL including query string.
      // Construct the URL carefully based on your API Gateway/deployment setup.
      // X-Forwarded-Proto and Host headers are common but might vary.
      let url = event.headers['x-forwarded-proto'] + '://' + event.headers.host + event.path;
      if (event.queryStringParameters && Object.keys(event.queryStringParameters).length > 0) {
        // Ensure query parameters are sorted correctly if Plivo requires it (check docs)
        url += '?' + new URLSearchParams(event.queryStringParameters).toString();
      }
    
      // Body might be base64 encoded by API Gateway, decode if necessary
      const body = event.isBase64Encoded ? Buffer.from(event.body, 'base64').toString() : event.body;
    
      if (!signature || !nonce || !authToken) {
          logger.warn('Missing Plivo signature, nonce, or auth token for validation.');
          return false;
      }
    
      // Plivo V3 signature validation using Node.js crypto
      // Verify this logic against current Plivo documentation.
      try {
        // Plivo V3 concatenates URL, Nonce, and the *raw request body*.
        const baseString = url + nonce + (body || '');
        const expectedSignature = crypto
          .createHmac('sha256', authToken)
          .update(baseString)
          .digest('base64');
    
        logger.debug({ signature, expectedSignature, nonce, url }, "Validating Plivo V3 Signature");
    
        // Use timing-safe comparison to prevent timing attacks
        // Note: crypto.timingSafeEqual requires buffers of equal length
        try {
          const signatureBuffer = Buffer.from(signature);
          const expectedBuffer = Buffer.from(expectedSignature);
          if (signatureBuffer.length !== expectedBuffer.length) {
            logger.warn('Plivo signature length mismatch.');
            return false;
          }
          const isValid = crypto.timingSafeEqual(signatureBuffer, expectedBuffer);
          if (!isValid) {
            logger.warn('Plivo signature mismatch.');
            return false;
          }
        } catch (compareError) {
          logger.warn('Plivo signature comparison failed.');
          return false;
        }
    
        logger.info("Plivo signature validated successfully.");
        return true;
    
      } catch (err) {
          logger.error(err, "Error during signature validation");
          return false;
      }
    };
    
    // Track processed message UUIDs to prevent duplicate processing
    const processedMessages = new Set<string>();
    
    export const handler = async (event: APIGatewayEvent, _context: Context) => {
      logger.info('Received request on /api/whatsappWebhook');
    
      const authToken = process.env.PLIVO_AUTH_TOKEN;
      if (!authToken) {
        logger.error('PLIVO_AUTH_TOKEN is not set. Cannot validate signature.');
        return { statusCode: 500, body: 'Internal Server Error: Configuration missing.' };
      }
    
      // 1. Validate Signature (CRITICAL)
      if (!validatePlivoSignature(event, authToken)) {
        logger.warn('Invalid Plivo signature. Rejecting request.');
        return { statusCode: 403, body: 'Forbidden: Invalid signature.' };
      }
    
      // 2. Process Request Body
      // Plivo sends data as application/x-www-form-urlencoded
      let params: Record<string, string>;
      try {
         // Decode if necessary and parse
         const bodyString = event.isBase64Encoded ? Buffer.from(event.body, 'base64').toString() : event.body;
         params = Object.fromEntries(new URLSearchParams(bodyString));
         logger.info({ params }, 'Parsed incoming WhatsApp message parameters');
      } catch (error) {
          logger.error({ error, body: event.body }, 'Failed to parse incoming webhook body');
          return { statusCode: 400, body: 'Bad Request: Could not parse body.' };
      }
    
      const fromNumber = params.From;
      const toNumber = params.To; // Your Plivo WhatsApp number
      const messageText = params.Text;
      const messageUuid = params.MessageUUID;
      const messageType = params.Type; // e.g., 'text', 'media'
    
      // 3. Implement Idempotency Check
      if (processedMessages.has(messageUuid)) {
        logger.info(`Message ${messageUuid} already processed. Skipping duplicate.`);
        return { statusCode: 200, body: JSON.stringify({ message: 'Duplicate message ignored.' }) };
      }
    
      // Check database for existing message (more robust than in-memory Set)
      try {
        const existingMessage = await db.whatsAppMessage.findUnique({
          where: { plivoUuid: messageUuid }
        });
        if (existingMessage) {
          logger.info(`Message ${messageUuid} already exists in database. Skipping duplicate.`);
          return { statusCode: 200, body: JSON.stringify({ message: 'Duplicate message ignored.' }) };
        }
      } catch (dbError) {
        logger.error({ error: dbError, messageUuid }, 'Error checking for duplicate message');
        // Continue processing to avoid message loss
      }
    
      // 4. Implement Business Logic
      // - Save the message to the database
      // - Trigger auto-responses, forward to support, etc.
      try {
          logger.info(`Processing incoming message ${messageUuid} from ${fromNumber}`);
    
          // Handle media messages
          let mediaUrl = null;
          let mediaContentType = null;
          if (messageType === 'media') {
            mediaUrl = params.MediaUrl0; // Plivo uses MediaUrl0, MediaUrl1, etc. for multiple media
            mediaContentType = params.MediaContentType0;
            logger.info({ mediaUrl, mediaContentType }, 'Media message received');
    
            // Optional: Download and store media file
            // const mediaFile = await downloadMedia(mediaUrl);
            // mediaUrl = await uploadToStorage(mediaFile);
          }
    
          // Save to DB (requires Prisma setup from Section 6)
          await db.whatsAppMessage.create({
              data: {
                  plivoUuid: messageUuid,
                  fromNumber: fromNumber,
                  toNumber: toNumber,
                  body: messageText,
                  mediaUrl: mediaUrl,
                  mediaContentType: mediaContentType,
                  direction: 'INBOUND',
                  status: 'received', // Or Plivo's status if provided
                  plivoTimestamp: params.Timestamp ? new Date(parseInt(params.Timestamp) * 1000) : new Date(),
              }
          });
          logger.info(`Saved incoming message ${messageUuid} to database.`);
    
          // Mark as processed
          processedMessages.add(messageUuid);
    
          // Optional: Send an auto-reply (use sendWhatsAppText imported from services)
          // Be mindful of creating loops or spamming users.
          // Example: keyword-based auto-responder
          /*
          if (messageText?.toLowerCase().includes('help')) {
              const { sendWhatsAppText } = await import('src/services/whatsapp/whatsapp');
              await sendWhatsAppText({
                to: fromNumber,
                text: "Thanks for reaching out! How can we help?"
              });
          }
          */
    
      } catch (error) {
          logger.error({ error, messageUuid }, 'Error processing incoming WhatsApp message');
          // Return 500 so Plivo might retry (configure retry behavior in Plivo if needed)
          return { statusCode: 500, body: 'Internal Server Error: Failed to process message.' };
      }
    
      // 5. Respond to Plivo
      // A 2xx status code acknowledges receipt. Body is usually ignored by Plivo.
      return {
        statusCode: 200,
        headers: { 'Content-Type': 'application/json' }, // Or text/xml depending on Plivo expectations
        body: JSON.stringify({ message: 'Webhook received successfully.' }),
      };
    };

    Explanation:

    • Signature Validation: This is paramount for security. It verifies the request genuinely originated from Plivo using your AUTH_TOKEN. The code uses the X-Plivo-Signature-V3 and X-Plivo-Signature-V3-Nonce headers along with the request URL and body. Uses crypto.timingSafeEqual for secure comparison.
    • Idempotency Handling: Prevents duplicate message processing by checking both in-memory Set and database. Plivo may retry webhook deliveries on failures.
    • Body Parsing: Plivo sends data as application/x-www-form-urlencoded. The code parses this string into a JavaScript object.
    • Media Processing: Handles media messages by extracting MediaUrl0 and MediaContentType0 parameters. Add media download/storage logic as needed.
    • Processing: Extracts key information (From, To, Text, MessageUUID). Add your application-specific logic (saving to DB, triggering replies, etc.).
    • Auto-Reply Best Practices:
      • Only respond to specific keywords or patterns
      • Implement cooldown periods to prevent spam
      • Track conversation state to avoid loops
      • Use templates for auto-replies outside 24-hour window
    • Response: Returns a 200 OK status to Plivo to acknowledge receipt.

4. Building the API Layer (GraphQL)

Expose the sending functionality through RedwoodJS's GraphQL API for easy frontend integration.

  1. Define GraphQL Schema: Open api/src/graphql/whatsapp.sdl.ts (create if it doesn't exist) and define mutations for sending messages:

    typescript
    // api/src/graphql/whatsapp.sdl.ts
    export const schema = gql`
      type WhatsAppMessageSentResponse {
        messageUuid: String!
        apiId: String
        message: String # Confirmation message
      }
    
      type WhatsAppMessage {
        id: Int!
        plivoUuid: String!
        fromNumber: String!
        toNumber: String!
        body: String
        templateName: String
        templateData: JSON
        mediaUrl: String
        mediaContentType: String
        direction: String!
        status: String!
        plivoTimestamp: DateTime
        createdAt: DateTime!
      }
    
      # Input type for sending text message
      input SendWhatsAppTextInput {
        to: String!       # E.164 format
        text: String!
      }
    
      # Input type for sending template message
      input SendWhatsAppTemplateInput {
        to: String!       # E.164 format
        templateName: String!
        languageCode: String! # e.g., 'en_US'
        # Use JSON for flexibility with components, parse in resolver
        headerComponentsJson: String # JSON stringified array
        bodyComponentsJson: String   # JSON stringified array
      }
    
      type Query {
        """Fetch message history for a specific number"""
        whatsAppMessages(phoneNumber: String, limit: Int): [WhatsAppMessage!]! @requireAuth
    
        """Get a single message by UUID"""
        whatsAppMessage(plivoUuid: String!): WhatsAppMessage @requireAuth
      }
    
      type Mutation {
        """Sends a standard WhatsApp text message. Requires active session."""
        sendWhatsAppText(input: SendWhatsAppTextInput!): WhatsAppMessageSentResponse @requireAuth
    
        """Sends a WhatsApp message using a pre-approved template."""
        sendWhatsAppTemplate(input: SendWhatsAppTemplateInput!): WhatsAppMessageSentResponse @requireAuth
      }
    `
    • @requireAuth: Ensures only authenticated users can call these mutations. Set up RedwoodJS auth if you haven't (RedwoodJS Auth Docs).
  2. Implement Resolvers: RedwoodJS maps GraphQL types/mutations to service functions. Add the resolver logic within api/src/services/whatsapp/whatsapp.ts:

    typescript
    // api/src/services/whatsapp/whatsapp.ts
    // ... (keep existing code: Plivo client, send functions, db import, logger) ...
    import { requireAuth } from 'src/lib/auth' // Import Redwood auth helper
    import type {
        SendWhatsAppTextInput,
        SendWhatsAppTemplateInput,
        QueryWhatsAppMessagesArgs,
        QueryWhatsAppMessageArgs
    } from 'types/graphql' // Redwood automatically generates these types
    
    // Rename the original implementation function to avoid naming conflict with resolver
    const sendWhatsAppTextService = sendWhatsAppText;
    // Add 'export' if you need to call it from elsewhere, otherwise keep it internal
    
    // Rename the original implementation function
    const sendWhatsAppTemplateService = sendWhatsAppTemplate;
    // Add 'export' if needed
    
    // Query resolvers
    export const whatsAppMessages = async ({ phoneNumber, limit = 50 }: QueryWhatsAppMessagesArgs) => {
      requireAuth();
    
      return db.whatsAppMessage.findMany({
        where: phoneNumber ? {
          OR: [
            { fromNumber: phoneNumber },
            { toNumber: phoneNumber }
          ]
        } : undefined,
        orderBy: { createdAt: 'desc' },
        take: limit
      });
    };
    
    export const whatsAppMessage = async ({ plivoUuid }: QueryWhatsAppMessageArgs) => {
      requireAuth();
    
      return db.whatsAppMessage.findUnique({
        where: { plivoUuid }
      });
    };
    
    // Resolver for the sendWhatsAppText mutation
    // Redwood convention maps Mutation.sendWhatsAppText to this function
    export const sendWhatsAppText = async ({ input }: { input: SendWhatsAppTextInput }) => {
      requireAuth() // Check authentication
    
      try {
        // The service function now handles sending *and* initial DB saving
        const response = await sendWhatsAppTextService(input); // Call renamed service function
        if (!response || !response.messageUuid || response.messageUuid.length === 0) {
            throw new Error('Failed to send message or received invalid response from Plivo.');
        }
        return {
          messageUuid: response.messageUuid[0], // Plivo returns an array
          apiId: response.apiId,
          message: response.message,
        };
      } catch (error) {
        logger.error({ error, input }, 'Error in sendWhatsAppText GraphQL mutation');
        // Return structured error for better client handling
        throw new Error(`Failed to send WhatsApp text: ${error.message}`);
      }
    };
    
    // Resolver for the sendWhatsAppTemplate mutation
    // Redwood convention maps Mutation.sendWhatsAppTemplate to this function
    export const sendWhatsAppTemplate = async ({ input }: { input: SendWhatsAppTemplateInput }) => {
        requireAuth() // Check authentication
    
        // Parse JSON component strings carefully
        let headerComponents, bodyComponents;
        try {
            if (input.headerComponentsJson) {
                headerComponents = JSON.parse(input.headerComponentsJson);
            }
             if (input.bodyComponentsJson) {
                bodyComponents = JSON.parse(input.bodyComponentsJson);
            }
        } catch (parseError) {
            logger.error({ parseError, input }, 'Failed to parse component JSON in sendWhatsAppTemplate mutation');
            throw new Error('Invalid JSON format for template components.');
        }
    
        try {
             // The service function now handles sending *and* initial DB saving
            const response = await sendWhatsAppTemplateService({ // Call renamed service function
                to: input.to,
                templateName: input.templateName,
                languageCode: input.languageCode,
                headerComponents: headerComponents,
                bodyComponents: bodyComponents,
            });
    
            if (!response || !response.messageUuid || response.messageUuid.length === 0) {
                throw new Error('Failed to send template or received invalid response from Plivo.');
            }
            return {
                 messageUuid: response.messageUuid[0],
                 apiId: response.apiId,
                 message: response.message,
            };
        } catch (error) {
            logger.error({ error, input }, 'Error in sendWhatsAppTemplate GraphQL mutation');
            throw new Error(`Failed to send WhatsApp template: ${error.message}`);
        }
    };
  3. Testing with GraphQL Playground:

    • Run your RedwoodJS dev server: yarn rw dev

    • Access the GraphQL Playground (usually http://localhost:8911/graphql).

    • You'll need to handle authentication (e.g., pass an auth token in headers).

    • Example curl (replace placeholders and add auth header):

      Send Text:

      bash
      curl 'http://localhost:8911/graphql' \
        -H 'Content-Type: application/json' \
        -H 'Authorization: Bearer YOUR_AUTH_TOKEN' \
        --data-raw '{"query":"mutation SendText($input: SendWhatsAppTextInput!) { sendWhatsAppText(input: $input) { messageUuid apiId message } }","variables":{"input":{"to":"+15551234567","text":"Hello from RedwoodJS via Plivo!"}}}'

      Send Template (Example: order confirmation – structure depends on your template):

      bash
      # Note: headerComponentsJson and bodyComponentsJson need to be correctly JSON stringified arrays
      # Example: bodyComponentsJson: "[{\"type\":\"body\",\"parameters\":[{\"type\":\"text\",\"text\":\"Your Name\"},{\"type\":\"text\",\"text\":\"Order123\"}]}]"
      curl 'http://localhost:8911/graphql' \
        -H 'Content-Type: application/json' \
        -H 'Authorization: Bearer YOUR_AUTH_TOKEN' \
        --data-raw '{"query":"mutation SendTemplate($input: SendWhatsAppTemplateInput!) { sendWhatsAppTemplate(input: $input) { messageUuid apiId message } }","variables":{"input":{"to":"+15551234567","templateName":"order_confirmation","languageCode":"en_US","bodyComponentsJson":"[{\"type\":\"body\",\"parameters\":[{\"type\":\"text\",\"text\":\"Value1\"},{\"type\":\"text\",\"text\":\"Value2\"}]}]"}}}'

5. Database Schema (Prisma)

Define the database model to store WhatsApp messages for tracking and analytics.

  1. Update Prisma Schema: Open api/db/schema.prisma and add the WhatsAppMessage model:

    prisma
    // api/db/schema.prisma
    datasource db {
      provider = "postgresql" // or "mysql", "sqlite", etc.
      url      = env("DATABASE_URL")
    }
    
    generator client {
      provider      = "prisma-client-js"
      binaryTargets = ["native"]
    }
    
    model WhatsAppMessage {
      id               Int       @id @default(autoincrement())
      plivoUuid        String    @unique
      fromNumber       String
      toNumber         String
      body             String?
      templateName     String?
      templateData     Json?
      mediaUrl         String?
      mediaContentType String?
      direction        String    // INBOUND or OUTBOUND
      status           String    // queued, sent, delivered, failed, received
      plivoTimestamp   DateTime?
      createdAt        DateTime  @default(now())
      updatedAt        DateTime  @updatedAt
    
      @@index([fromNumber])
      @@index([toNumber])
      @@index([createdAt])
    }
  2. Run Migrations: Create and apply the database migration:

    bash
    yarn rw prisma migrate dev --name create_whatsapp_messages

6. Deployment

Deploy your RedwoodJS WhatsApp integration to production with these platform-specific guides.

Deployment Checklist:

  1. Environment Variables: Set production values for PLIVO_AUTH_ID, PLIVO_AUTH_TOKEN, PLIVO_WHATSAPP_NUMBER, and DATABASE_URL in your deployment platform.

  2. Update Webhook URL: Replace ngrok URL in Plivo Console with your production domain (e.g., https://your-app.com/api/whatsappWebhook).

  3. Database Migration: Run Prisma migrations in production:

    bash
    yarn rw prisma migrate deploy
  4. Deploy Options:

  5. Monitoring & Observability:

    • Set up application monitoring (e.g., Sentry, Datadog)
    • Configure log aggregation (e.g., Logtail, CloudWatch)
    • Monitor webhook delivery rates in Plivo Console
    • Set up alerts for failed messages and high error rates
  6. Testing Strategy:

    • Unit Tests: Test individual service functions
    • Integration Tests: Test GraphQL mutations and webhook handlers
    • E2E Tests: Test complete message flows

    Example test for sendWhatsAppText:

    typescript
    // api/src/services/whatsapp/whatsapp.test.ts
    import { sendWhatsAppText } from './whatsapp'
    import { db } from 'src/lib/db'
    
    describe('sendWhatsAppText', () => {
      scenario('sends a WhatsApp text message', async (scenario) => {
        const result = await sendWhatsAppText({
          to: '+15551234567',
          text: 'Test message'
        })
    
        expect(result).toHaveProperty('messageUuid')
        expect(result.messageUuid).toBeTruthy()
    
        const dbMessage = await db.whatsAppMessage.findFirst({
          where: { plivoUuid: result.messageUuid[0] }
        })
        expect(dbMessage).toBeTruthy()
        expect(dbMessage.direction).toBe('OUTBOUND')
      })
    })

Frequently Asked Questions

What Node.js version do I need for Plivo WhatsApp integration with RedwoodJS?

You need Node.js 20.x (LTS Active until October 2026, Maintenance until April 2027). RedwoodJS requires Node.js =20.x. Node.js 21+ may cause compatibility issues with some deployment targets like AWS Lambdas. Node.js 22.x is also supported but verify compatibility with your specific deployment environment before using it.

Do I need a WhatsApp Business Account (WABA) to use Plivo's WhatsApp API?

Yes. You must have an active WhatsApp Business Account (WABA) mapped to Plivo. You'll need a Meta Business Manager account with admin access to create and configure the WABA. Plivo is a registered Meta Solution Provider, making the integration process streamlined through their embedded signup flow.

Can I send free-form messages to any WhatsApp user?

No. WhatsApp requires businesses to use pre-approved message templates to initiate conversations or send messages outside the 24-hour customer service window. Once a user messages you first, you have a 24-hour window to send free-form text messages. After this window closes, you must use approved templates. The 24-hour window resets each time the user sends you a new message.

How do I create and approve WhatsApp message templates?

Create templates in your Meta Business Manager → WhatsApp Manager → Message Templates. Choose appropriate categories (Marketing, Utility, Authentication) and include placeholders for dynamic content. Submit templates for approval (takes up to 24 hours). Once approved in Meta, sync them to Plivo Console → Messaging → WhatsApp Templates → Sync Templates from WhatsApp.

How does RedwoodJS handle incoming WhatsApp messages from Plivo?

Plivo triggers a webhook POST request to your RedwoodJS API function endpoint when messages arrive. Create a serverless function at api/src/functions/whatsappWebhook.ts that validates Plivo's signature (V3), processes the message data, saves it to your Prisma database, and triggers any additional business logic you define.

What database options work with this RedwoodJS WhatsApp integration?

You can use any database supported by Prisma ORM (v6.16.3 current), including PostgreSQL, MySQL, MariaDB, SQL Server, SQLite, MongoDB, and CockroachDB. RedwoodJS uses Prisma by default for database access, providing type-safe queries and automatic migrations.

How do I test webhooks locally during development?

Use ngrok or similar tunneling service to expose your local RedwoodJS API server (port 8911) to the public internet. Run ngrok http 8911, copy the HTTPS forwarding URL, and configure it in Plivo Console → Messaging → WhatsApp Numbers → Message URL as https://<random_string>.ngrok.io/api/whatsappWebhook.

How do I secure my Plivo WhatsApp webhook in RedwoodJS?

Implement Plivo signature validation (V3) in your webhook function. Plivo sends a signature in the X-Plivo-Signature-V3 header. Compute an HMAC-SHA256 hash of the webhook URL concatenated with the nonce and request body using your AUTH_TOKEN as the key. Compare this computed signature with the received signature using crypto.timingSafeEqual to verify authenticity before processing messages.

How do I handle message delivery status tracking?

Configure a status callback URL in Plivo Console or when sending messages. Create a separate webhook function to handle delivery status updates. Plivo sends status events (sent, delivered, failed, read) to this endpoint. Update your database records based on these status callbacks.

How do I scale my WhatsApp integration for high message volumes?

Implement these production optimizations:

  • Use connection pooling for database access
  • Implement Redis caching for frequently accessed data
  • Use queue systems (e.g., Bull, AWS SQS) for async message processing
  • Enable horizontal scaling on your deployment platform
  • Monitor and optimize Plivo rate limits
  • Implement circuit breakers for external API calls

How do I ensure GDPR compliance for message storage?

Implement these privacy best practices:

  • Store only necessary message data (minimize PII)
  • Implement data retention policies and automated cleanup
  • Provide user data export and deletion APIs
  • Encrypt sensitive data at rest and in transit
  • Log access to message data for audit trails
  • Include privacy disclosures in your WhatsApp templates
  • Implement user consent mechanisms

Common error codes and troubleshooting

Error CodeMeaningSolution
401Authentication failedVerify PLIVO_AUTH_ID and PLIVO_AUTH_TOKEN
404Number/template not foundVerify number is registered and templates are synced
429Rate limit exceededImplement exponential backoff and retry logic
500/503Plivo service errorRetry after delay; check Plivo status page
Webhook signature mismatchInvalid signature validationVerify AUTH_TOKEN and URL construction
Template rejectedTemplate doesn't comply with Meta guidelinesReview Meta Template Guidelines