code examples

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

Real-time SMS Delivery Status: Implementing Plivo Webhooks with Fastify

Complete guide to building Fastify webhook endpoints for Plivo SMS delivery status tracking. Covers signature validation, error handling, deployment, and production best practices for Node.js.

Real-time SMS Delivery Status: Implementing Plivo Webhooks with Fastify

Tracking the delivery status of SMS messages is crucial for applications that rely on timely communication. Knowing whether a message was successfully delivered, failed, or is still queued enables developers to build more robust and reliable systems, trigger follow-up actions, and provide better user feedback.

This guide provides a complete walkthrough for building a Fastify application that receives and processes SMS delivery status updates from Plivo via webhooks. We'll cover everything from initial project setup to deployment considerations, ensuring you have a production-ready solution.

Project Goals:

  • Set up a Node.js project using the Fastify framework.
  • Configure Plivo API credentials and a phone number.
  • Implement a Fastify route to send an SMS message via Plivo, specifying a callback URL.
  • Create a dedicated webhook endpoint in Fastify to receive delivery status updates from Plivo.
  • Log the incoming status updates.
  • Discuss security, error handling, and deployment best practices.

Technology Stack:

  • Node.js: JavaScript runtime environment.
  • Fastify: High-performance, low-overhead web framework for Node.js. Chosen for its speed, extensibility, and developer-friendly features. Note: This guide uses Fastify v4 syntax. Fastify v5 was released in 2024 and v4 will be retired on June 30, 2025. For new projects, consider using Fastify v5 (which requires Node.js v20+) and consult the v5 migration guide.
  • Plivo: Communications Platform as a Service (CPaaS) providing SMS API and webhook capabilities. The official npm package is plivo (formerly @plivo/node-sdk or the legacy plivo-node).
  • dotenv: Module to load environment variables from a .env file.
  • ngrok (for development): Tool to expose local servers to the internet. Alternatives include Cloudflare Tunnel (free for up to 50 users), Pinggy, and LocalXpose.

System Architecture:

The basic flow is as follows:

  1. Your Fastify application makes an API call to Plivo to send an SMS, including a url parameter pointing to your webhook endpoint.
  2. Plivo attempts to send the SMS to the carrier.
  3. As the message status changes (e.g., queued, sent, delivered, failed), Plivo sends an HTTP POST request containing the status details to the specified webhook URL.
  4. Your Fastify application's webhook endpoint receives the POST request, processes the payload (e.g., logs the status), and responds to Plivo.
mermaid
sequenceDiagram
    participant App as Fastify App
    participant Plivo
    participant Carrier
    participant EndUser as End User's Phone

    App->>+Plivo: Send SMS API Request (with webhook URL)
    Plivo-->>-App: API Response (MessageUUID)
    Plivo->>+Carrier: Submit SMS
    Carrier-->>-Plivo: Initial Acceptance (Queued/Sent Status)
    Plivo->>App: POST /webhook (Status: queued/sent)
    App-->>Plivo: HTTP 200 OK
    Carrier->>+EndUser: Deliver SMS
    EndUser-->>-Carrier: Delivery Receipt (DLR)
    Carrier->>Plivo: Delivery Status Update (delivered/failed)
    Plivo->>App: POST /webhook (Status: delivered/failed)
    App-->>Plivo: HTTP 200 OK

(Note: The diagram above uses Mermaid syntax. You may need a Markdown previewer or environment that supports Mermaid to render it correctly.)

Prerequisites:

  • Node.js and npm (or yarn) installed.
  • A Plivo account with API credentials and a Plivo phone number capable of sending SMS.
  • ngrok or a similar tunneling service installed for local development testing.
  • Basic understanding of JavaScript, Node.js, and REST APIs.

Final Outcome:

By the end of this guide, you will have a running Fastify application capable of sending SMS messages through Plivo and receiving real-time delivery status updates at a dedicated webhook endpoint. You'll also understand the essential considerations for making this system robust and secure.

1. Setting up the Project

Let's start by creating our project directory and setting up the basic structure and dependencies.

Step 1: Create Project Directory

Open your terminal and create a new directory for the project, then navigate into it.

bash
mkdir fastify-plivo-webhooks
cd fastify-plivo-webhooks

Step 2: Initialize Node.js Project

Initialize the project using npm. The -y flag accepts the default settings.

bash
npm init -y

Step 3: Install Dependencies

We need Fastify for the web server, the Plivo Node SDK to interact with the Plivo API, and dotenv to manage environment variables.

bash
npm install fastify @plivo/node-sdk dotenv

Step 4: Install Development Dependencies

We'll use nodemon to automatically restart the server during development when files change and pino-pretty for more readable logs.

bash
npm install --save-dev nodemon pino-pretty

Step 5: Configure package.json Scripts

