This guide provides a step-by-step walkthrough for integrating Plivo's WhatsApp Business API into your Next.js application. You'll learn how to build a robust solution capable of sending and receiving WhatsApp messages, handling webhooks securely, and managing configurations effectively.
We'll cover everything from initial project setup to deployment, enabling you to leverage WhatsApp for customer communication, notifications, or interactive services directly within your Next.js app. By the end, you'll have a functional application ready for production use cases.
Project Overview and Goals
What We're Building:
A Next.js application with API routes that can:
- Send WhatsApp Messages: Trigger outgoing messages (text, media, templates, interactive) via Plivo's API through a secure backend endpoint.
- Receive WhatsApp Messages: Handle incoming messages and status updates from Plivo via a secure webhook endpoint.
Problem Solved:
This integration enables direct, programmatic communication with users on WhatsApp, bypassing the need for manual messaging or less integrated solutions. It's ideal for applications requiring timely notifications, customer support interactions, or automated conversational flows over WhatsApp.
Technologies Used:
- Next.js: A React framework providing server-side rendering, API routes, and a streamlined developer experience. Chosen for its integrated backend capabilities (API routes) and popularity in modern web development.
- Node.js: The underlying runtime for Next.js and the Plivo SDK.
- Plivo Node.js SDK: Simplifies interaction with the Plivo REST API for sending messages and handling communication logic.
- Plivo WhatsApp Business API: The service providing the connection to the WhatsApp network.
- (Optional)
ngrok
: For exposing local development servers to the internet to test webhooks.
System Architecture:
graph LR
A[User/Client App] -- HTTP Request --> B(Next.js Frontend);
B -- API Call --> C{Next.js API Route (/api/send-whatsapp)};
C -- Plivo SDK --> D[Plivo API];
D -- WhatsApp Network --> E[End User WhatsApp];
E -- Sends Message --> D;
D -- Webhook POST --> F{Next.js API Route (/api/plivo-webhook)};
F -- Process/Log --> G[(Optional) Database/Logging Service];
F -- 200 OK --> D;
style C fill:#f9f,stroke:#333,stroke-width:2px
style F fill:#f9f,stroke:#333,stroke-width:2px
Prerequisites:
- Node.js (v18 or later recommended)
npm
oryarn
package manager- A Plivo account with
Auth ID
andAuth Token
. - A WhatsApp Business Account (WABA) and a Plivo-approved WhatsApp Sender number configured in your Plivo console.
- Basic understanding of Next.js, APIs, and JavaScript.
- (Optional)
ngrok
installed for local webhook testing.
Final Outcome:
A Next.js application with two API endpoints: one to trigger outgoing WhatsApp messages and another to receive incoming messages/statuses from Plivo. The setup will use environment variables for security and include basic error handling.
1. Setting up the Project
Let's initialize a new Next.js project and install the necessary dependencies.
-
Create a Next.js App: Open your terminal and run the following command. Choose your preferred settings when prompted (we'll use TypeScript: No, ESLint: Yes, Tailwind CSS: No,
src/
directory: No, App Router: Yes, customize import alias: No for this guide).npx create-next-app@latest plivo-nextjs-whatsapp
-
Navigate to Project Directory:
cd plivo-nextjs-whatsapp
-
Install Plivo Node.js SDK: Add the Plivo SDK to your project dependencies.
npm install plivo
-
Set Up Environment Variables: Create a file named
.env.local
in the root of your project. This file is gitignored by default in Next.js and is the secure place for your credentials. Add your Plivo Auth ID and Auth Token.- Find your
Auth ID
andAuth Token
on the Plivo Console dashboard homepage.
# .env.local # Plivo Credentials PLIVO_AUTH_ID=YOUR_PLIVO_AUTH_ID PLIVO_AUTH_TOKEN=YOUR_PLIVO_AUTH_TOKEN # Your Plivo WhatsApp Sender Number (in E.164 format, e.g., +14155552671) PLIVO_WHATSAPP_SENDER_NUMBER=+1XXXXXXXXXX # Optional: A secret token if implementing additional custom webhook checks # PLIVO_WEBHOOK_SECRET=your_strong_random_secret_here
PLIVO_AUTH_ID
/PLIVO_AUTH_TOKEN
: Used by the SDK to authenticate API requests to Plivo. Obtainable from the Plivo Console dashboard. This is also used for validating incoming webhooks.PLIVO_WHATSAPP_SENDER_NUMBER
: Your Plivo-provisioned WhatsApp number used as thesrc
for outgoing messages. Find this under Messaging -> WhatsApp Senders in the Plivo Console.PLIVO_WEBHOOK_SECRET
: (Commented out by default) A secret you define. Plivo doesn't use this directly for its standard signature validation. You might use it if implementing additional custom verification logic beyond Plivo's signature check. Note: Plivo's primary webhook security relies on signature validation using yourPLIVO_AUTH_TOKEN
, which is covered later.
- Find your
-
Project Structure: Your basic structure (using App Router) will look something like this:
plivo-nextjs-whatsapp/ ├── app/ │ ├── api/ # API routes live here │ │ ├── send-whatsapp/ │ │ │ └── route.js │ │ └── plivo-webhook/ │ │ └── route.js │ ├── layout.js │ └── page.js ├── node_modules/ ├── public/ ├── .env.local # Your secret credentials ├── .gitignore ├── next.config.mjs ├── package.json └── README.md
We place our backend logic within the
app/api/
directory, following Next.js conventions for API routes.
2. Implementing Core Functionality: Sending Messages
We'll create an API route that accepts a request (containing destination number and message content) and uses the Plivo SDK to send a WhatsApp message.
-
Create the Send API Route: Create the file
app/api/send-whatsapp/route.js
. -
Implement the Sending Logic: Add the following code to
app/api/send-whatsapp/route.js
.// app/api/send-whatsapp/route.js import { NextResponse } from 'next/server'; import plivo from 'plivo'; // Ensure environment variables are loaded (Next.js does this automatically) const authId = process.env.PLIVO_AUTH_ID; const authToken = process.env.PLIVO_AUTH_TOKEN; const plivoWhatsappNumber = process.env.PLIVO_WHATSAPP_SENDER_NUMBER; // Validate essential environment variables if (!authId || !authToken || !plivoWhatsappNumber) { console.error(""Missing Plivo credentials or sender number in environment variables.""); // In a real app, you might throw an error or handle this differently // For now, we'll log and prevent client initialization } let client; try { // Initialize Plivo client only if credentials are present if (authId && authToken) { client = new plivo.Client(authId, authToken); } else { // Throw error if credentials are not available during initialization throw new Error(""Plivo client cannot be initialized due to missing credentials.""); } } catch (error) { console.error(""Failed to initialize Plivo client:"", error); // Handle client initialization failure globally if needed // Depending on setup, this might prevent the API route from working correctly } export async function POST(request) { if (!client) { console.error(""Plivo client is not initialized. Check server logs for initialization errors.""); return NextResponse.json( { success: false, error: ""Server configuration error: Plivo client not available."" }, { status: 500 } ); } let requestBody; try { requestBody = await request.json(); } catch (error) { return NextResponse.json({ success: false, error: ""Invalid JSON body"" }, { status: 400 }); } const { to, message, type = 'text', // Default to text message media_urls, template, interactive, location } = requestBody; // --- Input Validation --- if (!to || !to.startsWith('+') || to.length < 10) { // Basic E.164 check return NextResponse.json({ success: false, error: ""Invalid 'to' phone number format. Use E.164 (e.g., +14155552671)."" }, { status: 400 }); } if (type === 'text' && !message) { return NextResponse.json({ success: false, error: ""Missing 'message' field for text message."" }, { status: 400 }); } if (type === 'media' && (!media_urls || !Array.isArray(media_urls) || media_urls.length === 0)) { return NextResponse.json({ success: false, error: ""Missing or invalid 'media_urls' array for media message."" }, { status: 400 }); } if (type === 'template' && !template) { return NextResponse.json({ success: false, error: ""Missing 'template' object for template message."" }, { status: 400 }); } if (type === 'interactive' && !interactive) { return NextResponse.json({ success: false, error: ""Missing 'interactive' object for interactive message."" }, { status: 400 }); } if (type === 'location' && !location) { return NextResponse.json({ success: false, error: ""Missing 'location' object for location message."" }, { status: 400 }); } // Add more validation as needed based on message types (e.g., structure of template/interactive objects) // --- Construct Plivo Payload --- const payload = { src: plivoWhatsappNumber, dst: to, type: 'whatsapp', // Specify WhatsApp channel // Optional: Add a URL for delivery status callbacks for *this specific message* // url: 'https://yourdomain.com/api/plivo-webhook', // Overrides Plivo Application setting if provided // method: 'POST', }; // Add type-specific parameters switch(type) { case 'text': payload.text = message; break; case 'media': payload.media_urls = media_urls; // Optionally add text caption if needed if (message) payload.text = message; break; case 'template': payload.template = template; // Pass the template object directly break; case 'interactive': payload.interactive = interactive; // Pass the interactive object directly break; case 'location': payload.location = location; // Pass the location object directly break; default: return NextResponse.json({ success: false, error: `Unsupported message type: ${type}` }, { status: 400 }); } // --- Send Message via Plivo --- try { const response = await client.messages.create(payload); console.log(""Plivo API Response:"", response); // Success return NextResponse.json({ success: true, message_uuid: response.messageUuid?.[0], // Plivo returns uuids in an array api_id: response.apiId, message: response.message, // Confirmation message from Plivo }); } catch (error) { console.error(""Plivo API Error:"", error); // Extract more specific error details if available const errorMessage = error.message || ""Failed to send message via Plivo.""; const errorStatus = error.statusCode || 500; // Plivo errors often have a statusCode return NextResponse.json( { success: false, error: errorMessage, details: error.error }, // Include Plivo's error details if present { status: errorStatus } ); } }
Explanation:
- We import
NextResponse
for API responses and theplivo
SDK. - We retrieve Plivo credentials and the sender number from environment variables. Crucially, we check if they exist before initializing the client. An error is thrown if initialization fails due to missing credentials.
- The Plivo client is initialized using the Auth ID and Token. This happens outside the
POST
handler for potential reuse. - The
POST
function handles incoming requests. It first checks if theclient
was successfully initialized. It expects a JSON body containingto
(destination number) and fields specific to the messagetype
(e.g.,message
for text,media_urls
for media,template
object,interactive
object,location
object). - Basic input validation is performed on the
to
number and required fields based ontype
. - We construct the
payload
object for theclient.messages.create
method, settingsrc
,dst
, andtype: 'whatsapp'
. - A
switch
statement adds the correct parameters (text
,media_urls
,template
, etc.) based on the requestedtype
. - The
client.messages.create(payload)
function sends the request to Plivo. - We wrap the API call in a
try...catch
block to handle potential errors from the Plivo API (e.g., invalid number, insufficient funds, API downtime). - On success, we return the Plivo message UUID and API ID.
- On error, we log the error and return a JSON response with the error message and appropriate status code (using Plivo's status code if available).
- We import
3. Implementing Core Functionality: Receiving Messages (Webhook)
Plivo uses webhooks to send your application information about incoming messages or status updates for outgoing messages. We need an API route to receive these webhook POST requests.
-
Create the Webhook API Route: Create the file
app/api/plivo-webhook/route.js
. -
Implement the Webhook Handler: Add the following code to
app/api/plivo-webhook/route.js
.// app/api/plivo-webhook/route.js import { NextResponse } from 'next/server'; import plivo from 'plivo'; // SDK might be needed for validation utility import { headers } from 'next/headers'; // To access request headers // Retrieve Auth Token needed for signature validation const authToken = process.env.PLIVO_AUTH_TOKEN; // Note: PLIVO_WEBHOOK_SECRET (if defined) is not used for Plivo's standard signature validation. // --- Plivo Signature Validation Function (CRITICAL FOR SECURITY) --- // This function is a placeholder and conceptual example. // You MUST replace this with the actual validation logic provided in // Plivo's official documentation for Node.js. Failure to implement // correct validation leaves your webhook endpoint vulnerable. function validatePlivoSignature(request, rawBody) { const headerList = headers(); // Get access to headers const signature = headerList.get('X-Plivo-Signature-V3'); const nonce = headerList.get('X-Plivo-Signature-V3-Nonce'); // Construct the full URL the request was sent to. In Next.js on Vercel, // you might need to combine headers like 'x-forwarded-proto', 'x-forwarded-host', and request.nextUrl.pathname. // Consult Plivo docs and test carefully. For simplicity here, we use request.url // but verify this matches what Plivo expects. const url = request.url; if (!signature || !nonce || !authToken) { console.warn("Webhook validation skipped: Missing 'X-Plivo-Signature-V3' header, 'X-Plivo-Signature-V3-Nonce' header, or 'PLIVO_AUTH_TOKEN'."); return false; } try { // >>> CRITICAL: Replace this section with Plivo's official validation method <<< // 1. Check Plivo's Node.js SDK documentation for a validation utility function. // It might look something like: // // return plivo.validateV3Signature(url, nonce, signature, authToken, rawBody); // Check exact params required by SDK! // 2. If no SDK utility exists, implement the manual steps precisely as documented by Plivo. // This typically involves: // - Concatenating the URL, nonce, and raw request body (order matters!). // - Creating an HMAC SHA-256 hash of the concatenated string using your Auth Token as the key. // - Base64 encoding the hash. // - Performing a timing-safe comparison between the calculated signature and the one in the header. // Example conceptual placeholder (DO NOT USE IN PRODUCTION): // const crypto = require('crypto'); // const baseString = url + nonce + rawBody; // Verify exact concatenation order from Plivo docs // const expectedSignature = crypto.createHmac('sha256', authToken).update(baseString).digest('base64'); // const isValid = crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature)); // return isValid; // --- Current Status: Validation Logic Missing --- console.error("CRITICAL SECURITY WARNING: Plivo signature validation logic is NOT IMPLEMENTED. Using placeholder. Replace with logic from Plivo documentation IMMEDIATELY."); // Defaulting to 'false' for safety. Remove this line once real validation is implemented. return false; // ------------------------------------------------ } catch (error) { console.error("Error during (placeholder) signature validation:", error); return false; // Fail validation on any error during the process } } export async function POST(request) { let rawBody; try { // IMPORTANT: Need the raw request body *before* parsing for signature validation rawBody = await request.text(); // --- Validate Signature --- // CRITICAL: Enable and ensure correct implementation before production deployment. // const isValid = validatePlivoSignature(request, rawBody); // if (!isValid) { // console.warn("Invalid Plivo signature received. Rejecting request."); // // Return 403 Forbidden for invalid signatures // return new Response("Invalid signature", { status: 403 }); // } // Temporary log message while validation is bypassed/placeholder: console.warn("Plivo signature validation is currently bypassed or using placeholder logic. IMPLEMENT PROPER VALIDATION!"); // --- Parse and Process --- // Now parse the JSON body *after* validation (or temporary bypass) const data = JSON.parse(rawBody); console.log("Received Plivo Webhook Data:", JSON.stringify(data, null, 2)); // Extract common fields const messageType = data.Type; // e.g., 'message', 'message_status' const fromNumber = data.From; const toNumber = data.To; const messageUuid = data.MessageUUID; // Process based on webhook type if (messageType === 'message') { // An incoming message from a user const text = data.Text; const mediaUrl = data.MediaUrl; // If it's a media message const mediaContentType = data.MediaContentType; // Check for interactive message response data (structure depends on Plivo payload) const buttonPayload = data.Button?.Payload; // Example: Payload from button click const listId = data.List?.Id; // Example: ID of selected list item console.log(`Incoming message from ${fromNumber}: ${text || ('Media: ' + mediaUrl) || 'Interactive Response (check payload/ID)'}`); if (buttonPayload) { console.log(`Button Payload: ${buttonPayload}`); // Handle button response based on payload } if (listId) { console.log(`List Selection ID: ${listId}`); // Handle list selection based on ID } // Add your business logic here: // - Store the message in a database // - Trigger an automated response // - Route to a support system } else if (messageType === 'message_status') { // Status update for an outgoing message you sent const status = data.MessageStatus; // e.g., 'sent', 'delivered', 'read', 'failed', 'undelivered' console.log(`Status update for message ${messageUuid}: ${status}`); // Add your business logic here: // - Update message status in your database if (status === 'failed' || status === 'undelivered') { const errorCode = data.ErrorCode; console.error(`Message ${messageUuid} failed with code: ${errorCode}. Error details: ${data.ErrorReason || 'N/A'}`); // Handle failure (e.g., notify admin, update UI) } } else { console.log(`Received unhandled webhook type: ${messageType}`); } // --- Acknowledge Receipt --- // Plivo expects a 200 OK response to confirm receipt. // Avoid sending any body content unless specified by Plivo docs. return new Response(null, { status: 200 }); // Empty body, 200 status } catch (error) { if (error instanceof SyntaxError && rawBody !== undefined) { // Error parsing the rawBody as JSON console.error("Failed to parse webhook JSON body:", error); return NextResponse.json({ success: false, error: "Invalid JSON received" }, { status: 400 }); } else { // General processing error console.error("Error processing Plivo webhook:", error); // Avoid sending detailed internal errors back in the response for security. // Return 500 to indicate a server-side issue. Plivo might retry. return NextResponse.json({ success: false, error: "Internal server error processing webhook" }, { status: 500 }); } } }
Explanation:
- Signature Validation Function (
validatePlivoSignature
):- This is the most critical security component for your webhook. It verifies that incoming requests originate from Plivo.
- Placeholder Warning: The provided function is a placeholder only. It demonstrates the concept but lacks the actual validation logic.
- Action Required: You must consult the official Plivo documentation for Node.js and replace the placeholder section with Plivo's recommended signature validation method. This might involve using a utility function from the Plivo Node.js SDK (if available) or manually implementing the HMAC-SHA256 validation steps using the request URL, nonce header (
X-Plivo-Signature-V3-Nonce
), signature header (X-Plivo-Signature-V3
), your Plivo Auth Token, and the raw request body. - Do not deploy to production without implementing and testing correct signature validation. The current code defaults to failing validation (
return false;
) for safety if the placeholder isn't replaced.
- Webhook Handler (
POST
):- Reads the raw request body using
request.text()
before JSON parsing, as the raw body is essential for signature validation. - Validation Call (Commented Out): The call to
validatePlivoSignature
is commented out initially. You must uncomment and integrate it once the validation function is correctly implemented. If validation fails, a403 Forbidden
response should be returned. - Parsing: After successful validation (or during testing with the bypass), the raw body is parsed into a JSON object using
JSON.parse()
. - Processing: The code logs the incoming data, checks the
Type
field (message
ormessage_status
), and extracts relevant information. It includes examples for handling text, media, and basic interactive message responses (button clicks, list selections). - Business Logic: Placeholder comments indicate where your application-specific logic (database interaction, automated replies, etc.) should be added.
- Acknowledgement: A
200 OK
response with an empty body is returned to Plivo upon successful receipt and basic processing. Returning non-2xx status codes may cause Plivo to retry the webhook delivery.
- Reads the raw request body using
- Error Handling: Catches JSON parsing errors and other processing errors, logging them and returning appropriate HTTP status codes (
400
for bad JSON,500
for internal errors).
- Signature Validation Function (
4. Configuring Plivo for Webhooks
You need to tell Plivo where to send these webhook events. This is typically done by creating or configuring a Plivo Application.
- Navigate to Plivo Console: Go to Messaging -> Applications.
- Create or Edit an Application:
- Click ""Add New Application"".
- Give it a descriptive name (e.g., ""Next.js WhatsApp App"").
- Message URL: This is the crucial part. Enter the publicly accessible HTTPS URL for your webhook handler:
- Local Development: Start
ngrok
(ngrok http 3000
) and copy thehttps
forwarding URL. Your Message URL will behttps://<your-ngrok-subdomain>.ngrok.io/api/plivo-webhook
. - Production (e.g., Vercel): Use your deployed application's URL:
https://<your-vercel-app-name>.vercel.app/api/plivo-webhook
.
- Local Development: Start
- Method: Set to
POST
. - (Optional) Configure
Answer URL
and other settings if you plan to use Plivo for voice calls with this application as well. - Click ""Create Application"" or ""Update Application"".
- Associate Sender with Application:
- Go to Messaging -> WhatsApp Senders.
- Find your WhatsApp sender number and click ""Edit"".
- In the ""Application"" dropdown, select the Plivo Application you just created or updated.
- Click ""Update Sender"". This ensures incoming messages and status updates for this sender are routed to your webhook URL.
5. Error Handling, Logging, and Retries (Refined)
Robust applications need solid error handling.
- API Route Errors: The API routes already include
try...catch
blocks.- Log errors using
console.error
. In production, integrate a dedicated logging service (e.g., Sentry, Logtail, Datadog) for better aggregation, searching, and analysis. - Return meaningful JSON error responses with appropriate HTTP status codes (4xx for client errors like invalid input, 5xx for server errors like failed Plivo client initialization or downstream issues). Avoid leaking sensitive internal details in error responses sent to the client.
- Log errors using
- Plivo API Errors: The
catch
block in/api/send-whatsapp/route.js
specifically handles errors fromclient.messages.create()
. Log theerror.message
and potentiallyerror.error
(which might contain Plivo-specific error details) for debugging. Consider theerror.statusCode
to understand the error type (e.g., 400 bad request, 401 auth error, 5xx server error). - Webhook Errors: The webhook handler logs processing errors. Ensure signature validation failures are logged clearly, including why validation failed (e.g., missing header, calculation mismatch). If your internal processing logic fails after receiving the webhook (e.g., database write error), consider logging the error but still returning a
200 OK
to Plivo to prevent unnecessary retries if the issue is internal and not Plivo's fault (assuming you can handle the failure asynchronously or it's non-critical). If the webhook should be retried because the failure was temporary (e.g., temporary DB unavailability), return a5xx
error code. - Retries:
- Outgoing Messages: For transient errors from the Plivo API (e.g., network issues, temporary Plivo downtime resulting in 5xx errors), consider implementing a retry mechanism in the
/api/send-whatsapp
route. Use libraries likeasync-retry
for exponential backoff.npm install async-retry
// Example integration within /api/send-whatsapp/route.js POST function import retry from 'async-retry'; // ... inside the POST function, replace the direct client.messages.create call ... try { const response = await retry( async (bail, attemptNumber) => { console.log(`Attempt ${attemptNumber} to send message via Plivo...`); try { const apiResponse = await client.messages.create(payload); console.log("Plivo API call successful."); return apiResponse; // Success, return result } catch (error) { console.warn(`Plivo API call attempt ${attemptNumber} failed:`, error.message); // Don't retry on client errors (4xx) - indicates a problem with the request itself. if (error.statusCode && error.statusCode >= 400 && error.statusCode < 500) { console.error(`Bailing on Plivo API call due to client error ${error.statusCode}.`); bail(error); // bail stops retrying and throws the error captured here return; // Explicitly return after bailing } // For other errors (e.g., 5xx, network errors, no statusCode), throw to trigger retry. throw error; } }, { retries: 3, // Number of retries (e.g., 3 retries = 4 total attempts) factor: 2, // Exponential backoff factor minTimeout: 1000, // Initial delay in ms (1 second) maxTimeout: 10000, // Maximum delay in ms (10 seconds) onRetry: (error, attempt) => console.warn(`Retrying Plivo API call (Attempt ${attempt}). Error: ${error.message}`) } ); // Success after retries (or on first attempt) console.log("Successfully sent message after retries (if any). Plivo Response:", response); return NextResponse.json({ success: true, message_uuid: response.messageUuid?.[0], api_id: response.apiId, message: response.message, }); } catch (error) { // Error after all retries failed, or if bail() was called due to a 4xx error. console.error("Plivo API Error after all retries or non-retryable error:", error); const errorMessage = error.message || "Failed to send message via Plivo after retries."; const errorStatus = error.statusCode || 500; return NextResponse.json( { success: false, error: errorMessage, details: error.error }, { status: errorStatus } ); }
- Incoming Webhooks: Plivo automatically handles retries for webhooks if your endpoint doesn't return
200 OK
within a certain timeout. Ensure your endpoint is reliable and responds quickly. If processing might take longer than Plivo's timeout, acknowledge the webhook immediately (200 OK
) and process the data asynchronously.
- Outgoing Messages: For transient errors from the Plivo API (e.g., network issues, temporary Plivo downtime resulting in 5xx errors), consider implementing a retry mechanism in the