code examples
code examples
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:
+-----------------+ +---------------------+ +-----------------+ +----------------+ +-----------+
| 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:
| Direction | Flow Description |
|---|---|
| Sending | Your 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. |
| Receiving | WhatsApp 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 Handling | Failed 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.
ngrokor 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 thengrokURL 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.
-
Create RedwoodJS App: Open your terminal and run:
bashyarn create redwood-app ./redwood-plivo-whatsapp cd redwood-plivo-whatsappFollow 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.
-
Install Plivo SDK: Navigate to the API workspace and add the Plivo Node.js SDK:
bashyarn workspace api add plivoCurrent SDK version: 4.x (verify compatibility with your Node.js version). Check Plivo Node.js SDK Releases for updates.
-
Configure Environment Variables: Store your Plivo authentication credentials (
AUTH_IDandAUTH_TOKEN) and registered WhatsApp number securely in environment variables.-
Create a
.envfile in your project root:bashtouch .env -
Add the following variables to
.env. Find yourAUTH_IDandAUTH_TOKENon 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
.envto your.gitignorefile to prevent committing secrets. RedwoodJS's default.gitignoreusually 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.
-
-
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.
-
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.
-
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.
-
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 WhatsAppto make them available via the Plivo API. Note the exactnameandlanguagecode (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 Type Category Structure Example Order Confirmation Utility Header: "Order Confirmed" / Body: "Hi {{1}}, your order {{2}} is confirmed. Delivery by {{3}}." Appointment Reminder Utility Header: Image/Video / Body: "Hi {{1}}, reminder: your appointment on {{2}} at {{3}}." OTP Authentication Authentication Body: "Your verification code is {{1}}. Valid for {{2}} minutes." -
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.
- Start
- 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:
- localtunnel: Free, open-source alternative to ngrok
- Cloudflare Tunnel: Secure tunnel with Cloudflare CDN
- Tailscale Funnel: Share local services securely
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
-
Generate Service: Create a service to encapsulate Plivo interactions.
bashyarn rw g service whatsappThis creates
api/src/services/whatsapp/whatsapp.tsand associated test/scenario files. -
Implement Sending Logic: Open
api/src/services/whatsapp/whatsapp.tsand 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)
-
Generate Function: Create a RedwoodJS function to handle incoming POST requests from Plivo.
bashyarn rw g function whatsappWebhookThis creates
api/src/functions/whatsappWebhook.ts. -
Implement Webhook Logic: Open
api/src/functions/whatsappWebhook.tsand 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 theX-Plivo-Signature-V3andX-Plivo-Signature-V3-Nonceheaders along with the request URL and body. Usescrypto.timingSafeEqualfor 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
MediaUrl0andMediaContentType0parameters. 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 OKstatus to Plivo to acknowledge receipt.
- Signature Validation: This is paramount for security. It verifies the request genuinely originated from Plivo using your
4. Building the API Layer (GraphQL)
Expose the sending functionality through RedwoodJS's GraphQL API for easy frontend integration.
-
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).
-
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}`); } }; -
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:
bashcurl '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.
-
Update Prisma Schema: Open
api/db/schema.prismaand 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]) } -
Run Migrations: Create and apply the database migration:
bashyarn rw prisma migrate dev --name create_whatsapp_messages
6. Deployment
Deploy your RedwoodJS WhatsApp integration to production with these platform-specific guides.
Deployment Checklist:
-
Environment Variables: Set production values for
PLIVO_AUTH_ID,PLIVO_AUTH_TOKEN,PLIVO_WHATSAPP_NUMBER, andDATABASE_URLin your deployment platform. -
Update Webhook URL: Replace ngrok URL in Plivo Console with your production domain (e.g.,
https://your-app.com/api/whatsappWebhook). -
Database Migration: Run Prisma migrations in production:
bashyarn rw prisma migrate deploy -
Deploy Options:
- Vercel: RedwoodJS Vercel Deploy Guide
- Netlify: RedwoodJS Netlify Deploy Guide
- AWS (Lambda + RDS): RedwoodJS AWS Deploy Guide
- Docker: RedwoodJS Docker Deploy Guide
-
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
-
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 Code | Meaning | Solution |
|---|---|---|
| 401 | Authentication failed | Verify PLIVO_AUTH_ID and PLIVO_AUTH_TOKEN |
| 404 | Number/template not found | Verify number is registered and templates are synced |
| 429 | Rate limit exceeded | Implement exponential backoff and retry logic |
| 500/503 | Plivo service error | Retry after delay; check Plivo status page |
| Webhook signature mismatch | Invalid signature validation | Verify AUTH_TOKEN and URL construction |
| Template rejected | Template doesn't comply with Meta guidelines | Review Meta Template Guidelines |