Open your package.json file and add the following scripts to the "scripts" section. This allows us to easily start the server in development (with nodemon and pretty logs) or production mode.

json
{
  "name": "fastify-plivo-webhooks",
  "version": "1.0.0",
  "description": "",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js | pino-pretty",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@plivo/node-sdk": "^4.31.0",
    "dotenv": "^16.0.3",
    "fastify": "^4.15.0"
  },
  "devDependencies": {
    "nodemon": "^2.0.22",
    "pino-pretty": "^10.0.0"
  }
}

Step 6: Create .gitignore File

Create a .gitignore file in the root of your project to prevent sensitive information and unnecessary files from being committed to version control.

text
# .gitignore

# Node dependencies
node_modules/

# Environment variables
.env

# Log files
*.log

# OS generated files
.DS_Store
Thumbs.db

Step 7: Create .env File for Environment Variables

Create a file named .env in the project root. This file will store sensitive credentials and configuration. Never commit this file to Git.

ini
# .env

# Plivo Credentials
PLIVO_AUTH_ID=YOUR_PLIVO_AUTH_ID
PLIVO_AUTH_TOKEN=YOUR_PLIVO_AUTH_TOKEN

# Plivo Phone Number (Must be SMS enabled)
PLIVO_PHONE_NUMBER=YOUR_PLIVO_SENDER_NUMBER

# Recipient Phone Number for testing
PHONE_NUMBER_TO=RECIPIENT_E164_NUMBER

# Base URL for your webhook endpoint (e.g., from ngrok)
# IMPORTANT: Must be HTTPS for Plivo production webhooks
BASE_URL=YOUR_NGROK_OR_PUBLIC_URL

Step 8: Obtain Plivo Credentials and Configure .env

  1. Plivo Auth ID & Token:

    • Log in to your Plivo Console.
    • Navigate to the "API" section in the left sidebar, then select "Keys & Credentials".
    • Copy your "Auth ID" and "Auth Token".
    • Paste these values into the PLIVO_AUTH_ID and PLIVO_AUTH_TOKEN fields in your .env file.
  2. Plivo Phone Number:

    • In the Plivo Console, navigate to "Phone Numbers" > "Your Numbers".
    • Find a number that is SMS enabled. If you don't have one, you'll need to purchase one.
    • Copy the full number (including the country code, e.g., +14155551212).
    • Paste this value into the PLIVO_PHONE_NUMBER field in your .env file.
  3. Recipient Phone Number:

    • Set PHONE_NUMBER_TO to your own mobile number in E.164 format (e.g., +14155552323) for testing.
  4. Base URL:

    • Leave BASE_URL blank for now. We will fill this in when we run ngrok later (Section 4). This URL tells Plivo where to send the status updates. It must be publicly accessible. Plivo requires HTTPS for webhooks in production, and it's highly recommended even for development. ngrok provides HTTPS URLs automatically.

Project Structure:

Your project should now look like this:

fastify-plivo-webhooks/ ├── node_modules/ ├── .env ├── .gitignore ├── package.json ├── package-lock.json └── server.js <-- We will create this next

2. Implementing Core Functionality

Now, let's create the Fastify server and implement the routes for sending SMS and receiving status updates.

Step 1: Create server.js

Create a file named server.js in the project root.

Step 2: Basic Server Setup and Environment Loading

Add the following code to initialize Fastify, load environment variables, and configure the Plivo client.

javascript
// server.js
'use strict';

// Load environment variables from .env file
require('dotenv').config();

// Import Fastify
const fastify = require('fastify')({
  logger: true // Enable Fastify's built-in logger
});

// Import Plivo Node SDK
const plivo = require('@plivo/node-sdk');

// Validate essential Plivo environment variables needed for startup
const requiredStartupEnv = ['PLIVO_AUTH_ID', 'PLIVO_AUTH_TOKEN', 'PLIVO_PHONE_NUMBER', 'PHONE_NUMBER_TO'];
for (const variable of requiredStartupEnv) {
  if (!process.env[variable]) {
    fastify.log.error(`Missing required environment variable for startup: ${variable}`);
    process.exit(1); // Exit if critical configuration is missing
  }
}

// Initialize Plivo client
let plivoClient;
try {
    plivoClient = new plivo.Client(process.env.PLIVO_AUTH_ID, process.env.PLIVO_AUTH_TOKEN);
} catch (error) {
    fastify.log.error({ err: error }, 'Failed to initialize Plivo client. Check credentials.');
    process.exit(1);
}

// Define the port to listen on
const PORT = process.env.PORT || 3000;

// --- Routes will be added below ---

// --- Start the server ---
const start = async () => {
  try {
    // Listen on all available network interfaces
    await fastify.listen({ port: PORT, host: '0.0.0.0' });
    fastify.log.info(`Server listening on port ${PORT}`);
    // BASE_URL check moved to where it's first needed (send-sms route)
  } catch (err) {
    fastify.log.error(err);
    process.exit(1);
  }
};

