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.
- Plivo: Communications Platform as a Service (CPaaS) providing SMS API and webhook capabilities. (
@plivo/node-sdk
) - dotenv: Module to load environment variables from a
.env
file. - ngrok (for development): Tool to expose local servers to the internet.
System Architecture:
The basic flow is as follows:
- Your Fastify application makes an API call to Plivo to send an SMS, including a
url
parameter 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 HTTPPOST
request containing the status details to the specified webhook URL. - Your Fastify application's webhook endpoint receives the
POST
request, 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.
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.
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.
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.
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.
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.
{
""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.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.
# .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
-
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
andPLIVO_AUTH_TOKEN
fields in your.env
file.
-
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.
-
Recipient Phone Number:
- Set
PHONE_NUMBER_TO
to your own mobile number in E.164 format (e.g.,+14155552323
) for testing.
- Set
-
Base URL:
- Leave
BASE_URL
blank for now. We will fill this in when we runngrok
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.
- 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
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 on0.0.0.0
makes 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 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 theurl
andmethod
for 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-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
orapplication/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):
# 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 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.
# .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
only if signature validation fails.
- Use
- SMS Sending (
/send-sms
):- Wrap
plivoClient.messages.create
intry...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-
2xx
responses or timeouts. Respond200 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.
- 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
UPDATE
statement based onmessage_uuid
, potentially checkingstatus_timestamp
to avoid overwriting newer updates with older ones.
Data Layer (/send-sms
):
- Before calling
plivoClient.messages.create
:INSERT
a new record with status'pending'
. - After successful call:
UPDATE
the record with the returnedMessageUUID
andinitial_status
(e.g.,'queued'
).
7. Adding Security Features
Securing your webhook endpoint is critical.
-
Webhook Signature Validation (CRITICAL):
- Plivo sends
X-Plivo-Signature-V3
,X-Plivo-Signature-V3-Nonce
. - Process:
- Get the exact full URL Plivo used for the request (e.g.,
https://yourdomain.com/plivo/status
). Note: If behind a proxy, this might require checking headers likeX-Forwarded-Proto
andHost
, not justrequest.url
. Consult Plivo's signature documentation for specifics on URL construction. - Get the nonce from
X-Plivo-Signature-V3-Nonce
. - Get the raw, unparsed POST body. This requires specific Fastify configuration. You might need to use
addContentTypeParser
to handleapplication/x-www-form-urlencoded
and store the raw buffer, or use apreParsing
hook. Consult Fastify documentation for accessing the raw request body. - Concatenate:
url + nonce
. - Calculate HMAC-SHA256 hash of
(url + nonce)
using yourPLIVO_AUTH_TOKEN
as the key. Base64 encode the result. - Compare the calculated signature (step 5) with the value in the
X-Plivo-Signature-V3
header. Use a timing-safe comparison function. - If they match, proceed. If not, reject with
401
or403
.
- Get the exact full URL Plivo used for the request (e.g.,
- Implementation: Check if
@plivo/node-sdk
provides a validation utility function. If not, use Node.js's built-incrypto
module. - Add to
/plivo/status
: Implement this validation at the very beginning of the route handler.
// server.js - /plivo/status route - Improved Validation Placeholder fastify.post('/plivo/status', async (request, reply) => { // --- Plivo Signature Validation --- // CRITICAL: Implement robust signature validation here. // 1. Get headers: signature = request.headers['x-plivo-signature-v3'], nonce = request.headers['x-plivo-signature-v3-nonce'] // 2. Get rawBody: Requires Fastify config (e.g., preParsing hook or custom content type parser). Store it on `request.rawBody`. // 3. Get fullUrl: Determine the exact URL Plivo used (consider proxies, X-Forwarded-* headers). // 4. Check SDK: Does `@plivo/node-sdk` have a `validateV3Signature(fullUrl, nonce, signature, authToken, rawBody)` function? // 5. If no SDK function, use Node 'crypto': // const crypto = require('crypto'); // const expectedSignature = crypto.createHmac('sha256', process.env.PLIVO_AUTH_TOKEN) // .update(fullUrl + nonce) // Use rawBody directly if Plivo spec requires it for V3 // .digest('base64'); // 6. Compare: Use a timing-safe comparison between `signature` and `expectedSignature`. // 7. If comparison fails: // fastify.log.warn('Invalid Plivo signature.'); // reply.code(403).send('Forbidden: Invalid signature'); // return; // // fastify.log.info('Plivo signature validated.'); // --- End Validation --- const statusData = request.body; // ... rest of the handler });
(Note: Accessing
request.rawBody
requires explicit setup in Fastify. Consult Fastify's documentation on body parsing and accessing the raw body.) - Plivo sends
-
HTTPS: Enforce HTTPS.
ngrok
provides it for development. Use a reverse proxy (Nginx, Caddy) or load balancer for TLS termination in production. -
Input Validation: Sanitize/validate data before DB operations, even after signature validation.
-
Rate Limiting: Consider
fastify-rate-limit
as a defense-in-depth measure. -
Secrets Management: Use secure methods for managing
PLIVO_AUTH_ID
,PLIVO_AUTH_TOKEN
in production (environment variables via platform secrets management, Vault, etc.). Keep.env
out of Git.
8. Handling Special Cases
- Message Statuses: Handle
queued
,sent
,failed
,delivered
,undelivered
,rejected
. - Error Codes: Log the
ErrorCode
field forfailed
/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
orwarn
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 (check Plivo docs, consider proxies), raw body access, nonce handling. - Plivo Error Codes: Consult Plivo documentation.
- Ngrok Limitations: Temporary URLs, rate limits on free tier. Use a static IP/domain or alternatives like
localtunnel
(use with caution), Cloudflare Tunnel, or deploy to a cloud service (even a cheap VM) for persistent development/staging URLs. - State Management: Use a database for persistence.
- Scalability: Plan for database load and consider asynchronous processing for high volume.
12. Deployment and CI/CD
- Environment Configuration: Use secure environment variable management provided by your deployment platform. Do not commit secrets.
- Persistent URL: Update
BASE_URL
to your production URL. - Process Management: Use
pm2
, systemd, or container orchestration (Docker, Kubernetes) for process lifecycle management. - Containerization (Docker):
- Create a
Dockerfile
:# Dockerfile FROM node:18-alpine WORKDIR /usr/src/app # Copy package files and install only production dependencies COPY package*.json ./ RUN npm ci --only=production --ignore-scripts --prefer-offline # 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, not hardcoded here. CMD [ ""node"", ""server.js"" ]
- Build and run (injecting secrets):
docker build -t fastify-plivo-webhook . # Example run, use your platform's secret management in production docker run -p 3000:3000 \ -e PLIVO_AUTH_ID='YOUR_ACTUAL_ID' \ -e PLIVO_AUTH_TOKEN='YOUR_ACTUAL_TOKEN' \ -e PLIVO_PHONE_NUMBER='YOUR_PLIVO_NUMBER' \ -e BASE_URL='YOUR_PRODUCTION_HTTPS_URL' \ -e PHONE_NUMBER_TO='DEFAULT_OR_CONFIGURED_RECIPIENT' \ fastify-plivo-webhook
- Create a
- CI/CD Pipeline: Automate checkout, dependency installation (
npm ci
), linting/testing, Docker build/push, and deployment (injecting secrets). - Rollback: Have a strategy to revert to a previous working version.
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
): SeePOST
s to/plivo/status
with200 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: Modifyserver.js
to export a function that builds and returns the Fastify app instance, instead of starting it directly.
// --- 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
):
// 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: