code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Article

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:

  1. Receive Plivo Callbacks: Set up a secure endpoint to receive HTTP POST requests from Plivo containing message status updates.
  2. Validate Requests: Ensure incoming requests genuinely originate from Plivo using signature validation.
  3. Process Status Updates: Parse the callback data to understand the message status (e.g., queued, sent, delivered, failed).
  4. Persist Status Data: Store message status information in a database for tracking, analysis, and auditing.
  5. Real-time Notifications (Optional): Implement a WebSocket server to push status updates to connected clients (e.g., a frontend dashboard).
  6. 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.

  1. Create Project Directory: Open your terminal and create a new directory for the project.

    bash
    mkdir plivo-callback-guide
    cd plivo-callback-guide
  2. Initialize Node.js Project:

    bash
    npm init -y

    This creates a package.json file.

  3. Install Dependencies: We need Express for the server, the Plivo SDK, dotenv for environment variables, ws for WebSockets, and Prisma for database interaction.

    bash
    npm install express plivo dotenv ws
    npm install prisma @prisma/client --save-dev
    • express: Web server framework.
    • plivo: Plivo's official Node.js SDK.
    • dotenv: Loads environment variables from a .env file.
    • ws: WebSocket server implementation.
    • prisma: Prisma CLI (dev dependency).
    • @prisma/client: Prisma database client.
  4. 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).

  5. 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.

    dotenv
    # .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.
  6. Create .gitignore: Create a .gitignore file in the root directory to avoid committing sensitive information and unnecessary files.

    text
    # .gitignore
    node_modules
    .env
    dist
    npm-debug.log*
    yarn-debug.log*
    yarn-error.log*
    prisma/dev.db*
  7. 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.
  8. 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.

  9. Create Server File: Create a file named server.js in the root directory. This will contain our Express application logic.

    javascript
    // 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.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.

  1. 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.
  2. 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.
  3. Update .env: Paste the Ngrok HTTPS URL (or your production URL) into your .env file as the BASE_CALLBACK_URL.
    dotenv
    # .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.
  4. 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.

  1. 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 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) ...
    • 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():

javascript
    // 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 ...
    }
  • 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.

Refer to Plivo Message States for a full list and ErrorCode details.


5. Storing Status Updates (Database)

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 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.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 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`);
  • 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 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;
  • 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.

  1. 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 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) ...

Frequently Asked Questions

How to track Plivo SMS delivery status in Node.js?

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'.

What is the Plivo message status callback URL?

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.

Why does Plivo callback validation matter?

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.

When should I acknowledge Plivo's callback request?

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.

Can I push Plivo status updates to a frontend?

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.

How to send a test SMS message with Plivo?

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.

How to set up a Plivo callback endpoint in Express.js?

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.

What are common Plivo SMS message statuses?

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.

How to validate Plivo callback signatures in Node?

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.

How to store Plivo message statuses?

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.

What is the purpose of the messageUUID in Plivo callbacks?

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.

What Node.js libraries are used for Plivo callbacks?

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).

How to handle multiple Plivo callbacks for one message?

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.

What technologies are used in this Plivo callback project?

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.