start();
  • Explanation:
    • We load dotenv first to ensure environment variables are available.
    • Fastify is initialized with logging enabled (logger: true).
    • We import the Plivo SDK.
    • A crucial check ensures essential Plivo credentials and phone numbers are present at startup. The BASE_URL check is moved, as it's only needed when sending the first SMS that requires a callback URL.
    • The Plivo client is instantiated using credentials from the environment variables, wrapped in a try-catch.
    • The start function attempts to listen on the specified port (defaulting to 3000) and logs success or failure. Listening on 0.0.0.0 makes the server accessible from outside the container/machine if needed.

Step 3: Implement the Webhook Endpoint (/plivo/status)

This route will listen for POST requests from Plivo containing message status updates.

Add the following code inside server.js, before the // --- Start the server --- comment:

javascript
// server.js
// ... (previous code) ...

// --- Routes ---

// Route to handle incoming Plivo SMS status webhooks
fastify.post('/plivo/status', async (request, reply) => {
  // --- Plivo Signature Validation (Placeholder - CRITICAL TO IMPLEMENT) ---
  // See Section 7 for details on implementing this validation.
  // It involves checking the X-Plivo-Signature-V3 header against a calculation
  // using your Auth Token, the request URL, nonce, and raw POST body.
  // If validation fails, return 401 or 403 immediately.
  // Example check (pseudo-code):
  // if (!isValidPlivoSignature(request)) {
  //   fastify.log.warn('Invalid Plivo signature received.');
  //   reply.code(403).send('Forbidden: Invalid signature');
  //   return;
  // }
  // fastify.log.info('Plivo signature validated successfully.');
  // --- End Validation Placeholder ---

  const statusData = request.body; // Fastify parses JSON or form-urlencoded

  fastify.log.info({ plivoWebhookData: statusData }, 'Received Plivo status update');

  // --- Basic Payload Validation ---
  if (!statusData || !statusData.MessageUUID || !statusData.Status) {
     fastify.log.warn('Received invalid or incomplete webhook payload');
     // Still return 200 OK to Plivo to prevent retries for malformed requests.
     // Log the issue internally for investigation.
     reply.code(200).send('Payload received, but appears invalid.');
     return;
  }

  const messageUuid = statusData.MessageUUID;
  const status = statusData.Status;
  const timestamp = statusData.Timestamp || new Date().toISOString(); // Use Plivo timestamp if available

  fastify.log.info(`Message UUID: ${messageUuid}, Status: ${status}, Timestamp: ${timestamp}`);

  // --- Processing Logic ---
  // In a real application, you would typically:
  // 1. Validate the webhook signature (See Security section - placeholder above).
  // 2. Look up the message UUID in your database.
  // 3. Update the message status and timestamp (idempotently).
  // 4. Trigger any necessary follow-up actions based on the status.
  // For this guide, we are just logging the information.

  // --- Respond to Plivo ---
  // Crucial: Respond quickly with 200 OK to acknowledge receipt.
  reply.code(200).send('Webhook received successfully');
});

// --- Health Check Route (Good Practice) ---
fastify.get('/health', async (request, reply) => {
  reply.code(200).send({ status: 'ok' });
});

// --- Route to Trigger Sending an SMS (for testing) ---
fastify.get('/send-sms', async (request, reply) => {
  // Check for BASE_URL here, as it's needed to construct the webhook URL
  if (!process.env.BASE_URL) {
      fastify.log.error('BASE_URL environment variable is not set. Cannot specify webhook URL.');
      reply.code(500).send({ success: false, message: 'Server configuration error: BASE_URL is missing.' });
      return;
  }

  const webhookUrl = `${process.env.BASE_URL}/plivo/status`;
  fastify.log.info(`Attempting to send SMS to ${process.env.PHONE_NUMBER_TO} with webhook URL: ${webhookUrl}`);

  try {
    const response = await plivoClient.messages.create(
      process.env.PLIVO_PHONE_NUMBER, // Source number
      process.env.PHONE_NUMBER_TO,   // Destination number
      `Hello from Fastify! Testing Plivo webhooks. [${Date.now()}]`, // Text message
      {
        url: webhookUrl, // Your webhook URL for delivery status
        method: 'POST'   // Method Plivo should use to call your webhook
      }
    );
    fastify.log.info({ plivoResponse: response }, 'SMS send request successful');
    reply.send({ success: true, message: 'SMS send request initiated.', plivoResponse: response });
  } catch (error) {
    fastify.log.error({ err: error }, 'Failed to send SMS via Plivo');
    reply.code(500).send({ success: false, message: 'Failed to send SMS.', error: error.message });
  }
});

