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.
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.
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.
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.
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.
Configure Environment Variables:
Store your Plivo authentication credentials (AUTH_ID and AUTH_TOKEN) and registered WhatsApp number securely in environment variables.
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.
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 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 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.
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
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.
Bash
yarn rw g service whatsapp
This creates api/src/services/whatsapp/whatsapp.ts and associated test/scenario files.
Implement Sending Logic:
Open api/src/services/whatsapp/whatsapp.ts and add the following:
TypeScript
// api/src/services/whatsapp/whatsapp.tsimport { PlivoClient } from'plivo-node';
importtype { MessageCreateResponse } from'plivo-node/dist/resources/message'; // Adjust import path based on SDK version if neededimport { db } from'src/lib/db'; // Import Prisma clientimport { 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 = newPlivoClient(
process.env.PLIVO_AUTH_ID,
process.env.PLIVO_AUTH_TOKEN
);
interfaceSendWhatsAppTextParams {
to: string; // Recipient number in E.164 formattext: string;
}
interfaceSendWhatsAppTemplateParams {
to: string; // Recipient number in E.164 formattemplateName: string;
languageCode: string; // e.g., 'en_US'headerComponents?: any[]; // Optional: For template header variables/mediabodyComponents?: 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/tabsreturn 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.
*/exportconst 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.');
thrownewError('WhatsApp source number not configured.');
}
if (!to || !text) {
logger.error('Missing "to" or "text" parameter for sending WhatsApp text.');
thrownewError('Recipient number and text message are required.');
}
// Validate and sanitize inputif (!validatePhoneNumber(to)) {
logger.error(`Invalid phone number format: ${to}`);
thrownewError('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.');
thrownewError('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 databasetry {
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 codesif (error.status) {
switch (error.status) {
case401:
thrownewError('Plivo authentication failed. Check your AUTH_ID and AUTH_TOKEN.');
case404:
thrownewError('WhatsApp number not found or not configured in Plivo.');
case429:
thrownewError('Rate limit exceeded. Implement retry logic with exponential backoff.');
case500:
case503:
thrownewError('Plivo service error. Retry after a delay.');
default:
thrownewError(`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.
*/exportconst 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.');
thrownewError('WhatsApp source number not configured.');
}
if (!to || !templateName || !languageCode) {
logger.error('Missing "to", "templateName", or "languageCode" parameter.');
thrownewError('Recipient, template name, and language code are required.');
}
// Validate phone numberif (!validatePhoneNumber(to)) {
logger.error(`Invalid phone number format: ${to}`);
thrownewError('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, // dstundefined, // 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 databasetry {
await db.whatsAppMessage.create({
data: {
plivoUuid: response.messageUuid[0],
fromNumber: sourceNumber,
toNumber: to,
templateName: templateName,
templateData: { // Store the components usedheader: 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 codesif (error.status) {
switch (error.status) {
case401:
thrownewError('Plivo authentication failed. Check your AUTH_ID and AUTH_TOKEN.');
case404:
thrownewError('Template not found or not approved. Sync templates in Plivo Console.');
case429:
thrownewError('Rate limit exceeded. Implement retry logic with exponential backoff.');
case500:
case503:
thrownewError('Plivo service error. Retry after a delay.');
default:
thrownewError(`Plivo API error (${error.status}): ${error.message}`);
}
}
throw error;
}
};
// Placeholder for service functions required by GraphQL schema// RedwoodJS requires these even if logic is elsewhereexportconstwhatsapp = () => {
// 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.
Bash
yarn rw g function whatsappWebhook
This creates api/src/functions/whatsappWebhook.ts.
Implement Webhook Logic:
Open api/src/functions/whatsappWebhook.ts and add the following:
TypeScript
// api/src/functions/whatsappWebhook.tsimporttype { APIGatewayEvent, Context } from'aws-lambda';
import { logger } from'src/lib/logger';
import { db } from'src/lib/db'; // Import Prisma clientimport 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 += '?' + newURLSearchParams(event.queryStringParameters).toString();
}
// Body might be base64 encoded by API Gateway, decode if necessaryconst 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.');
returnfalse;
}
// 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 lengthtry {
const signatureBuffer = Buffer.from(signature);
const expectedBuffer = Buffer.from(expectedSignature);
if (signatureBuffer.length !== expectedBuffer.length) {
logger.warn('Plivo signature length mismatch.');
returnfalse;
}
const isValid = crypto.timingSafeEqual(signatureBuffer, expectedBuffer);
if (!isValid) {
logger.warn('Plivo signature mismatch.');
returnfalse;
}
} catch (compareError) {
logger.warn('Plivo signature comparison failed.');
returnfalse;
}
logger.info("Plivo signature validated successfully.");
returntrue;
} catch (err) {
logger.error(err, "Error during signature validation");
returnfalse;
}
};
// Track processed message UUIDs to prevent duplicate processingconst processedMessages = newSet<string>();
exportconsthandler = 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-urlencodedletparams: Record<string, string>;
try {
// Decode if necessary and parseconst bodyString = event.isBase64Encoded ? Buffer.from(event.body, 'base64').toString() : event.body;
params = Object.fromEntries(newURLSearchParams(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 numberconst messageText = params.Text;
const messageUuid = params.MessageUUID;
const messageType = params.Type; // e.g., 'text', 'media'// 3. Implement Idempotency Checkif (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 messageslet 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 providedplivoTimestamp: params.Timestamp ? newDate(parseInt(params.Timestamp) * 1000) : newDate(),
}
});
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 expectationsbody: 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.
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.tsexportconst 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 messageinput SendWhatsAppTextInput {to: String!# E.164 formattext: String!}# Input type for sending template messageinput SendWhatsAppTemplateInput {to: String!# E.164 formattemplateName: String!languageCode: String!# e.g., 'en_US'# Use JSON for flexibility with components, parse in resolverheaderComponentsJson: String # JSON stringified arraybodyComponentsJson: String # JSON stringified array}typeQuery{"""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}typeMutation{"""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 helperimporttype {
SendWhatsAppTextInput,
SendWhatsAppTemplateInput,
QueryWhatsAppMessagesArgs,
QueryWhatsAppMessageArgs
} from'types/graphql'// Redwood automatically generates these types// Rename the original implementation function to avoid naming conflict with resolverconst sendWhatsAppTextService = sendWhatsAppText;
// Add 'export' if you need to call it from elsewhere, otherwise keep it internal// Rename the original implementation functionconst sendWhatsAppTemplateService = sendWhatsAppTemplate;
// Add 'export' if needed// Query resolversexportconstwhatsAppMessages = async ({ phoneNumber, limit = 50 }: QueryWhatsAppMessagesArgs) => {
requireAuth();
return db.whatsAppMessage.findMany({
where: phoneNumber ? {
OR: [
{ fromNumber: phoneNumber },
{ toNumber: phoneNumber }
]
} : undefined,
orderBy: { createdAt: 'desc' },
take: limit
});
};
exportconstwhatsAppMessage = async ({ plivoUuid }: QueryWhatsAppMessageArgs) => {
requireAuth();
return db.whatsAppMessage.findUnique({
where: { plivoUuid }
});
};
// Resolver for the sendWhatsAppText mutation// Redwood convention maps Mutation.sendWhatsAppText to this functionexportconstsendWhatsAppText = async ({ input }: { input: SendWhatsAppTextInput }) => {
requireAuth() // Check authenticationtry {
// The service function now handles sending *and* initial DB savingconst response = awaitsendWhatsAppTextService(input); // Call renamed service functionif (!response || !response.messageUuid || response.messageUuid.length === 0) {
thrownewError('Failed to send message or received invalid response from Plivo.');
}
return {
messageUuid: response.messageUuid[0], // Plivo returns an arrayapiId: response.apiId,
message: response.message,
};
} catch (error) {
logger.error({ error, input }, 'Error in sendWhatsAppText GraphQL mutation');
// Return structured error for better client handlingthrownewError(`Failed to send WhatsApp text: ${error.message}`);
}
};
// Resolver for the sendWhatsAppTemplate mutation// Redwood convention maps Mutation.sendWhatsAppTemplate to this functionexportconstsendWhatsAppTemplate = async ({ input }: { input: SendWhatsAppTemplateInput }) => {
requireAuth() // Check authentication// Parse JSON component strings carefullylet 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');
thrownewError('Invalid JSON format for template components.');
}
try {
// The service function now handles sending *and* initial DB savingconst response = awaitsendWhatsAppTemplateService({ // Call renamed service functionto: input.to,
templateName: input.templateName,
languageCode: input.languageCode,
headerComponents: headerComponents,
bodyComponents: bodyComponents,
});
if (!response || !response.messageUuid || response.messageUuid.length === 0) {
thrownewError('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');
thrownewError(`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):
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.tsimport { sendWhatsAppText } from'./whatsapp'import { db } from'src/lib/db'describe('sendWhatsAppText', () => {
scenario('sends a WhatsApp text message', async (scenario) => {
const result = awaitsendWhatsAppText({
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
Integrate WhatsApp into your RedwoodJS application using the Plivo communications platform. This involves setting up a RedwoodJS project, installing the Plivo SDK, configuring your Plivo account, and implementing the necessary backend and frontend logic to send and receive WhatsApp messages.
Plivo is a cloud communications platform that provides the necessary APIs for sending and receiving WhatsApp messages. It acts as a bridge between your RedwoodJS application and the WhatsApp Business API.
RedwoodJS is chosen for its full-stack capabilities, serverless-friendly nature, and excellent developer experience. It provides conventions, integrated tooling for GraphQL, Prisma, and Jest, making development smoother.
ngrok is essential during local development to create a publicly accessible URL for your RedwoodJS API server, which Plivo's webhook uses to send incoming WhatsApp messages. For production, replace the ngrok URL with your application's stable public URL.
Yes, sending WhatsApp message templates is a key part of this integration. Templates are required for initiating conversations or sending messages outside the 24-hour customer service window and must be pre-approved by Meta.
You send WhatsApp messages by calling the Plivo API from your RedwoodJS backend service. The provided code example demonstrates using the sendWhatsAppText function for standard text messages and sendWhatsAppTemplate for templated messages. Both functions use the Plivo Node.js SDK to interact with the API.
The webhook acts as a receiver for incoming WhatsApp messages. Plivo sends a POST request to your designated RedwoodJS API endpoint (the webhook) whenever a new message arrives. The webhook processes the incoming message data and can trigger further actions like saving to a database.
Configure your Plivo WhatsApp number's messaging settings to point to your RedwoodJS API endpoint. Use ngrok for local development and your application's public URL for production. Ensure the HTTP Method is set to POST.
Prerequisites include Node.js v18 or later, Yarn v1.x, a Plivo account, a Meta Business Manager account, a phone number for WhatsApp registration, and a basic understanding of RedwoodJS, GraphQL, and Node.js.
Validating Plivo webhook signatures is critical for security. Use the X-Plivo-Signature-V3 and X-Plivo-Signature-V3-Nonce headers, the request URL, and the request body along with your Plivo Auth Token to verify that incoming requests originate from Plivo. Refer to the Plivo documentation for the most up-to-date validation method.
Prisma is used as the Object-Relational Mapper (ORM) in RedwoodJS, enabling convenient interaction with your database. It simplifies database operations, such as creating, reading, updating, and deleting records.
Store your Plivo AUTH_ID, AUTH_TOKEN, and PLIVO_WHATSAPP_NUMBER as environment variables in a .env file in the root of your project. This file should be added to your .gitignore to prevent committing sensitive information.
Create WhatsApp message templates in your Meta Business Manager under WhatsApp Manager -> Message Templates. You'll need to categorize them and submit them for approval by Meta before you can use them in your application.
WhatsApp message templates are crucial for initiating conversations with users or sending messages outside the 24-hour customer service window. Using pre-approved templates ensures compliance with WhatsApp Business API guidelines.
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:
Time Estimate: 2–3 hours Skill Level: Intermediate (requires Node.js, GraphQL, and API experience)
Technologies Used:
System Architecture:
Flow:
Prerequisites:
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:
yarn 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:
Current 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:touch .envAdd the following variables to
.env. Find yourAUTH_IDandAUTH_TOKENon the Plivo Console dashboard (https://console.plivo.com/dashboard/).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:// 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
WhatsApp Accounts > Partners. Plivo should be listed.Common Troubleshooting:
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.
{{1}},{{2}}) for dynamic content and media headers.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:
Example Template Structures:
Configure Plivo Webhook for Incoming Messages: To receive messages in RedwoodJS, Plivo needs an endpoint to send data to.
ngrok.ngrok:ngrok http 8911(RedwoodJS API typically runs on 8911).ngrok(e.g.,https://<random_string>.ngrok.io).https://<random_string>.ngrok.io/api/whatsappWebhook.POST.Security Considerations:
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
Generate Service: Create a service to encapsulate Plivo interactions.
This creates
api/src/services/whatsapp/whatsapp.tsand associated test/scenario files.Implement Sending Logic: Open
api/src/services/whatsapp/whatsapp.tsand add the following:// 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.
yarn rw g function whatsappWebhookThis creates
api/src/functions/whatsappWebhook.ts.Implement Webhook Logic: Open
api/src/functions/whatsappWebhook.tsand add the following:// 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:
AUTH_TOKEN. The code uses theX-Plivo-Signature-V3andX-Plivo-Signature-V3-Nonceheaders along with the request URL and body. Usescrypto.timingSafeEqualfor secure comparison.application/x-www-form-urlencoded. The code parses this string into a JavaScript object.MediaUrl0andMediaContentType0parameters. Add media download/storage logic as needed.From,To,Text,MessageUUID). Add your application-specific logic (saving to DB, triggering replies, etc.).200 OKstatus to Plivo to acknowledge receipt.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:// 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:// 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 devAccess 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:
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):
# 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:Run Migrations: Create and apply the database migration:
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:
Deploy Options:
Monitoring & Observability:
Testing Strategy:
Example test for sendWhatsAppText:
// 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.tsthat 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
ngrokor similar tunneling service to expose your local RedwoodJS API server (port 8911) to the public internet. Runngrok http 8911, copy the HTTPS forwarding URL, and configure it in Plivo Console → Messaging → WhatsApp Numbers → Message URL ashttps://<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-V3header. Compute an HMAC-SHA256 hash of the webhook URL concatenated with the nonce and request body using yourAUTH_TOKENas the key. Compare this computed signature with the received signature usingcrypto.timingSafeEqualto 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:
How do I ensure GDPR compliance for message storage?
Implement these privacy best practices:
Common error codes and troubleshooting
PLIVO_AUTH_IDandPLIVO_AUTH_TOKENAUTH_TOKENand URL construction