code examples
code examples
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-sdkor the legacyplivo-node). - dotenv: Module to load environment variables from a
.envfile. - 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:
- Your Fastify application makes an API call to Plivo to send an SMS, including a
urlparameter pointing to your webhook endpoint. - Plivo attempts to send the SMS to the carrier.
- As the message status changes (e.g.,
queued,sent,delivered,failed), Plivo sends an HTTPPOSTrequest containing the status details to the specified webhook URL. - Your Fastify application's webhook endpoint receives the
POSTrequest, processes the payload (e.g., logs the status), and responds to Plivo.
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.
ngrokor 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.
mkdir fastify-plivo-webhooks
cd fastify-plivo-webhooksStep 2: Initialize Node.js Project
Initialize the project using npm. The -y flag accepts the default settings.
npm init -yStep 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.
npm install fastify @plivo/node-sdk dotenvStep 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.
npm install --save-dev nodemon pino-prettyStep 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.
{
"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.
# .gitignore
# Node dependencies
node_modules/
# Environment variables
.env
# Log files
*.log
# OS generated files
.DS_Store
Thumbs.dbStep 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.
# .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_URLStep 8: Obtain Plivo Credentials and Configure .env
-
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_IDandPLIVO_AUTH_TOKENfields in your.envfile.
-
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_NUMBERfield in your.envfile.
-
Recipient Phone Number:
- Set
PHONE_NUMBER_TOto your own mobile number in E.164 format (e.g.,+14155552323) for testing.
- Set
-
Base URL:
- Leave
BASE_URLblank for now. We will fill this in when we runngroklater (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.ngrokprovides HTTPS URLs automatically.
- Leave
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.
// 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
dotenvfirst 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_URLcheck 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
startfunction attempts to listen on the specified port (defaulting to 3000) and logs success or failure. Listening on0.0.0.0makes the server accessible from outside the container/machine if needed.
- We load
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:
// 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 OKresponse promptly. Heavy processing should occur after this response.
/health(GET): Standard health check endpoint./send-sms(GET):- Now includes the check for
BASE_URLbefore attempting to send, returning an error if it's missing. - Constructs the
webhookUrl. - Calls
plivoClient.messages.createwith theurlandmethodfor the callback. - Includes error handling for the Plivo API call.
- Now includes the check for
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-V3header and your Auth Token. - Request Validation: Basic field presence validation is included. Consider Fastify's built-in schema validation or libraries like
zodfor 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-urlencodedorapplication/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.
- Endpoint:
- 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).
ngrok http 3000Step 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.
# .env
# ... other variables
BASE_URL=https://xxxxxxxxxxxx.ngrok.ioStep 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...catchfor internal logic (DB updates, etc.). - Critical: Return
200 OKto Plivo even if internal processing fails (log the internal error). Handle internal failures separately (alerts, queues). - Return
401/403 Forbiddenonly if signature validation fails.
- Use
- SMS Sending (
/send-sms):- Wrap
plivoClient.messages.createintry...catch. - Log Plivo SDK errors.
- Return appropriate HTTP errors (
500,502) to the client triggering the send.
- Wrap
- Webhook (
- Retry Mechanisms:
- Plivo Webhooks: Plivo retries automatically on non-
2xxresponses or timeouts. Respond200 OKquickly 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.createfails due to transient issues.
- Plivo Webhooks: Plivo retries automatically on non-
6. Database Schema and Data Layer (Conceptual)
A production system needs to store status updates.
Conceptual Schema (PostgreSQL):
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):
- After sending
200 OK(or asynchronously): - Use a DB client/ORM.
- Extract
MessageUUID,Status,Timestamp,ErrorCode. - Execute an idempotent
UPDATEstatement based onmessage_uuid, potentially checkingstatus_timestampto avoid overwriting newer updates with older ones.
Data Layer (/send-sms):
- Before calling
plivoClient.messages.create:INSERTa new record with status'pending'. - After successful call:
UPDATEthe record with the returnedMessageUUIDandinitial_status(e.g.,'queued').
7. Adding Security Features
Securing your webhook endpoint is critical.
-
Webhook Signature Validation (CRITICAL):
- Plivo sends
X-Plivo-Signature-V3(andX-Plivo-Signature-Ma-V3for additional verification) along withX-Plivo-Signature-V3-Nonceheaders with every webhook request. - Validation Process (per Plivo official documentation):
- Extract Headers: Get
X-Plivo-Signature-V3andX-Plivo-Signature-V3-Noncefrom the request headers. - 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 likeX-Forwarded-Proto,X-Forwarded-Host, orHostto reconstruct the correct URL. - 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). - Calculate Signature: Use HMAC-SHA256 to hash the assembled string (
url + nonce) with yourPLIVO_AUTH_TOKENas the secret key, then Base64-encode the result. - Compare Signatures: Use a timing-safe comparison to match your calculated signature against the
X-Plivo-Signature-V3header value. If they match, the request is valid.
- Extract Headers: Get
- 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 }); - Plivo sends
-
HTTPS: Enforce HTTPS for all webhook endpoints. Plivo requires HTTPS for production webhooks.
ngrokprovides HTTPS automatically for development. In production, use a reverse proxy (Nginx, Caddy) or load balancer with TLS termination. -
Input Validation: Sanitize and validate all incoming data before database operations, even after signature validation.
-
Rate Limiting: Consider using
@fastify/rate-limitplugin as a defense-in-depth measure to protect against abuse. -
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
ErrorCodefield forfailed/undeliveredstatuses. Refer to Plivo docs for code meanings. - Timestamps: Use
TIMESTAMPTZfor 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 OKimmediately. - 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
infoorwarnin production.
10. Adding Monitoring, Observability, and Analytics
- Health Checks: Use
/healthfor 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
ngrokstatus/URL,BASE_URLin.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
cloudflareddaemon 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_URLto 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
nvmor 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
- Create a
- 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
/healthendpoint 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). -
ngrokrunning,BASE_URLupdated 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
ngrokweb UI (http://127.0.0.1:4040): SeePOSTs to/plivo/statuswith200 OK. - (If signature validation implemented) Test with invalid signature -> see
4xxerror.
-
Automated Testing (Integration): Use Fastify's testing capabilities.
- Install test runner:
npm install --save-dev tap - Refactor
server.jsfor Testability: Modifyserver.jsto 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 - Install test runner:
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.