// ... (start function remains the same) ...
  • Explanation:
    • /plivo/status (POST):
      • Includes a placeholder comment block emphasizing where Signature Validation (Section 7) must be implemented.
      • Logs the incoming request.body.
      • Performs basic validation for required fields (MessageUUID, Status).
      • Logs key information.
      • Sends a 200 OK response promptly. Heavy processing should occur after this response.
    • /health (GET): Standard health check endpoint.
    • /send-sms (GET):
      • Now includes the check for BASE_URL before attempting to send, returning an error if it's missing.
      • Constructs the webhookUrl.
      • Calls plivoClient.messages.create with the url and method for the callback.
      • Includes error handling for the Plivo API call.

3. Building the API Layer (Webhook Focus)

For this guide, the primary "API" is the webhook endpoint /plivo/status designed to be consumed by Plivo.

  • Authentication/Authorization: The essential security mechanism is Signature Validation (Section 7). This verifies requests originate from Plivo using the X-Plivo-Signature-V3 header and your Auth Token.
  • Request Validation: Basic field presence validation is included. Consider Fastify's built-in schema validation or libraries like zod for more complex checks.
  • API Endpoint Documentation (Webhook):
    • Endpoint: POST /plivo/status
    • Description: Receives SMS delivery status updates from Plivo.
    • Request Body: application/x-www-form-urlencoded or application/json. Example Payload (Form-urlencoded): From=14155551212&To=14155552323&Status=delivered&MessageUUID=abc123xyz-uuid-456&Timestamp=2025-04-20T10:30:00Z&ErrorCode=0&...
    • Headers (Important): X-Plivo-Signature-V3, X-Plivo-Signature-V3-Nonce.
    • Response:
      • 200 OK: Success. Body like "Webhook received successfully".
      • 401/403 Forbidden: If signature validation fails.
      • Other 4xx/5xx: Plivo may retry. Avoid unless necessary.
  • Testing with cURL (Simulating Plivo):
    bash
    # Replace YOUR_NGROK_URL and other values
    curl -X POST YOUR_NGROK_URL/plivo/status \
    -H "Content-Type: application/x-www-form-urlencoded" \
    # Add valid headers if testing signature validation:
    # -H "X-Plivo-Signature-V3: CALCULATED_SIGNATURE" \
    # -H "X-Plivo-Signature-V3-Nonce: NONCE_USED_IN_CALCULATION" \
    -d "From=14155551212&To=14155552323&Status=sent&MessageUUID=test-uuid-123&Timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)"

4. Integrating with Plivo (Setup Recap & Ngrok)

We've configured the Plivo client. Now, make your local server accessible using ngrok.

Step 1: Start Ngrok

Open a new terminal window. Run ngrok to expose your Fastify port (default 3000).

bash
ngrok http 3000

Step 2: Get Ngrok URL

