Implement Plivo SMS/MMS Delivery Status Callbacks in Node.js
A step-by-step guide to building a Node.js (Express) service to receive, validate, process, and store Plivo SMS/MMS delivery status updates using webhooks, with optional WebSocket integration.
Reliably tracking the delivery status of SMS and MMS messages is crucial for applications that depend on timely communication. Misdelivered or failed messages can lead to poor user experience, missed notifications, and operational issues. Plivo provides a robust mechanism using webhooks (callbacks) to notify your application in real-time about the status of sent messages.
This guide provides a step-by-step walkthrough for building a production-ready Node.js (Express) backend service to receive, validate, process, and store Plivo SMS/MMS delivery status updates. We'll also cover sending messages and optionally pushing these status updates to a frontend (like React/Vue built with Vite) via WebSockets.
Project Goals:
Receive Plivo Callbacks: Set up a secure endpoint to receive HTTP POST requests from Plivo containing message status updates.
Validate Requests: Ensure incoming requests genuinely originate from Plivo using signature validation.
Process Status Updates: Parse the callback data to understand the message status (e.g., queued, sent, delivered, failed).
Persist Status Data: Store message status information in a database for tracking, analysis, and auditing.
Real-time Notifications (Optional): Implement a WebSocket server to push status updates to connected clients (e.g., a frontend dashboard).
Send Test Messages: Create an endpoint to initiate sending an SMS/MMS message via Plivo, triggering the callback workflow.
Technologies Used:
Node.js: JavaScript runtime environment for the backend.
Express.js: Web framework for Node.js to create the API endpoint.
Plivo Node SDK: Official library for interacting with the Plivo API (sending messages and validating callbacks).
PostgreSQL: Relational database for storing status updates.
Prisma: Next-generation ORM for Node.js and TypeScript for database interaction.
ws: WebSocket library for real-time communication.
dotenv: Module to load environment variables from a .env file.
Ngrok (Development): Tool to expose your local server to the internet for Plivo callbacks.
Prerequisites:
Node.js and npm (or yarn) installed.
A Plivo account with Auth ID and Auth Token. (Sign up at Plivo)
A Plivo phone number capable of sending SMS/MMS.
Access to a PostgreSQL database (local or cloud-hosted).
A text editor or IDE (like VS Code).
Basic understanding of Node.js, Express, APIs, and databases.
Ngrok installed (optional, for local development testing).
System Architecture:
Text
+-----------------+ 1. Send SMS/MMS +-----------------+ 2. Status Update +---------------------+
| Your Application| ----------------------> | Plivo API | ----------------------> | Node.js Callback EP |
| (e.g., Frontend)| +-----------------+ | (This Guide) |
+-----------------+ +----------+----------+
^ | 3. Validate Req.
| 6. WebSocket Update (Optional) |
| | 4. Process Status
+----+-----------+ 5. Store Status +-----------------+ |
| WebSocket | <--------------------- | PostgreSQL DB | <----------------------------------+
| Clients | +-----------------+
+----------------+
Final Outcome:
By the end of this guide, you will have a robust Node.js service capable of:
Receiving and securely validating Plivo message delivery callbacks.
Storing detailed status updates in a database.
Sending SMS/MMS messages via Plivo that trigger these callbacks.
Optionally, broadcasting status updates in real-time via WebSockets.
1. Project Setup
Let's initialize our Node.js project and install the necessary dependencies.
Create Project Directory:
Open your terminal and create a new directory for the project.
Bash
mkdir plivo-callback-guide
cd plivo-callback-guide
Initialize Node.js Project:
Bash
npm init -y
This creates a package.json file.
Install Dependencies:
We need Express for the server, the Plivo SDK, dotenv for environment variables, ws for WebSockets, and Prisma for database interaction.
dotenv: Loads environment variables from a .env file.
ws: WebSocket server implementation.
prisma: Prisma CLI (dev dependency).
@prisma/client: Prisma database client.
Initialize Prisma:
Set up Prisma with PostgreSQL as the provider.
Bash
npx prisma init --datasource-provider postgresql
This creates a prisma directory with a schema.prisma file and a .env file (if one doesn't exist).
Configure Environment Variables:
Open the .env file created by Prisma (or create one) and add your Plivo credentials and database connection string. Replace the placeholder values with your actual credentials and URLs.
.env
# .env
# Plivo Credentials
# Get these from your Plivo Console: https://console.plivo.com/dashboard/
PLIVO_AUTH_ID="YOUR_PLIVO_AUTH_ID"
PLIVO_AUTH_TOKEN="YOUR_PLIVO_AUTH_TOKEN"
PLIVO_PHONE_NUMBER="YOUR_PLIVO_PHONE_NUMBER" # Sender number (e.g., +14155551212)
# Database Connection (Prisma)
# Example: postgresql://user:password@host:port/database?schema=public
DATABASE_URL="postgresql://user:password@localhost:5432/plivo_callbacks?schema=public"
# Application Settings
PORT=3000
# Replace with your Ngrok URL (dev) or public server URL (prod)
# Example: https://yourapp.com OR https://your-subdomain.ngrok.io
BASE_CALLBACK_URL="YOUR_PUBLICLY_ACCESSIBLE_URL"
BASE_CALLBACK_URL is crucial. Plivo needs a public URL to send callbacks to. We'll set this later using Ngrok for development.
Create .gitignore:
Create a .gitignore file in the root directory to avoid committing sensitive information and unnecessary files.
Define Database Schema:
Open prisma/schema.prisma and define a model to store message status updates.
Prisma
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model MessageStatus {
id String @id @default(cuid()) // Unique identifier for the status record
messageUUID String @unique // Plivo's unique identifier for the message
status String // e.g., queued, sent, delivered, failed, undelivered
errorCode String? // Plivo error code if status is failed/undelivered
rawPayload Json // Store the full callback payload for reference
receivedAt DateTime @default(now()) // Timestamp when the callback was received
updatedAt DateTime @updatedAt // Timestamp when the record was last updated
@@index([messageUUID])
@@index([status])
@@index([receivedAt])
}
This schema defines a MessageStatus table with relevant fields.
messageUUID is marked as unique to potentially update existing records (upsert) if multiple callbacks arrive for the same message.
rawPayload stores the complete JSON received from Plivo, useful for debugging or future analysis.
Apply Database Schema:
Run the Prisma migration command to create the MessageStatus table in your database. Prisma will prompt you to create a migration file.
Bash
npx prisma migrate dev --name init-message-status
Alternatively, if you prefer not to use migration files during early development (use with caution):
Bash
# npx prisma db push
Ensure your PostgreSQL database server is running and accessible using the DATABASE_URL in your .env file.
Create Server File:
Create a file named server.js in the root directory. This will contain our Express application logic.
JavaScript
// server.jsrequire('dotenv').config(); // Load .env variables firstconst express = require('express');
const plivo = require('plivo');
const { PrismaClient } = require('@prisma/client');
constWebSocket = require('ws');
// --- Basic Configuration ---const app = express();
const port = process.env.PORT || 3000;
const prisma = newPrismaClient();
// Plivo Client Initializationif (!process.env.PLIVO_AUTH_ID || !process.env.PLIVO_AUTH_TOKEN) {
console.error("Error: Plivo Auth ID or Auth Token missing in .env file.");
process.exit(1);
}
const plivoClient = new plivo.Client(process.env.PLIVO_AUTH_ID, process.env.PLIVO_AUTH_TOKEN);
// --- Middleware ---// Use Express's built-in JSON parser for most routes
app.use(express.json());
// Use raw body parser specifically for Plivo validation middleware// It MUST come before the validation middleware and handle potential content types
app.use('/plivo/callback', express.raw({ type: ['application/json', 'application/x-www-form-urlencoded'] }));
// --- WebSocket Setup (Optional) ---const wss = newWebSocket.Server({ noServer: true }); // Attach to HTTP server laterconst clients = newSet(); // Store connected WebSocket clients
wss.on('connection', (ws) => {
console.log('WebSocket client connected');
clients.add(ws);
ws.on('message', (message) => {
// Handle messages from clients if neededconsole.log('Received WebSocket message:', message.toString());
});
ws.on('close', () => {
console.log('WebSocket client disconnected');
clients.delete(ws);
});
ws.on('error', (error) => {
console.error('WebSocket error:', error);
clients.delete(ws); // Remove on error
});
ws.send(JSON.stringify({ message: 'Connected to Plivo Status WebSocket' }));
});
// Function to broadcast messages to all connected WebSocket clientsfunctionbroadcast(data) {
const message = JSON.stringify(data);
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message, (err) => {
if (err) {
console.error('WebSocket send error:', err);
// Optionally remove client if send fails repeatedly// clients.delete(client);
}
});
}
});
}
// --- Routes ---
app.get('/', (req, res) => {
res.send('Plivo Callback Server is Running!');
});
// Endpoint to receive Plivo callbacks (Implementation in next steps)
app.post('/plivo/callback', /* ... Plivo Validation Middleware ... */async (req, res) => {
// Callback processing logic here (will be added in Step 3)console.log('Placeholder for /plivo/callback POST');
res.status(200).send('Callback received (placeholder)');
});
// Endpoint to send a test SMS (Implementation in later steps)
app.post('/send-test-sms', async (req, res) => {
// SMS sending logic here (will be added in Step 7)console.log('Placeholder for /send-test-sms POST');
res.status(501).send('Not Implemented Yet');
});
// --- Global Error Handler ---
app.use((err, req, res, next) => {
console.error("Unhandled Error:", err.stack || err);
res.status(500).json({ error: 'Something went wrong!' });
});
// --- Start Server ---const server = app.listen(port, () => {
console.log(`Server listening on http://localhost:${port}`);
});
// Attach WebSocket server to the HTTP server
server.on('upgrade', (request, socket, head) => {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
});
});
// Graceful shutdown handlerconstshutdown = async (signal) => {
console.log(`${signal} signal received: closing HTTP server`);
server.close(async () => {
console.log('HTTP server closed');
await prisma.$disconnect();
console.log('Prisma client disconnected');
wss.close(() => {
console.log('WebSocket server closed');
process.exit(0); // Exit after cleanup
});
});
};
// Listen for termination signals
process.on('SIGTERM', () =>shutdown('SIGTERM'));
process.on('SIGINT', () =>shutdown('SIGINT'));
This sets up the basic Express server, initializes Prisma and the WebSocket server, and defines placeholders for our routes and error handling. Note the use of express.raw specifically for the callback route – Plivo's signature validation needs the raw, unparsed request body.
2. Plivo Setup and Callback URL
Before Plivo can send status updates, you need to tell it where to send them. This requires a publicly accessible URL.
Get Plivo Credentials: If you haven't already, log in to your Plivo Console and note your Auth ID and Auth Token. Ensure they are correctly set in your .env file.
Expose Local Server (Development): For development, use Ngrok to create a secure tunnel to your local machine.
Open a new terminal window (leave your server running in the first).
Run Ngrok, pointing it to the port your server is listening on (default 3000).
Bash
ngrok http 3000
Ngrok will display forwarding URLs (e.g., https://random-string.ngrok.io). Copy the https URL. This is your public base URL.
Update .env: Paste the Ngrok HTTPS URL (or your production URL) into your .env file as the BASE_CALLBACK_URL.
.env
# .env
# ... other variables
BASE_CALLBACK_URL=""https://<your-ngrok-subdomain>.ngrok.io"" # Or https://your-production-app.com
Important: Restart your Node.js server (node server.js or using nodemon) after updating the .env file for the changes to take effect. Check the server startup logs to ensure BASE_CALLBACK_URL is loaded.
Configure Plivo Application (Optional but Recommended): While you can specify the callback URL per message, it's good practice to set a default at the application level in Plivo.
Go to Plivo Console -> Messaging -> Applications.
Click ""Add New Application"".
Give it a name (e.g., ""Node Callback App"").
In the ""Message URL"" field, enter your full callback URL: ${BASE_CALLBACK_URL}/plivo/callback. Example: https://<your-ngrok-subdomain>.ngrok.io/plivo/callback.
Leave other fields blank or as default for now.
Click ""Create Application"". Plivo will assign an App ID.
You can optionally link your Plivo number to this application under Phone Numbers -> Your Number -> Application Type: XML Application, Application: Choose your app. This makes the callback URL the default for messages sent from that number unless overridden in the API call.
3. Building the Callback Endpoint & Validation
Now, let's implement the /plivo/callback endpoint to receive and validate requests. Security is paramount here; we must ensure requests actually come from Plivo.
Implement Validation Middleware: Plivo includes X-Plivo-Signature-V3, X-Plivo-Signature-V3-Nonce, and X-Plivo-Signature-V3-Timestamp headers. The SDK provides a utility to validate these. Modify the server.js file:
JavaScript
// server.js// ... (imports and setup) ...// --- Middleware ---
app.use(express.json()); // For other routes// Raw body parser MUST come before the validation middleware for the specific route
app.use('/plivo/callback', express.raw({ type: ['application/json', 'application/x-www-form-urlencoded'] })); // Handle potential content types// Plivo Validation Middleware FunctionconstvalidatePlivoSignature = (req, res, next) => {
const signature = req.headers['x-plivo-signature-v3'];
const nonce = req.headers['x-plivo-signature-v3-nonce'];
const timestamp = req.headers['x-plivo-signature-v3-timestamp'];
const method = req.method; // Typically POST// Construct the full URL Plivo used to send the requestconst url = process.env.BASE_CALLBACK_URL + req.originalUrl;
// Use the raw body buffer for validationconst rawBody = req.body; // Thanks to express.raw() middlewareconsole.log('Received Plivo Callback:');
console.log(` URL: ${url}`);
console.log(` Method: ${method}`);
console.log(` Signature: ${signature ? 'Present' : 'MISSING!'}`);
console.log(` Nonce: ${nonce ? 'Present' : 'MISSING!'}`);
console.log(` Timestamp: ${timestamp ? 'Present' : 'MISSING!'}`);
// console.log(` Raw Body: ${rawBody.toString()}`); // Uncomment for debugging (can be verbose/sensitive)if (!signature || !nonce || !timestamp || !process.env.PLIVO_AUTH_TOKEN) {
console.warn('Missing Plivo signature headers or Auth Token');
return res.status(400).send('Missing Plivo signature headers or server misconfiguration');
}
if (!Buffer.isBuffer(rawBody)) {
console.error('Raw body is not a buffer. Check middleware order.');
return res.status(500).send('Internal Server Error: Body parsing issue');
}
try {
// Plivo SDK v6+ uses validateV3Signatureconst isValid = plivo.validateV3Signature(
method, // HTTP Method (e.g., 'POST')
url, // Full URL Plivo sent the request to
nonce,
timestamp,
process.env.PLIVO_AUTH_TOKEN, // Use Auth Token for v3 validation
signature,
rawBody.toString() // Pass the raw body as a string
);
if (isValid) {
console.log('Plivo signature validation successful.');
// IMPORTANT: Parse the raw body AFTER validation if it's JSON// Store the parsed body back on req.body for the main handlerif (req.is('application/json') && Buffer.isBuffer(rawBody)) {
try {
// Replace the buffer with the parsed object
req.body = JSON.parse(rawBody.toString());
} catch (parseError) {
console.error('Failed to parse JSON body after validation:', parseError);
// Still proceed, but the handler will need to deal with the raw buffer// Or return an error if JSON is strictly required// return res.status(400).send('Invalid JSON payload');
}
} elseif (req.is('application/x-www-form-urlencoded') && Buffer.isBuffer(rawBody)) {
// Plivo status callbacks are typically JSON, but handle just in caseconsole.warn('Received form-urlencoded Plivo callback. Body kept as buffer.');
// If needed, parse using: const querystring = require('querystring');// req.body = querystring.parse(rawBody.toString());
}
// If not JSON or form-urlencoded, req.body remains the raw buffernext(); // Signature is valid, proceed to the route handler
} else {
console.warn('Invalid Plivo signature.');
return res.status(403).send('Invalid Plivo signature');
}
} catch (error) {
console.error('Error validating Plivo signature:', error);
return res.status(500).send('Error validating signature');
}
};
// --- Routes ---
app.get('/', (req, res) => {
res.send('Plivo Callback Server is Running!');
});
// Apply the validation middleware ONLY to the callback route
app.post('/plivo/callback', validatePlivoSignature, async (req, res) => {
// Process the validated callback (next step)let payload;
let rawPayloadStr = '';
// Check if body was parsed in middleware, otherwise try to parse if it's a bufferif (Buffer.isBuffer(req.body)) {
rawPayloadStr = req.body.toString();
// Attempt to parse if it wasn't handled in middleware (e.g., unexpected content type)try {
payload = JSON.parse(rawPayloadStr);
} catch (e) {
console.error("Failed to parse request body buffer in handler:", e);
// Send 200 OK anyway to prevent Plivo retries, but log the failure.// We won't be able to process this specific callback further.return res.status(200).send('Callback received but could not parse body');
}
} elseif (typeof req.body === 'object' && req.body !== null) {
payload = req.body; // Already parsed (likely JSON by middleware)try {
rawPayloadStr = JSON.stringify(payload); // For logging/storage if needed
} catch (e) {
console.error("Failed to stringify already parsed payload:", e);
}
} else {
console.error("Unexpected body type in handler:", typeof req.body);
return res.status(200).send('Callback received with unexpected body type');
}
console.log("Validated Plivo Payload:", JSON.stringify(payload, null, 2));
// Acknowledge receipt immediately to Plivo
res.status(200).send('Callback received');
// --- Asynchronous processing happens after sending the response ---try {
const messageUUID = payload.MessageUUID;
const status = payload.Status;
const errorCode = payload.ErrorCode; // Present on failure/undeliveredif (!messageUUID) {
console.warn("Callback received without MessageUUID:", payload);
return; // Can't process without UUID
}
console.log(`Processing status: ${status} for MessageUUID: ${messageUUID}`);
// Store in Database (Step 5)const savedStatus = await prisma.messageStatus.upsert({
where: { messageUUID: messageUUID },
update: {
status: status,
errorCode: errorCode || null, // Ensure null if not presentrawPayload: payload, // Store the parsed payload object as JSONB
},
create: {
messageUUID: messageUUID,
status: status,
errorCode: errorCode || null,
rawPayload: payload, // Store the parsed payload object as JSONB
},
});
console.log(`Saved/Updated status for ${messageUUID} in DB (ID: ${savedStatus.id})`);
// Broadcast via WebSocket (Step 6)broadcast({
type: 'status_update',
messageUUID: messageUUID,
status: status,
errorCode: errorCode || null,
timestamp: savedStatus.updatedAt// Use DB timestamp
});
console.log(`Broadcasted status update for ${messageUUID} via WebSocket`);
} catch (error) {
// Use optional chaining in case payload was unparseable or missing fieldsconsole.error(`Error processing callback for ${payload?.MessageUUID || 'unknown UUID'}:`, error);
// Error is logged, but we already sent 200 OK to Plivo.// Implement internal alerting/monitoring for processing failures.
}
});
// ... (send-test-sms route, error handler, server start) ...
We create a validatePlivoSignature middleware function.
It extracts necessary headers and constructs the full URL Plivo called.
Crucially, it uses req.body (which contains the raw Buffer thanks to express.raw) and the plivo.validateV3Signature method. Note: Plivo's V3 signature uses the Auth Token.
If validation passes, it calls next(). Otherwise, it sends a 403 Forbidden or 400 Bad Request.
Important: After successful validation, we attempt to parse the rawBody back into req.body as JSON if the content type was JSON. This makes req.body accessible as an object in the main route handler. Added robust handling in the main route to manage cases where parsing might fail or the body isn't a buffer.
The middleware is applied only to the /plivo/callback POST route.
4. Processing Callbacks
The previous step already included the processing logic inside the /plivo/callback route after validation and the immediate res.status(200).send():
Acknowledge Quickly: The res.status(200).send(...) happens before database or WebSocket operations. This confirms receipt to Plivo promptly, preventing timeouts and retries on their end.
Extract Data: Key fields like MessageUUID, Status, and ErrorCode are extracted from the validated payload object.
Asynchronous Operations: Database saving and WebSocket broadcasting happen asynchronously after the response has been sent.
Common Statuses:
queued: Plivo has accepted the message.
sent: Plivo has sent the message to the downstream carrier.
delivered: The carrier confirmed delivery to the recipient's handset.
failed: Plivo couldn't send the message (e.g., invalid number, API error). ErrorCode provides details.
undelivered: The carrier couldn't deliver the message (e.g., number blocked, phone off, out of coverage). ErrorCode provides details.
We use Prisma to save the status updates to our PostgreSQL database. This logic was implemented within the try...catch block of the /plivo/callback route in Step 3:
JavaScript
// Inside the try block of app.post('/plivo/callback', ...)// Store in Databaseconst savedStatus = await prisma.messageStatus.upsert({
where: { messageUUID: messageUUID }, // Find existing record by unique UUIDupdate: { // If found, update these fieldsstatus: status,
errorCode: errorCode || null,
rawPayload: payload, // Update the full payload too (as JSON)// updatedAt is handled automatically by Prisma @updatedAt
},
create: { // If not found, create a new recordmessageUUID: messageUUID,
status: status,
errorCode: errorCode || null,
rawPayload: payload, // Store the full payload (as JSON)// receivedAt handled by Prisma @default(now())// updatedAt handled by Prisma @updatedAt
},
});
console.log(`Saved/Updated status for ${messageUUID} in DB (ID: ${savedStatus.id})`);
prisma.messageStatus.upsert is used:
It tries to find a record where messageUUID matches the incoming one.
If found (update), it updates the status, errorCode, rawPayload, and updatedAt timestamp.
If not found (create), it creates a new record with all the details.
This handles scenarios where multiple callbacks might arrive for the same message (e.g., queued then sent then delivered). The rawPayload field stores the entire JSON object received from Plivo for reference.
6. Real-time Frontend Updates (WebSockets)
To push updates to a frontend, we use the WebSocket server. This logic was also added within the try...catch block of the /plivo/callback route in Step 3, after the database save:
JavaScript
// Inside the try block of app.post('/plivo/callback', ...) after prisma.upsert// Broadcast via WebSocketbroadcast({ // Call the broadcast function defined earliertype: 'status_update', // Add a type for client-side routingmessageUUID: messageUUID,
status: status,
errorCode: errorCode || null,
timestamp: savedStatus.updatedAt// Send the DB timestamp
});
console.log(`Broadcasted status update for ${messageUUID} via WebSocket`);
The broadcast function (defined in Step 1) sends the status update data as a JSON string to all currently connected WebSocket clients.
We include a type field (status_update) so client-side code can differentiate message types.
Client-Side Implementation (Conceptual Example for React/Vue):
JavaScript
// Example React/Vue component using WebSocketimportReact, { useState, useEffect } from'react';
functionStatusDashboard() {
const [statuses, setStatuses] = useState({}); // Store statuses by MessageUUIDconst [connectionStatus, setConnectionStatus] = useState('Connecting...');
useEffect(() => {
// Determine WebSocket protocol based on window locationconst wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// Use the same host as the web page, default port if neededconst wsHost = window.location.host; // e.g., localhost:3000 or your-production-domain.comconst wsUrl = `${wsProtocol}//${wsHost}`; // Connects to the root path where WS server is attachedlet ws;
let reconnectInterval;
functionconnect() {
ws = newWebSocket(wsUrl);
console.log('Attempting WebSocket connection to:', wsUrl);
setConnectionStatus('Connecting...');
ws.onopen = () => {
console.log('WebSocket connected');
setConnectionStatus('Connected');
if (reconnectInterval) {
clearInterval(reconnectInterval);
reconnectInterval = null;
}
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('Received status update:', data);
if (data.type === 'status_update' && data.messageUUID) {
setStatuses(prevStatuses => ({
...prevStatuses,
[data.messageUUID]: {
status: data.status,
errorCode: data.errorCode,
timestamp: data.timestamp,
},
}));
} elseif (data.message) {
console.log(""Servermessage:"", data.message); // Handle initial connection message etc.
}
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
setConnectionStatus('Error');
ws.close(); // Ensure connection is closed on error before retry
};
ws.onclose = () => {
console.log('WebSocket disconnected');
setConnectionStatus('Disconnected - Retrying...');
// Simple exponential backoff retryif (!reconnectInterval) {
let delay = 1000;
reconnectInterval = setInterval(() => {
console.log(`Retrying connection in ${delay / 1000}s...`);
connect(); // Attempt to reconnect
delay = Math.min(delay * 2, 30000); // Increase delay up to 30s
}, delay);
}
};
}
connect(); // Initial connection attempt// Cleanup function to close WebSocket and clear interval on component unmountreturn() => {
if (reconnectInterval) {
clearInterval(reconnectInterval);
}
if (ws) {
ws.close();
}
};
}, []); // Empty dependency array ensures this runs only once on mountreturn (
<div><h2>Live Message Statuses</h2><p>Connection: {connectionStatus}</p><ul>
{Object.entries(statuses).map(([uuid, data]) => (
<likey={uuid}><strong>UUID:</strong> {uuid} |
<strong>Status:</strong> {data.status}
{data.errorCode && ` (Error: ${data.errorCode})`} |
<strong>Updated:</strong> {new Date(data.timestamp).toLocaleString()}
</li>
))}
</ul>
{Object.keys(statuses).length === 0 && connectionStatus === 'Connected' && <p>Waiting for status updates...</p>}
</div>
);
}
exportdefaultStatusDashboard;
This frontend component connects to the WebSocket server running on the same host and port as the main HTTP server. It uses ws: or wss: based on the page protocol.
It listens for messages, parses the JSON, and updates the component's state if it's a status_update.
It includes basic connection status display and a simple reconnection mechanism.
It displays the live status updates.
7. Sending a Test Message
To trigger the callback flow, we need an endpoint to send an SMS/MMS message using the Plivo SDK.
Implement Send Endpoint: Add the following route to server.js.
JavaScript
// server.js// ... (imports, setup, other routes) ...// Endpoint to send a test SMS
app.post('/send-test-sms', express.json(), async (req, res) => { // Ensure JSON parsing for this routeconst { to, text } = req.body;
if (!to || !text) {
return res.status(400).json({ error: 'Missing "to" (recipient number) or "text" in request body' });
}
if (!process.env.PLIVO_PHONE_NUMBER) {
console.error("Plivo sender phone number (PLIVO_PHONE_NUMBER) is not configured in .env");
return res.status(500).json({ error: 'Server configuration error: Missing sender number' });
}
if (!process.env.BASE_CALLBACK_URL) {
console.error("Base callback URL (BASE_CALLBACK_URL) is not configured in .env");
return res.status(500).json({ error: 'Server configuration error: Missing callback URL base' });
}
const senderNumber = process.env.PLIVO_PHONE_NUMBER;
// Construct the full callback URL for this specific messageconst deliveryStatusCallbackUrl = `${process.env.BASE_CALLBACK_URL}/plivo/callback`;
console.log(`Attempting to send SMS from ${senderNumber} to ${to} with callback to ${deliveryStatusCallbackUrl}`);
try {
const response = await plivoClient.messages.create(
senderNumber, // src
to, // dst
text, // text
{
url: deliveryStatusCallbackUrl, // Delivery status report URLmethod: 'POST'// Method for the callback
}
// For MMS, add 'media_urls': ['https://example.com/image.jpg']
);
console.log('Plivo Send API Response:', response);
// The response contains the message_uuid which you can correlate// with the callbacks later.
res.status(202).json({
message: 'Message sending initiated via Plivo.',
plivoResponse: response
});
} catch (error) {
console.error('Error sending message via Plivo:', error);
res.status(error.statusCode || 500).json({
error: 'Failed to send message via Plivo.',
details: error.message || error.error || error // Plivo SDK might have different error structures
});
}
});
// ... (Global Error Handler, Server Start, Shutdown Logic) ...
Frequently asked questions
Use Plivo's webhooks (callbacks) to receive real-time delivery status updates. Set up a Node.js (Express) backend to receive HTTP POST requests from Plivo containing message status information like 'queued', 'sent', 'delivered', or 'failed'.
The callback URL is the publicly accessible URL of your Node.js server endpoint that will receive status updates. In development, use Ngrok to expose your local server, then provide the Ngrok HTTPS URL to Plivo as your BASE_CALLBACK_URL. In production, it's the public URL of your deployed application.
Callback validation ensures that incoming requests genuinely originate from Plivo and haven't been tampered with. This prevents unauthorized access and protects your application from malicious attacks.
Acknowledge Plivo's callback immediately with a 200 OK response before performing any database operations or sending WebSocket updates. This prevents Plivo from retrying the callback due to timeouts, ensuring reliable notification delivery.
Yes, you can implement real-time status updates in your frontend (e.g., React/Vue) by setting up a WebSocket server in your Node.js backend. Broadcast status updates to connected clients after receiving and processing a valid Plivo callback.
Create a POST endpoint in your Node.js Express app that uses the Plivo Node.js SDK's messages.create() method. Provide your Plivo phone number, the recipient's number, the message text, and the callback URL for delivery updates.
Use app.post('/plivo/callback', validatePlivoSignature, callbackHandler) to define your route. The validatePlivoSignature middleware validates requests, and callbackHandler processes and saves the status updates to the database.
Common statuses include 'queued', 'sent', 'delivered', 'failed', and 'undelivered'. 'Failed' indicates an issue on Plivo's end, while 'undelivered' indicates a carrier-side problem. ErrorCode provides details for failures.
Use plivo.validateV3Signature method from the Plivo Node SDK, including the request method, full URL, nonce, timestamp, your Plivo Auth Token, provided signature, and the raw request body string.
Use Prisma ORM (or your preferred database library) and a PostgreSQL (or your preferred) database. The provided example creates a MessageStatus table to record the unique message UUID, current status, error codes, the full callback payload, and timestamps.
The messageUUID is a unique identifier assigned by Plivo to each message you send. Use it to track individual messages, update status records in your database, and correlate callbacks to sent messages.
The key libraries are express for the web server, plivo for the Plivo Node.js SDK, dotenv to load environment variables, and ws to implement a WebSocket server for real-time updates to the frontend (optional).
Use an upsert operation in your database logic. This allows you to update the existing record for the messageUUID if one exists, or create a new one if it doesn't, handling multiple status updates for a single message.
The project utilizes Node.js with Express, the Plivo Node SDK, PostgreSQL database, Prisma ORM, optionally WebSockets with the 'ws' library, and Ngrok for local development testing.
Reliably tracking the delivery status of SMS and MMS messages is crucial for applications that depend on timely communication. Misdelivered or failed messages can lead to poor user experience, missed notifications, and operational issues. Plivo provides a robust mechanism using webhooks (callbacks) to notify your application in real-time about the status of sent messages.
This guide provides a step-by-step walkthrough for building a production-ready Node.js (Express) backend service to receive, validate, process, and store Plivo SMS/MMS delivery status updates. We'll also cover sending messages and optionally pushing these status updates to a frontend (like React/Vue built with Vite) via WebSockets.
Project Goals:
queued,sent,delivered,failed).Technologies Used:
ws: WebSocket library for real-time communication.dotenv: Module to load environment variables from a.envfile.Prerequisites:
System Architecture:
Final Outcome:
By the end of this guide, you will have a robust Node.js service capable of:
1. Project Setup
Let's initialize our Node.js project and install the necessary dependencies.
Create Project Directory: Open your terminal and create a new directory for the project.
mkdir plivo-callback-guide cd plivo-callback-guideInitialize Node.js Project:
This creates a
package.jsonfile.Install Dependencies: We need Express for the server, the Plivo SDK,
dotenvfor environment variables,wsfor WebSockets, and Prisma for database interaction.express: Web server framework.plivo: Plivo's official Node.js SDK.dotenv: Loads environment variables from a.envfile.ws: WebSocket server implementation.prisma: Prisma CLI (dev dependency).@prisma/client: Prisma database client.Initialize Prisma: Set up Prisma with PostgreSQL as the provider.
This creates a
prismadirectory with aschema.prismafile and a.envfile (if one doesn't exist).Configure Environment Variables: Open the
.envfile created by Prisma (or create one) and add your Plivo credentials and database connection string. Replace the placeholder values with your actual credentials and URLs.BASE_CALLBACK_URLis crucial. Plivo needs a public URL to send callbacks to. We'll set this later using Ngrok for development.Create
.gitignore: Create a.gitignorefile in the root directory to avoid committing sensitive information and unnecessary files.Define Database Schema: Open
prisma/schema.prismaand define a model to store message status updates.MessageStatustable with relevant fields.messageUUIDis marked as unique to potentially update existing records (upsert) if multiple callbacks arrive for the same message.rawPayloadstores the complete JSON received from Plivo, useful for debugging or future analysis.Apply Database Schema: Run the Prisma migration command to create the
MessageStatustable in your database. Prisma will prompt you to create a migration file.Alternatively, if you prefer not to use migration files during early development (use with caution):
# npx prisma db pushEnsure your PostgreSQL database server is running and accessible using the
DATABASE_URLin your.envfile.Create Server File: Create a file named
server.jsin the root directory. This will contain our Express application logic.// server.js require('dotenv').config(); // Load .env variables first const express = require('express'); const plivo = require('plivo'); const { PrismaClient } = require('@prisma/client'); const WebSocket = require('ws'); // --- Basic Configuration --- const app = express(); const port = process.env.PORT || 3000; const prisma = new PrismaClient(); // Plivo Client Initialization if (!process.env.PLIVO_AUTH_ID || !process.env.PLIVO_AUTH_TOKEN) { console.error("Error: Plivo Auth ID or Auth Token missing in .env file."); process.exit(1); } const plivoClient = new plivo.Client(process.env.PLIVO_AUTH_ID, process.env.PLIVO_AUTH_TOKEN); // --- Middleware --- // Use Express's built-in JSON parser for most routes app.use(express.json()); // Use raw body parser specifically for Plivo validation middleware // It MUST come before the validation middleware and handle potential content types app.use('/plivo/callback', express.raw({ type: ['application/json', 'application/x-www-form-urlencoded'] })); // --- WebSocket Setup (Optional) --- const wss = new WebSocket.Server({ noServer: true }); // Attach to HTTP server later const clients = new Set(); // Store connected WebSocket clients wss.on('connection', (ws) => { console.log('WebSocket client connected'); clients.add(ws); ws.on('message', (message) => { // Handle messages from clients if needed console.log('Received WebSocket message:', message.toString()); }); ws.on('close', () => { console.log('WebSocket client disconnected'); clients.delete(ws); }); ws.on('error', (error) => { console.error('WebSocket error:', error); clients.delete(ws); // Remove on error }); ws.send(JSON.stringify({ message: 'Connected to Plivo Status WebSocket' })); }); // Function to broadcast messages to all connected WebSocket clients function broadcast(data) { const message = JSON.stringify(data); clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(message, (err) => { if (err) { console.error('WebSocket send error:', err); // Optionally remove client if send fails repeatedly // clients.delete(client); } }); } }); } // --- Routes --- app.get('/', (req, res) => { res.send('Plivo Callback Server is Running!'); }); // Endpoint to receive Plivo callbacks (Implementation in next steps) app.post('/plivo/callback', /* ... Plivo Validation Middleware ... */ async (req, res) => { // Callback processing logic here (will be added in Step 3) console.log('Placeholder for /plivo/callback POST'); res.status(200).send('Callback received (placeholder)'); }); // Endpoint to send a test SMS (Implementation in later steps) app.post('/send-test-sms', async (req, res) => { // SMS sending logic here (will be added in Step 7) console.log('Placeholder for /send-test-sms POST'); res.status(501).send('Not Implemented Yet'); }); // --- Global Error Handler --- app.use((err, req, res, next) => { console.error("Unhandled Error:", err.stack || err); res.status(500).json({ error: 'Something went wrong!' }); }); // --- Start Server --- const server = app.listen(port, () => { console.log(`Server listening on http://localhost:${port}`); }); // Attach WebSocket server to the HTTP server server.on('upgrade', (request, socket, head) => { wss.handleUpgrade(request, socket, head, (ws) => { wss.emit('connection', ws, request); }); }); // Graceful shutdown handler const shutdown = async (signal) => { console.log(`${signal} signal received: closing HTTP server`); server.close(async () => { console.log('HTTP server closed'); await prisma.$disconnect(); console.log('Prisma client disconnected'); wss.close(() => { console.log('WebSocket server closed'); process.exit(0); // Exit after cleanup }); }); }; // Listen for termination signals process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT'));This sets up the basic Express server, initializes Prisma and the WebSocket server, and defines placeholders for our routes and error handling. Note the use of
express.rawspecifically for the callback route – Plivo's signature validation needs the raw, unparsed request body.2. Plivo Setup and Callback URL
Before Plivo can send status updates, you need to tell it where to send them. This requires a publicly accessible URL.
Auth IDandAuth Token. Ensure they are correctly set in your.envfile.https://random-string.ngrok.io). Copy thehttpsURL. This is your public base URL..env: Paste the Ngrok HTTPS URL (or your production URL) into your.envfile as theBASE_CALLBACK_URL.node server.jsor usingnodemon) after updating the.envfile for the changes to take effect. Check the server startup logs to ensureBASE_CALLBACK_URLis loaded.${BASE_CALLBACK_URL}/plivo/callback. Example:https://<your-ngrok-subdomain>.ngrok.io/plivo/callback.App ID.3. Building the Callback Endpoint & Validation
Now, let's implement the
/plivo/callbackendpoint to receive and validate requests. Security is paramount here; we must ensure requests actually come from Plivo.Implement Validation Middleware: Plivo includes
X-Plivo-Signature-V3,X-Plivo-Signature-V3-Nonce, andX-Plivo-Signature-V3-Timestampheaders. The SDK provides a utility to validate these. Modify theserver.jsfile:// server.js // ... (imports and setup) ... // --- Middleware --- app.use(express.json()); // For other routes // Raw body parser MUST come before the validation middleware for the specific route app.use('/plivo/callback', express.raw({ type: ['application/json', 'application/x-www-form-urlencoded'] })); // Handle potential content types // Plivo Validation Middleware Function const validatePlivoSignature = (req, res, next) => { const signature = req.headers['x-plivo-signature-v3']; const nonce = req.headers['x-plivo-signature-v3-nonce']; const timestamp = req.headers['x-plivo-signature-v3-timestamp']; const method = req.method; // Typically POST // Construct the full URL Plivo used to send the request const url = process.env.BASE_CALLBACK_URL + req.originalUrl; // Use the raw body buffer for validation const rawBody = req.body; // Thanks to express.raw() middleware console.log('Received Plivo Callback:'); console.log(` URL: ${url}`); console.log(` Method: ${method}`); console.log(` Signature: ${signature ? 'Present' : 'MISSING!'}`); console.log(` Nonce: ${nonce ? 'Present' : 'MISSING!'}`); console.log(` Timestamp: ${timestamp ? 'Present' : 'MISSING!'}`); // console.log(` Raw Body: ${rawBody.toString()}`); // Uncomment for debugging (can be verbose/sensitive) if (!signature || !nonce || !timestamp || !process.env.PLIVO_AUTH_TOKEN) { console.warn('Missing Plivo signature headers or Auth Token'); return res.status(400).send('Missing Plivo signature headers or server misconfiguration'); } if (!Buffer.isBuffer(rawBody)) { console.error('Raw body is not a buffer. Check middleware order.'); return res.status(500).send('Internal Server Error: Body parsing issue'); } try { // Plivo SDK v6+ uses validateV3Signature const isValid = plivo.validateV3Signature( method, // HTTP Method (e.g., 'POST') url, // Full URL Plivo sent the request to nonce, timestamp, process.env.PLIVO_AUTH_TOKEN, // Use Auth Token for v3 validation signature, rawBody.toString() // Pass the raw body as a string ); if (isValid) { console.log('Plivo signature validation successful.'); // IMPORTANT: Parse the raw body AFTER validation if it's JSON // Store the parsed body back on req.body for the main handler if (req.is('application/json') && Buffer.isBuffer(rawBody)) { try { // Replace the buffer with the parsed object req.body = JSON.parse(rawBody.toString()); } catch (parseError) { console.error('Failed to parse JSON body after validation:', parseError); // Still proceed, but the handler will need to deal with the raw buffer // Or return an error if JSON is strictly required // return res.status(400).send('Invalid JSON payload'); } } else if (req.is('application/x-www-form-urlencoded') && Buffer.isBuffer(rawBody)) { // Plivo status callbacks are typically JSON, but handle just in case console.warn('Received form-urlencoded Plivo callback. Body kept as buffer.'); // If needed, parse using: const querystring = require('querystring'); // req.body = querystring.parse(rawBody.toString()); } // If not JSON or form-urlencoded, req.body remains the raw buffer next(); // Signature is valid, proceed to the route handler } else { console.warn('Invalid Plivo signature.'); return res.status(403).send('Invalid Plivo signature'); } } catch (error) { console.error('Error validating Plivo signature:', error); return res.status(500).send('Error validating signature'); } }; // --- Routes --- app.get('/', (req, res) => { res.send('Plivo Callback Server is Running!'); }); // Apply the validation middleware ONLY to the callback route app.post('/plivo/callback', validatePlivoSignature, async (req, res) => { // Process the validated callback (next step) let payload; let rawPayloadStr = ''; // Check if body was parsed in middleware, otherwise try to parse if it's a buffer if (Buffer.isBuffer(req.body)) { rawPayloadStr = req.body.toString(); // Attempt to parse if it wasn't handled in middleware (e.g., unexpected content type) try { payload = JSON.parse(rawPayloadStr); } catch (e) { console.error("Failed to parse request body buffer in handler:", e); // Send 200 OK anyway to prevent Plivo retries, but log the failure. // We won't be able to process this specific callback further. return res.status(200).send('Callback received but could not parse body'); } } else if (typeof req.body === 'object' && req.body !== null) { payload = req.body; // Already parsed (likely JSON by middleware) try { rawPayloadStr = JSON.stringify(payload); // For logging/storage if needed } catch (e) { console.error("Failed to stringify already parsed payload:", e); } } else { console.error("Unexpected body type in handler:", typeof req.body); return res.status(200).send('Callback received with unexpected body type'); } console.log("Validated Plivo Payload:", JSON.stringify(payload, null, 2)); // Acknowledge receipt immediately to Plivo res.status(200).send('Callback received'); // --- Asynchronous processing happens after sending the response --- try { const messageUUID = payload.MessageUUID; const status = payload.Status; const errorCode = payload.ErrorCode; // Present on failure/undelivered if (!messageUUID) { console.warn("Callback received without MessageUUID:", payload); return; // Can't process without UUID } console.log(`Processing status: ${status} for MessageUUID: ${messageUUID}`); // Store in Database (Step 5) const savedStatus = await prisma.messageStatus.upsert({ where: { messageUUID: messageUUID }, update: { status: status, errorCode: errorCode || null, // Ensure null if not present rawPayload: payload, // Store the parsed payload object as JSONB }, create: { messageUUID: messageUUID, status: status, errorCode: errorCode || null, rawPayload: payload, // Store the parsed payload object as JSONB }, }); console.log(`Saved/Updated status for ${messageUUID} in DB (ID: ${savedStatus.id})`); // Broadcast via WebSocket (Step 6) broadcast({ type: 'status_update', messageUUID: messageUUID, status: status, errorCode: errorCode || null, timestamp: savedStatus.updatedAt // Use DB timestamp }); console.log(`Broadcasted status update for ${messageUUID} via WebSocket`); } catch (error) { // Use optional chaining in case payload was unparseable or missing fields console.error(`Error processing callback for ${payload?.MessageUUID || 'unknown UUID'}:`, error); // Error is logged, but we already sent 200 OK to Plivo. // Implement internal alerting/monitoring for processing failures. } }); // ... (send-test-sms route, error handler, server start) ...validatePlivoSignaturemiddleware function.req.body(which contains the raw Buffer thanks toexpress.raw) and theplivo.validateV3Signaturemethod. Note: Plivo's V3 signature uses the Auth Token.next(). Otherwise, it sends a403 Forbiddenor400 Bad Request.rawBodyback intoreq.bodyas JSON if the content type was JSON. This makesreq.bodyaccessible as an object in the main route handler. Added robust handling in the main route to manage cases where parsing might fail or the body isn't a buffer./plivo/callbackPOST route.4. Processing Callbacks
The previous step already included the processing logic inside the
/plivo/callbackroute after validation and the immediateres.status(200).send():// Inside app.post('/plivo/callback', validatePlivoSignature, async (req, res) => { ... }) // ... (Payload parsing logic) ... // Acknowledge receipt immediately res.status(200).send('Callback received'); // Asynchronous processing after response try { const messageUUID = payload.MessageUUID; const status = payload.Status; const errorCode = payload.ErrorCode; // e.g., ""401"", ""600"" if (!messageUUID) { /* ... handle missing UUID ... */ } console.log(`Processing status: ${status} for MessageUUID: ${messageUUID}`); // Store in Database (Step 5 - Implemented in Step 3) // Broadcast via WebSocket (Step 6 - Implemented in Step 3) } catch (error) { // ... error handling ... }res.status(200).send(...)happens before database or WebSocket operations. This confirms receipt to Plivo promptly, preventing timeouts and retries on their end.MessageUUID,Status, andErrorCodeare extracted from the validatedpayloadobject.Common Statuses:
queued: Plivo has accepted the message.sent: Plivo has sent the message to the downstream carrier.delivered: The carrier confirmed delivery to the recipient's handset.failed: Plivo couldn't send the message (e.g., invalid number, API error).ErrorCodeprovides details.undelivered: The carrier couldn't deliver the message (e.g., number blocked, phone off, out of coverage).ErrorCodeprovides details.Refer to Plivo Message States for a full list and
ErrorCodedetails.5. Storing Status Updates (Database)
We use Prisma to save the status updates to our PostgreSQL database. This logic was implemented within the
try...catchblock of the/plivo/callbackroute in Step 3:// Inside the try block of app.post('/plivo/callback', ...) // Store in Database const savedStatus = await prisma.messageStatus.upsert({ where: { messageUUID: messageUUID }, // Find existing record by unique UUID update: { // If found, update these fields status: status, errorCode: errorCode || null, rawPayload: payload, // Update the full payload too (as JSON) // updatedAt is handled automatically by Prisma @updatedAt }, create: { // If not found, create a new record messageUUID: messageUUID, status: status, errorCode: errorCode || null, rawPayload: payload, // Store the full payload (as JSON) // receivedAt handled by Prisma @default(now()) // updatedAt handled by Prisma @updatedAt }, }); console.log(`Saved/Updated status for ${messageUUID} in DB (ID: ${savedStatus.id})`);prisma.messageStatus.upsertis used:messageUUIDmatches the incoming one.update), it updates thestatus,errorCode,rawPayload, andupdatedAttimestamp.create), it creates a new record with all the details.queuedthensentthendelivered). TherawPayloadfield stores the entire JSON object received from Plivo for reference.6. Real-time Frontend Updates (WebSockets)
To push updates to a frontend, we use the WebSocket server. This logic was also added within the
try...catchblock of the/plivo/callbackroute in Step 3, after the database save:// Inside the try block of app.post('/plivo/callback', ...) after prisma.upsert // Broadcast via WebSocket broadcast({ // Call the broadcast function defined earlier type: 'status_update', // Add a type for client-side routing messageUUID: messageUUID, status: status, errorCode: errorCode || null, timestamp: savedStatus.updatedAt // Send the DB timestamp }); console.log(`Broadcasted status update for ${messageUUID} via WebSocket`);broadcastfunction (defined in Step 1) sends the status update data as a JSON string to all currently connected WebSocket clients.typefield (status_update) so client-side code can differentiate message types.Client-Side Implementation (Conceptual Example for React/Vue):
// Example React/Vue component using WebSocket import React, { useState, useEffect } from 'react'; function StatusDashboard() { const [statuses, setStatuses] = useState({}); // Store statuses by MessageUUID const [connectionStatus, setConnectionStatus] = useState('Connecting...'); useEffect(() => { // Determine WebSocket protocol based on window location const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; // Use the same host as the web page, default port if needed const wsHost = window.location.host; // e.g., localhost:3000 or your-production-domain.com const wsUrl = `${wsProtocol}//${wsHost}`; // Connects to the root path where WS server is attached let ws; let reconnectInterval; function connect() { ws = new WebSocket(wsUrl); console.log('Attempting WebSocket connection to:', wsUrl); setConnectionStatus('Connecting...'); ws.onopen = () => { console.log('WebSocket connected'); setConnectionStatus('Connected'); if (reconnectInterval) { clearInterval(reconnectInterval); reconnectInterval = null; } }; ws.onmessage = (event) => { try { const data = JSON.parse(event.data); console.log('Received status update:', data); if (data.type === 'status_update' && data.messageUUID) { setStatuses(prevStatuses => ({ ...prevStatuses, [data.messageUUID]: { status: data.status, errorCode: data.errorCode, timestamp: data.timestamp, }, })); } else if (data.message) { console.log(""Server message:"", data.message); // Handle initial connection message etc. } } catch (error) { console.error('Failed to parse WebSocket message:', error); } }; ws.onerror = (error) => { console.error('WebSocket error:', error); setConnectionStatus('Error'); ws.close(); // Ensure connection is closed on error before retry }; ws.onclose = () => { console.log('WebSocket disconnected'); setConnectionStatus('Disconnected - Retrying...'); // Simple exponential backoff retry if (!reconnectInterval) { let delay = 1000; reconnectInterval = setInterval(() => { console.log(`Retrying connection in ${delay / 1000}s...`); connect(); // Attempt to reconnect delay = Math.min(delay * 2, 30000); // Increase delay up to 30s }, delay); } }; } connect(); // Initial connection attempt // Cleanup function to close WebSocket and clear interval on component unmount return () => { if (reconnectInterval) { clearInterval(reconnectInterval); } if (ws) { ws.close(); } }; }, []); // Empty dependency array ensures this runs only once on mount return ( <div> <h2>Live Message Statuses</h2> <p>Connection: {connectionStatus}</p> <ul> {Object.entries(statuses).map(([uuid, data]) => ( <li key={uuid}> <strong>UUID:</strong> {uuid} | <strong>Status:</strong> {data.status} {data.errorCode && ` (Error: ${data.errorCode})`} | <strong>Updated:</strong> {new Date(data.timestamp).toLocaleString()} </li> ))} </ul> {Object.keys(statuses).length === 0 && connectionStatus === 'Connected' && <p>Waiting for status updates...</p>} </div> ); } export default StatusDashboard;ws:orwss:based on the page protocol.status_update.7. Sending a Test Message
To trigger the callback flow, we need an endpoint to send an SMS/MMS message using the Plivo SDK.
Implement Send Endpoint: Add the following route to
server.js.// server.js // ... (imports, setup, other routes) ... // Endpoint to send a test SMS app.post('/send-test-sms', express.json(), async (req, res) => { // Ensure JSON parsing for this route const { to, text } = req.body; if (!to || !text) { return res.status(400).json({ error: 'Missing "to" (recipient number) or "text" in request body' }); } if (!process.env.PLIVO_PHONE_NUMBER) { console.error("Plivo sender phone number (PLIVO_PHONE_NUMBER) is not configured in .env"); return res.status(500).json({ error: 'Server configuration error: Missing sender number' }); } if (!process.env.BASE_CALLBACK_URL) { console.error("Base callback URL (BASE_CALLBACK_URL) is not configured in .env"); return res.status(500).json({ error: 'Server configuration error: Missing callback URL base' }); } const senderNumber = process.env.PLIVO_PHONE_NUMBER; // Construct the full callback URL for this specific message const deliveryStatusCallbackUrl = `${process.env.BASE_CALLBACK_URL}/plivo/callback`; console.log(`Attempting to send SMS from ${senderNumber} to ${to} with callback to ${deliveryStatusCallbackUrl}`); try { const response = await plivoClient.messages.create( senderNumber, // src to, // dst text, // text { url: deliveryStatusCallbackUrl, // Delivery status report URL method: 'POST' // Method for the callback } // For MMS, add 'media_urls': ['https://example.com/image.jpg'] ); console.log('Plivo Send API Response:', response); // The response contains the message_uuid which you can correlate // with the callbacks later. res.status(202).json({ message: 'Message sending initiated via Plivo.', plivoResponse: response }); } catch (error) { console.error('Error sending message via Plivo:', error); res.status(error.statusCode || 500).json({ error: 'Failed to send message via Plivo.', details: error.message || error.error || error // Plivo SDK might have different error structures }); } }); // ... (Global Error Handler, Server Start, Shutdown Logic) ...