Copy the HTTPS forwarding URL provided by ngrok (e.g., https://xxxxxxxxxxxx.ngrok.io).

Step 3: Update .env File

Paste the copied HTTPS ngrok URL into the BASE_URL variable in your .env file.

ini
# .env
# ... other variables
BASE_URL=https://xxxxxxxxxxxx.ngrok.io

Step 4: Restart Fastify Server

Stop your Fastify server (Ctrl+C) and restart it (npm run dev) to load the updated .env file.

Your server is now running, and the BASE_URL is set, allowing the /send-sms route to work correctly and Plivo to reach your /plivo/status endpoint via the ngrok URL.

5. Error Handling, Logging, and Retry Mechanisms

  • Logging: Use Fastify's logger (fastify.log.info, .warn, .error). Log context, especially error objects: fastify.log.error({ err: error }, '...'). Configure production logging appropriately.
  • Error Handling Strategy:
    • Webhook (/plivo/status):
      • Use try...catch for internal logic (DB updates, etc.).
      • Critical: Return 200 OK to Plivo even if internal processing fails (log the internal error). Handle internal failures separately (alerts, queues).
      • Return 401/403 Forbidden only if signature validation fails.
    • SMS Sending (/send-sms):
      • Wrap plivoClient.messages.create in try...catch.
      • Log Plivo SDK errors.
      • Return appropriate HTTP errors (500, 502) to the client triggering the send.
  • Retry Mechanisms:
    • Plivo Webhooks: Plivo retries automatically on non-2xx responses or timeouts. Respond 200 OK quickly unless the request is invalid/unauthorized.
    • Application Retries (Sending): Consider implementing retries (e.g., with a job queue) if the initial call to plivoClient.messages.create fails due to transient issues.

6. Database Schema and Data Layer (Conceptual)

A production system needs to store status updates.

Conceptual Schema (PostgreSQL):

sql
CREATE TABLE sms_messages (
    id SERIAL PRIMARY KEY,
    message_uuid VARCHAR(255) UNIQUE NOT NULL, -- Plivo's MessageUUID
    sender_number VARCHAR(20) NOT NULL,
    recipient_number VARCHAR(20) NOT NULL,
    message_body TEXT,
    initial_status VARCHAR(50), -- Status from initial API response
    current_status VARCHAR(50), -- Latest status from webhook
    status_timestamp TIMESTAMPTZ, -- Timestamp of latest status
    error_code VARCHAR(10),      -- Plivo error code if failed/undelivered
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_sms_messages_message_uuid ON sms_messages(message_uuid);

Data Layer (/plivo/status):

  1. After sending 200 OK (or asynchronously):
  2. Use a DB client/ORM.
  3. Extract MessageUUID, Status, Timestamp, ErrorCode.
  4. Execute an idempotent UPDATE statement based on message_uuid, potentially checking status_timestamp to avoid overwriting newer updates with older ones.

Data Layer (/send-sms):

  1. Before calling plivoClient.messages.create: INSERT a new record with status 'pending'.
  2. After successful call: UPDATE the record with the returned MessageUUID and initial_status (e.g., 'queued').

7. Adding Security Features

Securing your webhook endpoint is critical.

  1. Webhook Signature Validation (CRITICAL):

    • Plivo sends X-Plivo-Signature-V3 (and X-Plivo-Signature-Ma-V3 for additional verification) along with X-Plivo-Signature-V3-Nonce headers with every webhook request.
    • Validation Process (per Plivo official documentation):
      1. Extract Headers: Get X-Plivo-Signature-V3 and X-Plivo-Signature-V3-Nonce from the request headers.
      2. Construct Full URL: Determine the complete URL that Plivo used for the request, including scheme, host, port (if non-standard), path, and query string (e.g., https://yourdomain.com/plivo/status). Important: If behind a proxy or load balancer, you may need to inspect headers like X-Forwarded-Proto, X-Forwarded-Host, or Host to reconstruct the correct URL.
      3. Assemble Request String: Plivo concatenates the full URL with the nonce: url + nonce. Note that for V3 signature validation, Plivo does NOT include POST parameters in the signature calculation (unlike some earlier versions).
      4. Calculate Signature: Use HMAC-SHA256 to hash the assembled string (url + nonce) with your PLIVO_AUTH_TOKEN as the secret key, then Base64-encode the result.
      5. Compare Signatures: Use a timing-safe comparison to match your calculated signature against the X-Plivo-Signature-V3 header value. If they match, the request is valid.
    • SDK Helper Functions: The Plivo Node SDK includes built-in validation functions. Check the latest SDK documentation for methods like validateSignature() or similar helpers that can simplify this process.
    • Implementation Note: For Fastify, you may need to access the raw request URL. Be mindful of how Fastify handles proxies and ensure you're using the correct URL as seen by Plivo.
    javascript
    // server.js - /plivo/status route - Enhanced Validation Example
    fastify.post('/plivo/status', async (request, reply) => {
      // --- Plivo Signature Validation ---
      const signature = request.headers['x-plivo-signature-v3'];
      const nonce = request.headers['x-plivo-signature-v3-nonce'];
    
      if (!signature || !nonce) {
        fastify.log.warn('Missing Plivo signature headers.');
        reply.code(401).send('Unauthorized: Missing signature');
        return;
      }
    
      // Reconstruct the full URL as Plivo sees it
      const protocol = request.headers['x-forwarded-proto'] || 'https';
      const host = request.headers['x-forwarded-host'] || request.headers.host;
      const fullUrl = `${protocol}://${host}${request.url}`;
    
      // Option 1: Use Plivo SDK validation helper (recommended if available)
      // Check SDK documentation for the exact method name
      // const isValid = plivoClient.validateSignature(fullUrl, nonce, signature);
    
      // Option 2: Manual validation using Node.js crypto
      const crypto = require('crypto');
      const expectedSignature = crypto
        .createHmac('sha256', process.env.PLIVO_AUTH_TOKEN)
        .update(fullUrl + nonce)
        .digest('base64');
    
      // Timing-safe comparison
      const isValid = crypto.timingSafeEqual(
        Buffer.from(signature),
        Buffer.from(expectedSignature)
      );
    
      if (!isValid) {
        fastify.log.warn({ fullUrl, nonce }, 'Invalid Plivo signature.');
        reply.code(403).send('Forbidden: Invalid signature');
        return;
      }
    
      fastify.log.info('Plivo signature validated successfully.');
      // --- End Validation ---
    
      const statusData = request.body;
      // ... rest of the handler
    });
  2. HTTPS: Enforce HTTPS for all webhook endpoints. Plivo requires HTTPS for production webhooks. ngrok provides HTTPS automatically for development. In production, use a reverse proxy (Nginx, Caddy) or load balancer with TLS termination.

  3. Input Validation: Sanitize and validate all incoming data before database operations, even after signature validation.

  4. Rate Limiting: Consider using @fastify/rate-limit plugin as a defense-in-depth measure to protect against abuse.

  5. Secrets Management: Never commit credentials to version control. In production, use secure environment variable management provided by your platform (AWS Secrets Manager, Azure Key Vault, GCP Secret Manager, etc.) or tools like HashiCorp Vault.

8. Handling Special Cases

  • Message Statuses: Handle queued, sent, failed, delivered, undelivered, rejected.
  • Error Codes: Log the ErrorCode field for failed/undelivered statuses. Refer to Plivo docs for code meanings.
  • Timestamps: Use TIMESTAMPTZ for storage.
  • Duplicate Webhooks: Design database updates to be idempotent (e.g., UPDATE ... WHERE message_uuid = ? AND (current_status != ? OR status_timestamp < ?)). Check timestamps to avoid overwriting newer statuses with delayed older ones.

9. Implementing Performance Optimizations

  • Fastify Speed: Leverage Fastify's performance.
  • Webhook Response Time: Respond 200 OK immediately.
  • Asynchronous Processing: Offload slow tasks (DB writes, external calls) triggered by webhooks to background job queues (BullMQ, Redis Streams, etc.).
  • Database Indexing: Index message_uuid.
  • Connection Pooling: Use database connection pools.
  • Logging Level: Use info or warn in production.

10. Adding Monitoring, Observability, and Analytics

  • Health Checks: Use /health for basic monitoring.
  • Performance Metrics (APM): Monitor latency, error rates, resource usage (Datadog, New Relic, Prometheus/Grafana).
  • Error Tracking: Integrate Sentry, Bugsnag, etc.
  • Logging Aggregation: Centralize logs (ELK, Splunk, Datadog Logs).
  • Key Metrics Dashboard: Track webhook rate, status distribution, latency, error rates.
  • Alerting: Set alerts for high error/latency rates, high failure codes, health check failures.

11. Troubleshooting and Caveats

  • Webhook Not Received: Check ngrok status/URL, BASE_URL in .env, server logs, Plivo console logs (Monitor -> Logs), firewalls, URL path in API call.
  • Invalid Signature Errors: Verify PLIVO_AUTH_TOKEN, URL construction (ensure you're using the exact URL Plivo sees, including protocol and host), nonce handling, and that you're calculating the signature correctly (URL + nonce only, no POST params for V3).
  • Plivo Error Codes: Consult Plivo documentation for specific error code meanings.
  • Ngrok Limitations and Alternatives:
    • ngrok: Free tier has rate limits and provides temporary URLs that change on restart.
    • Cloudflare Tunnel (Recommended for Production): Free for up to 50 users, provides persistent URLs, automatic HTTPS, and integrates with Cloudflare's DNS and security features. Install cloudflared daemon and configure a tunnel to your local/staging server.
    • Other Alternatives: Pinggy (affordable with collaboration features), LocalXpose (feature-complete with TCP/UDP support), localtunnel (free npm package but unmaintained since 2022 – use with caution), Tailscale (full mesh network solution).
    • For Production: Deploy to a cloud platform (Heroku, Vercel, Railway, AWS, GCP, Azure) with a static domain rather than relying on tunneling services.
  • State Management: Use a database for persistence across server restarts.
  • Scalability: Plan for database connection pooling and consider asynchronous processing with job queues (BullMQ, Redis Streams) for high-volume webhook handling.

12. Deployment and CI/CD

  • Environment Configuration: Use secure environment variable management provided by your deployment platform. Never commit secrets to version control.
  • Persistent URL: Update BASE_URL to your production domain with proper DNS and HTTPS configured.
  • Process Management: Use pm2, systemd, Docker, or container orchestration (Kubernetes, AWS ECS, GCP Cloud Run) for process lifecycle management and automatic restarts.
  • Node.js Version: Fastify v5 requires Node.js v20 or later. Ensure your production environment uses a compatible Node.js version. Use tools like nvm or specify the version in your deployment configuration.
  • Containerization (Docker):
    • Create a Dockerfile:
      dockerfile
      # Dockerfile
      FROM node:20-alpine
      
      WORKDIR /usr/src/app
      
      # Copy package files and install only production dependencies
      COPY package*.json ./
      RUN npm ci --only=production --ignore-scripts
      
      # Copy application code
      COPY . .
      
      # Expose the port the app runs on
      EXPOSE 3000
      
      # Define default environment variables (override at runtime)
      ENV NODE_ENV=production
      ENV PORT=3000
      # Critical variables like Plivo credentials and BASE_URL
      # MUST be injected securely at runtime via platform secrets.
      
      CMD [ "node", "server.js" ]
    • Build and run (use platform secrets in production):
      bash
      docker build -t fastify-plivo-webhook .
      # Use your platform's secret management, not environment flags
      docker run -p 3000:3000 \
        -e PLIVO_AUTH_ID='YOUR_ID' \
        -e PLIVO_AUTH_TOKEN='YOUR_TOKEN' \
        -e PLIVO_PHONE_NUMBER='YOUR_NUMBER' \
        -e BASE_URL='https://your-production-domain.com' \
        -e PHONE_NUMBER_TO='DEFAULT_RECIPIENT' \
        fastify-plivo-webhook
  • CI/CD Pipeline: Automate testing (linting, unit tests, integration tests), Docker image building, security scanning, and deployment. Popular platforms include GitHub Actions, GitLab CI, CircleCI, and Jenkins.
  • Health Checks: Configure your deployment platform to monitor the /health endpoint for automatic restart on failures.
  • Rollback Strategy: Maintain the ability to quickly revert to a previous working version using container image tags, deployment history, or blue-green deployment strategies.

13. Verification and Testing

  • Manual Verification Checklist:

    • Environment variables set correctly.
    • Server starts (npm run dev).
    • ngrok running, BASE_URL updated and server restarted.
    • Access /health -> shows {"status":"ok"}.
    • Access /send-sms.
    • Check logs: See SMS sending attempt/success logs.
    • Check phone: Receive SMS.
    • Check logs: See "Received Plivo status update" logs for various statuses.
    • Check ngrok web UI (http://127.0.0.1:4040): See POSTs to /plivo/status with 200 OK.
    • (If signature validation implemented) Test with invalid signature -> see 4xx error.
  • Automated Testing (Integration): Use Fastify's testing capabilities.

    • Install test runner: npm install --save-dev tap
    • Refactor server.js for Testability: Modify server.js to export a function that builds and returns the Fastify app instance, instead of starting it directly.
    javascript
    // --- Example server.js refactor pattern ---
    'use strict';
    require('dotenv').config();
    const Fastify = require('fastify');
    const plivo = require('@plivo/node-sdk');
    
    function buildApp(opts = {}) {
      const fastify = Fastify(opts);
    
      // Initialize Plivo Client (handle errors as before)
      let plivoClient;
      try {
          plivoClient = new plivo.Client(process.env.PLIVO_AUTH_ID, process.env.PLIVO_AUTH_TOKEN);
      } catch (error) {
          fastify.log.error({ err: error }, 'Failed to initialize Plivo client. Check credentials.');
          // In a real testable app, you might inject the client or mock it
          // For simplicity here, we might still exit or throw if critical
          process.exit(1); // Or throw new Error(...)
      }
    
      // Register plugins
      // Define routes (/plivo/status, /health, /send-sms) inside this function
    
      // Example route definition within buildApp:
      fastify.post('/plivo/status', async (request, reply) => {
          // --- Placeholder for Signature Validation ---
          const statusData = request.body;
          if (!statusData || !statusData.MessageUUID || !statusData.Status) {
             fastify.log.warn('Received invalid or incomplete webhook payload');
             reply.code(200).send('Payload received, but appears invalid.');
             return;
          }
          fastify.log.info(`Webhook received for UUID: ${statusData.MessageUUID}, Status: ${statusData.Status}`);
          reply.code(200).send('Webhook received successfully');
      });
    
      fastify.get('/health', async (request, reply) => {
          reply.code(200).send({ status: 'ok' });
       });
    
      fastify.get('/send-sms', async (request, reply) => {
          // Simplified for example - add BASE_URL check and full logic
          if (!process.env.BASE_URL) {
              reply.code(500).send({ success: false, message: 'BASE_URL missing' });
              return;
          }
          // Mock or perform actual Plivo call if testing integration
          reply.send({ success: true, message: 'SMS send request initiated (mocked/real).' });
      });
      // etc.
    
      return fastify;
    }
    
    // Export the build function
    module.exports = buildApp;
    
    // Optional: Start server only if run directly
    if (require.main === module) {
      const app = buildApp({ logger: true });
      const PORT = process.env.PORT || 3000;
      app.listen({ port: PORT, host: '0.0.0.0' }, (err) => {
        if (err) {
          app.log.error(err);
          process.exit(1);
        }
        // Log listening port, BASE_URL info etc.
      });
    }
    • Create a test file (test/routes.test.js):
    javascript
    // test/routes.test.js
    'use strict'
    
    const { test } = require('tap')
    // Import the build function from the refactored server.js
    const build = require('../server')
    
    test('Plivo status webhook endpoint', async (t) => {
      // Build the app instance for testing
      const app = build({ logger: false }) // Disable logger for cleaner test output
      // Ensure the server is closed after the test finishes
      t.teardown(() => app.close())
    
      const validPayload = {
        From: '14155551212',
        To: '14155552323',
        Status: 'delivered',
        MessageUUID: 'valid-test-uuid-789',
        Timestamp: new Date().toISOString()
      }
    
      // Inject a request simulating a valid Plivo webhook call
      const response = await app.inject({
        method: 'POST',
        url: '/plivo/status',
        payload: validPayload
        // headers: { /* Add valid signature headers if testing validation */ }
      })
    
      t.equal(response.statusCode, 200, 'should return 200 OK for valid payload')
      t.match(response.payload, /Webhook received successfully/, 'should return correct success message')
    
      // Test with an invalid payload structure
      const invalidPayload = { MessageUUID: 'incomplete-uuid' }
      const invalidResponse = await app.inject({
        method: 'POST',
        url: '/plivo/status',
        payload: invalidPayload
      })
    
      t.equal(invalidResponse.statusCode, 200, 'should return 200 OK even for invalid payload (as per logic)')
      t.match(invalidResponse.payload, /Payload received, but appears invalid/, 'should return invalid payload message')
    
      // Add more tests: health check, send-sms (might need mocking Plivo), signature validation etc.
      const healthResponse = await app.inject({ method: 'GET', url: '/health' });
      t.equal(healthResponse.statusCode, 200, 'health check should return 200');
      t.same(JSON.parse(healthResponse.payload), { status: 'ok' }, 'health check should return status ok');
    
    })
    
    // Add tests for other routes like /send-sms if needed

Frequently Asked Questions

How to track SMS delivery status with Plivo?

Implement Plivo webhooks with a Fastify server to receive real-time delivery updates. By specifying a webhook URL in your Plivo SMS API requests, the system automatically sends status updates to the specified URL as the message progresses through various stages, from queued to delivered or failed.

What is the Plivo Node SDK used for?

The Plivo Node SDK (`@plivo/node-sdk`) simplifies interaction with the Plivo API within your Node.js application. It handles authentication, API requests, and responses. The project goals of this article are to install dependencies needed for plivo, like: fastify, node.js, etc.

Why use Fastify for Plivo webhooks?

Fastify is a high-performance web framework for Node.js, offering speed and a developer-friendly experience. Its efficiency makes it well-suited for handling real-time updates from Plivo with minimal overhead.

When should I validate the Plivo webhook signature?

Signature validation is paramount for security and should be performed at the very beginning of your `/plivo/status` route handler. This verification confirms that incoming requests genuinely originate from Plivo and haven't been tampered with.

How to set up Plivo webhooks with Fastify?

Create a dedicated route (e.g., `/plivo/status`) in your Fastify application. This endpoint will receive `POST` requests from Plivo with message status updates like `queued`, `sent`, `delivered`, or `failed`.

What is the role of ngrok in Plivo webhook development?

Ngrok creates a secure tunnel to your local development server, making it publicly accessible. This is essential because Plivo webhooks require an HTTPS URL, even during development. ngrok fulfills this requirement, enabling status updates from plivo.

How to handle Plivo webhook errors in Fastify?

Always respond with a `200 OK` to Plivo, even if your internal processing encounters errors. Log those errors for later investigation, but the immediate `200 OK` prevents Plivo from continuously retrying the webhook.

What data does a Plivo SMS status webhook contain?

The webhook payload includes essential information such as `MessageUUID`, `Status` (e.g., 'sent', 'delivered', 'failed'), `Timestamp`, `From`, `To`, and `ErrorCode` (in case of failures). This data helps in updating internal systems and triggering appropriate actions.

Can I test Plivo webhooks locally?

Yes, use a tool like ngrok to create a public HTTPS URL for your local server. Then, configure your Plivo SMS API requests to use this ngrok URL as the webhook URL, enabling Plivo to reach your local endpoint.

How to secure Plivo webhooks?

Implement robust signature validation using the `X-Plivo-Signature-V3` and `X-Plivo-Signature-V3-Nonce` headers. This ensures only legitimate requests from Plivo are processed. Always use HTTPS and validate all incoming data.

What are common troubleshooting steps for Plivo webhooks?

Verify ngrok's status, ensure the `BASE_URL` in your `.env` file matches the ngrok URL, check server and Plivo console logs, examine firewalls, and confirm the URL path used in the API call to Plivo.

Why does my Plivo webhook return a 403 error?

A 403 Forbidden error usually indicates a signature validation failure. Double-check your Plivo Auth Token, URL construction (especially behind proxies), proper handling of the nonce and raw request body, and ensure a timing-safe string comparison is used.

How to handle duplicate Plivo webhooks?

Design database updates to be idempotent. Use unique constraints based on MessageUUID and check timestamps to prevent older status updates from overwriting newer ones. This ensures data consistency even if Plivo sends duplicate webhooks.

What is the recommended database schema for storing Plivo SMS statuses?

Use a table with columns for `message_uuid` (unique), `sender_number`, `recipient_number`, `message_body`, `initial_status`, `current_status`, `status_timestamp`, `error_code`, and standard timestamps (`created_at`, `updated_at`). Index `message_uuid` for efficient lookups.