Integrate WhatsApp messaging capabilities into your Node.js applications using the robust Fastify framework and Plivo's reliable communication APIs. This guide provides a complete walkthrough, from setting up your project to deploying a production-ready service capable of sending and receiving WhatsApp messages.
We'll build a Fastify backend service that exposes API endpoints to send text and template-based WhatsApp messages via Plivo. It will also include a webhook endpoint to receive incoming messages and status updates from Plivo, ensuring two-way communication. This setup solves the common need for programmatic WhatsApp interaction for customer support, notifications, or engagement campaigns.
Technologies Used:
- Node.js: The JavaScript runtime environment.
- Fastify: A high-performance, low-overhead web framework for Node.js. Chosen for its speed, extensibility, and developer-friendly features like built-in validation and logging.
- TypeScript: Adds static typing for improved code quality and maintainability.
- Plivo: The Communications Platform as a Service (CPaaS) provider for sending and receiving WhatsApp messages via their official API.
- Plivo Node SDK: Simplifies interaction with the Plivo API.
- Prisma (Optional but Recommended): A modern ORM for database access (we'll use PostgreSQL) to log messages.
- Docker: For containerizing the application for consistent deployment.
dotenv
/@fastify/env
: For managing environment variables securely.pino-pretty
: For readable development logs.@fastify/sensible
: Adds sensible defaults and utility decorators.@fastify/rate-limit
: For API rate limiting.@fastify/type-provider-typebox
: For schema validation using TypeBox.
System Architecture:
graph LR
subgraph Your Infrastructure
Client[Client Application] --> FAPI[Fastify API Service];
FAPI -- Send Request --> PlivoSDK[Plivo Node.js SDK];
FAPI -- Store/Retrieve --> DB[(Database)];
PlivoCallback[Plivo Webhook Handler] --> FAPI;
end
subgraph Plivo Cloud
PlivoSDK -- API Call --> PlivoAPI[Plivo WhatsApp API];
PlivoAPI -- Send/Receive --> WhatsApp;
WhatsApp -- Incoming Msg/Status --> PlivoAPI;
PlivoAPI -- POST Request --> PlivoCallback;
end
subgraph External
WhatsApp[WhatsApp Platform];
end
style DB fill:#f9f,stroke:#333,stroke-width:2px
Prerequisites:
- Node.js (v18 or later recommended) and npm/yarn. Use
nvm
for managing Node versions. - A Plivo account with credits.
- A WhatsApp Business Account (WABA) successfully onboarded and linked to Plivo.
- A phone number enabled for WhatsApp via Plivo.
- Access to a terminal or command prompt.
- An IDE like VS Code.
- (Optional) Docker installed.
- (Optional) PostgreSQL database running (locally or cloud-hosted).
- (For local development testing of webhooks)
ngrok
or a similar tunneling service.
Final Outcome:
A containerized Fastify application with API endpoints (/send/text
, /send/template
) and a webhook receiver (/webhooks/plivo/whatsapp
) for seamless WhatsApp communication via Plivo, complete with logging, basic security, and deployment readiness.
1. Setting up the project
Let's initialize our Node.js project using TypeScript and install Fastify along with essential dependencies.
1.1 Environment Setup:
We recommend using Node Version Manager (nvm
) to manage Node.js versions.
- macOS/Linux:
# Install nvm if you haven't already (check nvm GitHub repo for the absolute latest install script) curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash # Activate nvm export NVM_DIR="$([ -z "${XDG_CONFIG_HOME-}" ] && printf %s "${HOME}/.nvm" || printf %s "${XDG_CONFIG_HOME}/nvm")" [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # Install and use Node.js v18 (or latest LTS) nvm install 18 nvm use 18
- Windows: Download the installer from the Node.js website or use
nvm-windows
.
1.2 Project Initialization:
# Create project directory and navigate into it
mkdir fastify-plivo-whatsapp
cd fastify-plivo-whatsapp
# Initialize npm project
npm init -y
# Install core dependencies
npm install fastify @fastify/env @fastify/sensible @fastify/type-provider-typebox plivo-node
# Install development dependencies
npm install typescript @types/node @types/plivo-node ts-node nodemon pino-pretty --save-dev
# Initialize TypeScript configuration
npx tsc --init --rootDir src --outDir dist --esModuleInterop --resolveJsonModule --lib esnext --module commonjs --allowJs true --noImplicitAny true
1.3 Project Structure:
Create the following directory structure:
fastify-plivo-whatsapp/
├── dist/ # Compiled JavaScript output (from tsc)
├── src/ # TypeScript source code
│ ├── routes/ # API route definitions
│ ├── services/ # Business logic (Plivo interaction)
│ ├── schemas/ # Request/response validation schemas
│ ├── utils/ # Utility functions
│ └── server.ts # Fastify server setup and entry point
├── .env # Environment variables (DO NOT COMMIT)
├── .env.example # Example environment variables (commit this)
├── .gitignore
├── package.json
└── tsconfig.json
Explanation:
src/
: Contains all our application logic written in TypeScript.dist/
: Contains the compiled JavaScript code generated bytsc
. We run the code from here in production.routes/
: Separates API endpoint definitions for better organization.services/
: Encapsulates logic for interacting with external services like Plivo or the database.schemas/
: Holds JSON schema definitions used by Fastify for request validation.utils/
: Contains reusable helper functions.server.ts
: The main application file where the Fastify server is configured and started..env
: Stores sensitive information like API keys. Use@fastify/env
to load these..gitignore
: Prevents committing sensitive files (.env
,node_modules
,dist
).tsconfig.json
: Configures the TypeScript compiler.
1.4 Configuration Files:
-
.gitignore
:# Dependencies node_modules/ # Build output dist/ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pids *.pid *.seed *.pid.lock # Environment variables .env .env.*.local # Optional editor directories .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json .idea # OSX .DS_Store
-
.env.example
(Create this file):# Plivo Credentials (Get from Plivo Console -> Account -> Auth Credentials) PLIVO_AUTH_ID=YOUR_PLIVO_AUTH_ID PLIVO_AUTH_TOKEN=YOUR_PLIVO_AUTH_TOKEN # Plivo WhatsApp Number (The number you registered with Plivo for WhatsApp) PLIVO_WHATSAPP_NUMBER=+14155551234 # Use E.164 format # Plivo Webhook Validation (Optional but Highly Recommended) # Generate a strong, random secret string here and configure it in Plivo console PLIVO_WEBHOOK_SECRET=your-strong-random-secret # e.g., use `openssl rand -hex 32` # Application Settings PORT=3000 HOST=0.0.0.0 LOG_LEVEL=info # trace, debug, info, warn, error, fatal # API Key for simple endpoint protection (Generate a secure random string) API_KEY=your-secure-api-key # e.g., use `openssl rand -hex 32` # Database URL (if using Prisma/DB - uncomment and configure if needed) # Example for PostgreSQL: postgresql://user:password@host:port/database # DATABASE_URL=
- Important: Create a
.env
file by copying.env.example
and fill in your actual credentials. Ensure.env
is listed in.gitignore
. Generate strong, unique values forPLIVO_WEBHOOK_SECRET
andAPI_KEY
.
- Important: Create a
-
tsconfig.json
(Modify the generated one):{ "compilerOptions": { "target": "es2022", // Updated target for Node.js v18+ compatibility "module": "commonjs", "rootDir": "src", "outDir": "dist", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true, // Enable strict type-checking "skipLibCheck": true, // Skip type checking of declaration files "noImplicitAny": true, // Raise error on expressions and declarations with an implied 'any' type. "resolveJsonModule": true // Allows importing JSON files // "baseUrl": "./src", // Optional: for shorter import paths // "paths": { // Optional: define path aliases // "@services/*": ["services/*"], // "@routes/*": ["routes/*"] // } }, "include": ["src/**/*"], // Process files in src directory "exclude": ["node_modules", "**/*.spec.ts", "**/*.test.ts"] // Exclude test files and node_modules }
1.5 Run Scripts (package.json
):
Add these scripts to your package.json
file:
// package.json
{
// ... other settings
"main": "dist/server.js", // Point to compiled output
"scripts": {
"build": "tsc",
"start": "node dist/server.js",
"dev": "nodemon --watch src --ext ts --exec ts-node src/server.ts",
"test": "echo \"Error: no test specified\" && exit 1" // Placeholder - tests should be added
}
// ... dependencies, devDependencies
}
build
: Compiles TypeScript to JavaScript in thedist
directory.start
: Runs the compiled JavaScript application (for production).dev
: Runs the application usingts-node
andnodemon
for development, automatically restarting on file changes.test
: Placeholder script. A proper testing strategy (e.g., using Jest or Vitest) should be implemented.
2. Implementing core functionality
Now, let's set up the Fastify server and create the service responsible for interacting with Plivo.
2.1 Fastify Server Setup (src/server.ts
):
// src/server.ts
import Fastify, { FastifyInstance, FastifyServerOptions } from 'fastify';
import sensible from '@fastify/sensible';
import envPlugin from '@fastify/env';
import { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; // For TypeBox validation
import { S } from '@fastify/type-provider-typebox'; // Schema builder
// Import routes
import whatsappRoutes from './routes/whatsappRoutes';
// Import Plivo initializer
import { initializePlivoClient } from './services/plivoService';
// Define environment variable schema
const envSchema = S.Object({
PORT: S.Number({ default: 3000 }),
HOST: S.String({ default: '0.0.0.0' }),
LOG_LEVEL: S.String({ default: 'info' }),
PLIVO_AUTH_ID: S.String(),
PLIVO_AUTH_TOKEN: S.String(),
PLIVO_WHATSAPP_NUMBER: S.String(),
PLIVO_WEBHOOK_SECRET: S.String({ default: '' }), // Allow empty for optional validation
API_KEY: S.String(),
// DATABASE_URL: S.Optional(S.String()) // Uncomment if using database
});
// Declare module augmentation for FastifyInstance
declare module 'fastify' {
interface FastifyInstance {
config: {
PORT: number;
HOST: string;
LOG_LEVEL: string;
PLIVO_AUTH_ID: string;
PLIVO_AUTH_TOKEN: string;
PLIVO_WHATSAPP_NUMBER: string;
PLIVO_WEBHOOK_SECRET: string;
API_KEY: string;
// DATABASE_URL?: string; // Uncomment if using database
};
// Add other custom properties if needed, e.g., prisma client
// prisma: PrismaClient;
}
}
async function buildServer(): Promise<FastifyInstance> {
const isProduction = process.env.NODE_ENV === 'production';
const serverOptions: FastifyServerOptions = {
logger: {
level: process.env.LOG_LEVEL || 'info',
// Use pino-pretty only in development for better readability
transport: isProduction
? undefined
: { target: 'pino-pretty', options: { translateTime: 'SYS:standard', ignore: 'pid,hostname' } },
},
ajv: {
customOptions: {
// Recommended AJV options
removeAdditional: 'all', // Remove additional properties
useDefaults: true,
coerceTypes: true,
allErrors: true, // Collect all errors
}
}
};
const server = Fastify(serverOptions).withTypeProvider<TypeBoxTypeProvider>();
try {
// Register environment variables plugin
await server.register(envPlugin, {
dotenv: true, // Load .env file
schema: envSchema,
});
// Initialize Plivo Client *after* config is loaded
initializePlivoClient(server);
// Register sensible plugin (adds useful decorators like httpErrors)
await server.register(sensible);
// --- Register Plugins (e.g., Rate Limiting, Database) ---
// Example: await server.register(require('@fastify/rate-limit'), { max: 100, timeWindow: '1 minute' });
// Example: Register Prisma client
// --- Register Routes ---
await server.register(whatsappRoutes, { prefix: '/api/whatsapp' });
// --- Default Root Route ---
server.get('/', async (request, reply) => {
return { message: 'Fastify Plivo WhatsApp Service Running', timestamp: new Date().toISOString() };
});
// --- Health Check Route ---
server.get('/health', async (request, reply) => {
// Add checks for database connection, Plivo connectivity if needed
return { status: 'ok', timestamp: new Date().toISOString() };
});
server.log.info('Server plugins and routes registered successfully.');
} catch (err) {
server.log.error(err, 'Error during server setup');
process.exit(1);
}
return server;
}
async function start() {
let server: FastifyInstance | null = null;
try {
server = await buildServer();
await server.listen({ port: server.config.PORT, host: server.config.HOST });
// Log address info after successful listen
server.log.info(`Server listening at http://${server.config.HOST}:${server.config.PORT}`);
server.log.info(`Plivo WhatsApp Number configured: ${server.config.PLIVO_WHATSAPP_NUMBER}`);
// Setup graceful shutdown after server starts listening
setupGracefulShutdown(server);
} catch (err) {
if (server) {
server.log.error(err, 'Error starting server');
} else {
// Handle case where buildServer itself failed
console.error('Error during server build/start:', err);
}
process.exit(1);
}
}
// Graceful shutdown handler
const setupGracefulShutdown = (server: FastifyInstance) => {
const shutdown = async (signal: string) => {
server.log.warn(`Received ${signal}. Shutting down gracefully...`);
try {
await server.close();
// Add cleanup tasks here (e.g., close DB connection)
// if (server.prisma) { await server.prisma.$disconnect(); }
server.log.info('Server closed successfully.');
process.exit(0);
} catch (err) {
server.log.error(err, 'Error during graceful shutdown');
process.exit(1);
}
};
// Listen for termination signals
process.on('SIGINT', () => shutdown('SIGINT')); // Ctrl+C
process.on('SIGTERM', () => shutdown('SIGTERM')); // kill/systemctl stop
};
// Start the server
start().catch(err => {
// This catch is unlikely to be hit due to internal catches, but good practice
console.error('Unhandled error during startup process:', err);
process.exit(1);
});
Explanation:
- Imports: Import Fastify, plugins, routes, and the
initializePlivoClient
function. envSchema
: Defines expected environment variables using TypeBox for validation and type safety.- Module Augmentation: Extends
FastifyInstance
to include the loadedconfig
object for type-safe access. buildServer
function:- Configures Fastify logger (
pino-pretty
in dev, JSON in prod). - Configures AJV for schema validation.
- Registers
@fastify/env
to load.env
and validate variables. - Calls
initializePlivoClient(server)
immediately after config is loaded. - Registers
@fastify/sensible
. - Registers application routes under
/api/whatsapp
. - Includes basic
/
and/health
routes.
- Configures Fastify logger (
start
function:- Calls
buildServer
. - Starts the server using
HOST
andPORT
from config. - Calls
setupGracefulShutdown
after the server successfully starts listening. - Handles potential startup errors.
- Calls
- Graceful Shutdown (
setupGracefulShutdown
): Implements handlers forSIGINT
andSIGTERM
to allow graceful server closure. Cleanup tasks (like DB disconnect) should be added here. - Execution: Calls
start()
to run the application.
2.2 Plivo Service (src/services/plivoService.ts
):
This service encapsulates all logic for interacting with the Plivo API.
// src/services/plivoService.ts
import { Client, MessageCreateParams, Template } from 'plivo-node';
import { FastifyInstance } from 'fastify'; // Import FastifyInstance for access to config and logger
let plivoClient: Client;
// Initialize the Plivo client using config from Fastify instance
export function initializePlivoClient(server: FastifyInstance): void {
if (!plivoClient) {
if (!server.config.PLIVO_AUTH_ID || !server.config.PLIVO_AUTH_TOKEN) {
server.log.error('Plivo Auth ID or Auth Token missing in configuration. Cannot initialize Plivo client.');
throw new Error('Plivo credentials missing.');
}
plivoClient = new Client(
server.config.PLIVO_AUTH_ID,
server.config.PLIVO_AUTH_TOKEN
);
server.log.info('Plivo client initialized successfully.');
}
}
// Function to send a standard WhatsApp text message
export async function sendWhatsAppTextMessage(
server: FastifyInstance,
to: string,
text: string
): Promise<any> { // TODO: Define a more specific return type based on Plivo SDK response structure
if (!plivoClient) {
server.log.error('Plivo client accessed before initialization.');
throw new Error('Plivo client not initialized. Call initializePlivoClient first.');
}
const params: MessageCreateParams = {
src: server.config.PLIVO_WHATSAPP_NUMBER,
dst: to,
text: text,
type: 'whatsapp',
// Optional: URL for message status callbacks (configure in Plivo console or here)
// url: 'https://your-app.com/api/whatsapp/webhooks/plivo/status',
// method: 'POST'
};
server.log.info({ dst: to, type: 'text' }, 'Attempting to send WhatsApp text message via Plivo');
try {
const response = await plivoClient.messages.create(params);
server.log.info({ message_uuid: response.messageUuid, api_id: response.apiId }, 'WhatsApp text message sent successfully via Plivo');
// Optional: Log message details to database here
return response;
} catch (error: any) {
server.log.error({ error: error.message, stack: error.stack, params }, 'Error sending WhatsApp text message via Plivo');
// Rethrow or handle specific Plivo errors
throw new Error(`Plivo API Error: ${error.message || 'Unknown error'}`);
}
}
// Function to send a WhatsApp template message
export async function sendWhatsAppTemplateMessage(
server: FastifyInstance,
to: string,
template: Template // Use Plivo's Template type
): Promise<any> { // TODO: Define a more specific return type based on Plivo SDK response structure
if (!plivoClient) {
server.log.error('Plivo client accessed before initialization.');
throw new Error('Plivo client not initialized. Call initializePlivoClient first.');
}
// Basic validation for template structure (more specific validation might be needed)
if (!template || !template.name || !template.language) {
server.log.warn({ templateReceived: template }, 'Invalid template object provided to sendWhatsAppTemplateMessage');
throw new Error('Invalid template object: name and language are required.');
}
const params: MessageCreateParams = {
src: server.config.PLIVO_WHATSAPP_NUMBER,
dst: to,
template: template, // Pass the template object directly
type: 'whatsapp',
// Optional: URL for message status callbacks
// url: 'https://your-app.com/api/whatsapp/webhooks/plivo/status',
// method: 'POST'
};
server.log.info({ dst: to, templateName: template.name }, 'Attempting to send WhatsApp template message via Plivo');
try {
const response = await plivoClient.messages.create(params);
server.log.info({ message_uuid: response.messageUuid, api_id: response.apiId }, 'WhatsApp template message sent successfully via Plivo');
// Optional: Log message details to database here
return response;
} catch (error: any) {
server.log.error({ error: error.message, stack: error.stack, params }, 'Error sending WhatsApp template message via Plivo');
throw new Error(`Plivo API Error: ${error.message || 'Unknown error'}`);
}
}
Explanation:
- Initialization:
initializePlivoClient
creates a singleton Plivo client using credentials fromserver.config
. It includes a check to ensure credentials exist. This is called once during server startup inserver.ts
. sendWhatsAppTextMessage
:- Takes
server
(for logging/config), recipientto
, and messagetext
. - Constructs
params
for the Plivo API call. - Logs attempt and success/failure. Includes
try...catch
for Plivo API errors. - Uses
Promise<any>
as return type; a TODO comment reminds to use specific types.
- Takes
sendWhatsAppTemplateMessage
:- Similar structure, takes a
template
object conforming to Plivo'sTemplate
type. - Includes basic validation for the template object.
- Uses
Promise<any>
with a TODO comment.
- Similar structure, takes a
- Error Handling: Logs errors with context and throws a new Error for route handlers to catch. Added checks for client initialization.
3. Building a complete API layer
Let's define the API endpoints for sending messages and receiving webhooks.
3.1 Request Schemas (src/schemas/whatsappSchemas.ts
):
Using @fastify/type-provider-typebox
for clear, typed schemas.
// src/schemas/whatsappSchemas.ts
import { Type, Static } from '@fastify/type-provider-typebox';
// Schema for sending a plain text message
export const SendTextBodySchema = Type.Object({
to: Type.String({
description: 'Recipient WhatsApp number in E.164 format (e.g., +14155551234)',
pattern: '^\\+[1-9]\\d{1,14}$'
}),
text: Type.String({ description: 'The text content of the message', minLength: 1, maxLength: 4096 }) // WhatsApp limits
});
export type SendTextBody = Static<typeof SendTextBodySchema>;
// Schema for Plivo Template Parameter component
const TemplateParameterSchema = Type.Object({
type: Type.Union([Type.Literal('text'), Type.Literal('media'), Type.Literal('payload')]), // Add others if needed (currency, date_time)
text: Type.Optional(Type.String()),
media: Type.Optional(Type.String({ format: 'uri', description: 'URL of the media file' })),
payload: Type.Optional(Type.String())
// Add currency, date_time objects if using those parameter types
});
// Schema for Plivo Template Component
const TemplateComponentSchema = Type.Object({
type: Type.Union([Type.Literal('header'), Type.Literal('body'), Type.Literal('footer'), Type.Literal('button')]),
sub_type: Type.Optional(Type.String()), // e.g., 'quick_reply', 'url' for buttons
index: Type.Optional(Type.Number()), // For buttons
parameters: Type.Optional(Type.Array(TemplateParameterSchema))
});
// Schema for Plivo Template object
// Ref: https://www.plivo.com/docs/messaging/message-api/send-a-whatsapp-message#send-a-template-message
export const PlivoTemplateSchema = Type.Object({
name: Type.String({ description: 'The registered name of the template' }),
language: Type.String({ description: 'The language code of the template (e.g., en_US)' }),
components: Type.Optional(Type.Array(TemplateComponentSchema))
});
export type PlivoTemplate = Static<typeof PlivoTemplateSchema>;
// Schema for sending a template message
export const SendTemplateBodySchema = Type.Object({
to: Type.String({
description: 'Recipient WhatsApp number in E.164 format',
pattern: '^\\+[1-9]\\d{1,14}$'
}),
template: PlivoTemplateSchema
});
export type SendTemplateBody = Static<typeof SendTemplateBodySchema>;
// Generic Success Response Schema
export const SuccessResponseSchema = Type.Object({
message: Type.String(),
message_uuid: Type.Optional(Type.String()), // From Plivo response
api_id: Type.Optional(Type.String()) // From Plivo response
});
export type SuccessResponse = Static<typeof SuccessResponseSchema>;
// Schema for incoming Plivo WhatsApp message webhook
// Ref: https://www.plivo.com/docs/messaging/concepts/message-webhooks#whatsapp-inbound-message-parameters
export const PlivoIncomingWebhookSchema = Type.Object({
From: Type.String(), // Sender's WhatsApp number
To: Type.String(), // Your Plivo WhatsApp number
Type: Type.String(), // e.g., text, image, video, audio, location, contacts, interactive, button
Text: Type.Optional(Type.String()), // Content of text message
MediaUrl0: Type.Optional(Type.String()), // Renamed from MediaUrl for potential multi-media (check Plivo docs)
MediaContentType0: Type.Optional(Type.String()), // Renamed from MediaContentType
NumMedia: Type.Optional(Type.String()), // Number of media items (usually '1' if present)
Latitude: Type.Optional(Type.String()),
Longitude: Type.Optional(Type.String()),
LocationAddress: Type.Optional(Type.String()),
ContactName: Type.Optional(Type.String()),
ContactNumber: Type.Optional(Type.String()),
ButtonPayload: Type.Optional(Type.String()), // Payload from button click
ListResponseTitle: Type.Optional(Type.String()), // Title from list selection
InteractiveType: Type.Optional(Type.String()), // e.g., list_reply, button_reply
// Add other fields as needed based on Plivo docs (Context, Errors, etc.)
MessageUUID: Type.String() // Plivo's unique ID for the message
});
export type PlivoIncomingWebhook = Static<typeof PlivoIncomingWebhookSchema>;
// Schema for incoming Plivo WhatsApp status webhook
// Ref: https://www.plivo.com/docs/messaging/concepts/message-webhooks#message-status-parameters
export const PlivoStatusWebhookSchema = Type.Object({
MessageUUID: Type.String(), // Plivo's unique ID
Status: Type.String(), // e.g., queued, sent, delivered, read, failed, undelivered
To: Type.String(), // Recipient number
From: Type.String(), // Your Plivo number
ErrorCode: Type.Optional(Type.String()), // Plivo error code if failed/undelivered
ErrorMessage: Type.Optional(Type.String()), // Description of the error
// Add other fields like TotalRate, TotalAmount, Units if needed
});
export type PlivoStatusWebhook = Static<typeof PlivoStatusWebhookSchema>;
Explanation:
- Uses TypeBox for defining request/response structures and types.
- Includes E.164 pattern validation for phone numbers. Removed inline comments from regex patterns.
- Defines schemas for sending text, sending templates (based on Plivo's structure), incoming messages, status updates, and a generic success response. Adjusted
MediaUrl
toMediaUrl0
based on common Plivo webhook formats. - Exports both schemas and inferred TypeScript types.
3.2 API Routes (src/routes/whatsappRoutes.ts
):
// src/routes/whatsappRoutes.ts
import { FastifyInstance, FastifyPluginOptions, FastifyRequest, FastifyReply } from 'fastify';
import { Type } from '@fastify/type-provider-typebox'; // Import Type for response schema
import { PlivoIncomingWebhook, PlivoStatusWebhook, PlivoTemplate, SendTemplateBody, SendTextBody, SendTemplateBodySchema, SendTextBodySchema, SuccessResponse, SuccessResponseSchema, PlivoIncomingWebhookSchema, PlivoStatusWebhookSchema } from '../schemas/whatsappSchemas';
import { sendWhatsAppTemplateMessage, sendWhatsAppTextMessage } from '../services/plivoService';
// import { verifyPlivoSignature } from '../utils/webhookUtils'; // Implementation likely needed
// Simple Authentication Hook (API Key)
async function authenticate(request: FastifyRequest, reply: FastifyReply) {
const apiKey = request.headers['x-api-key'];
if (!apiKey || apiKey !== request.server.config.API_KEY) {
request.log.warn('Authentication failed: Invalid or missing API Key');
// Use sensible plugin for standard error
reply.unauthorized('Invalid or missing API Key');
// Stop execution
return reply;
}
request.log.trace('Authentication successful'); // Use trace for successful auth typically
}
// Plivo Webhook Validation Hook (Partial Implementation)
async function validatePlivoWebhook(request: FastifyRequest, reply: FastifyReply) {
const secret = request.server.config.PLIVO_WEBHOOK_SECRET;
// Skip validation if no secret is configured
if (!secret) {
request.log.warn('Skipping Plivo webhook validation: PLIVO_WEBHOOK_SECRET not configured.');
return; // Allow request through if validation is disabled
}
const signature = request.headers['x-plivo-signature-v3'] as string;
const nonce = request.headers['x-plivo-signature-v3-nonce'] as string;
if (!signature || !nonce) {
request.log.warn('Plivo webhook validation failed: Missing X-Plivo-Signature-V3 or X-Plivo-Signature-V3-Nonce headers.');
reply.badRequest('Missing Plivo signature headers');
return reply; // Stop execution
}
// TODO: Implement actual signature verification using crypto.
// The `verifyPlivoSignature` utility function implementation is required.
// This requires using the raw request body, not the parsed `request.body`.
// Libraries like `fastify-raw-body` can help access the raw payload.
// The signature is calculated using HMAC-SHA256(URL + Nonce + RawBody, WebhookSecret).
// See Plivo documentation for details: https://www.plivo.com/docs/getting-started/concepts/webhooks#validate-requests-from-plivo
request.log.info('Plivo webhook headers present. TODO: Implement full signature validation.');
// Placeholder for where actual validation would occur:
// const url = `${request.protocol}://${request.hostname}${request.url}`;
// const rawBody = request.rawBody; // Assuming fastify-raw-body is configured
// if (!verifyPlivoSignature(secret, url, nonce, signature, rawBody)) {
// request.log.warn('Plivo webhook validation failed: Invalid signature.');
// reply.forbidden('Invalid Plivo signature');
// return reply; // Stop execution
// }
// request.log.info('Plivo webhook signature validated successfully.');
}
// Define the routes
export default async function whatsappRoutes(server: FastifyInstance, options: FastifyPluginOptions) {
// --- Send Text Message Endpoint ---
server.post<{ Body: SendTextBody; Reply: SuccessResponse | { error: string } }>(
'/send/text',
{
schema: {
description: 'Sends a plain text WhatsApp message via Plivo.',
tags: ['WhatsApp'],
summary: 'Send Text Message',
headers: {
type: 'object',
properties: { 'x-api-key': { type: 'string' } }, // Lowercase header name standard
required: ['x-api-key']
},
body: SendTextBodySchema,
response: {
200: SuccessResponseSchema,
// Sensible plugin handles standard error responses (400, 401, 500)
},
},
preHandler: [authenticate] // Apply authentication hook
},
async (request, reply) => {
try {
const { to, text } = request.body;
const plivoResponse = await sendWhatsAppTextMessage(server, to, text);
reply.send({
message: 'WhatsApp text message sent successfully.',
message_uuid: plivoResponse.messageUuid?.[0], // Plivo returns array
api_id: plivoResponse.apiId
});
} catch (error: any) {
request.log.error(error, 'Error in /send/text handler');
// Use sensible plugin for standard error response
reply.internalServerError(error.message || 'Failed to send WhatsApp text message.');
}
}
);
// --- Send Template Message Endpoint ---
server.post<{ Body: SendTemplateBody; Reply: SuccessResponse | { error: string } }>(
'/send/template',
{
schema: {
description: 'Sends a WhatsApp template message via Plivo.',
tags: ['WhatsApp'],
summary: 'Send Template Message',
headers: {
type: 'object',
properties: { 'x-api-key': { type: 'string' } },
required: ['x-api-key']
},
body: SendTemplateBodySchema,
response: {
200: SuccessResponseSchema,
},
},
preHandler: [authenticate] // Apply authentication hook
},
async (request, reply) => {
try {
const { to, template } = request.body;
// Cast template to Plivo's expected type if necessary, though TypeBox should align
const plivoResponse = await sendWhatsAppTemplateMessage(server, to, template as PlivoTemplate);
reply.send({
message: 'WhatsApp template message sent successfully.',
message_uuid: plivoResponse.messageUuid?.[0], // Plivo returns array
api_id: plivoResponse.apiId
});
} catch (error: any) {
request.log.error(error, 'Error in /send/template handler');
reply.internalServerError(error.message || 'Failed to send WhatsApp template message.');
}
}
);
// --- Plivo Webhook Endpoint (Incoming Messages & Status Updates) ---
// Note: Plivo often uses the same URL for both message and status callbacks.
// We differentiate based on the payload structure.
server.post<{ Body: PlivoIncomingWebhook | PlivoStatusWebhook }>(
'/webhooks/plivo', // Consolidated webhook endpoint
{
schema: {
description: 'Receives incoming WhatsApp messages and status updates from Plivo.',
tags: ['Webhooks', 'WhatsApp'],
summary: 'Plivo Webhook Handler',
// Body schema is complex as it can be one of two types.
// Runtime check is needed. TypeBox `Type.Union` could be used,
// but checking for key fields like 'Status' vs 'Text'/'Type' is often simpler.
// headers: { // Headers for signature validation (checked in hook)
// 'X-Plivo-Signature-V3': { type: 'string' },
// 'X-Plivo-Signature-V3-Nonce': { type: 'string' }
// },
response: {
200: Type.Object({ status: Type.String() }), // Acknowledge receipt
// Errors handled by hook or handler
},
},
// Add config to access raw body if implementing full signature validation
// config: { rawBody: true },
preHandler: [validatePlivoWebhook] // Apply webhook validation hook
},
async (request, reply) => {
const payload = request.body;
request.log.info({ payload }, 'Received Plivo webhook');
try {
// Differentiate between Message Status and Incoming Message
if ('Status' in payload && 'MessageUUID' in payload && !('Type' in payload)) {
// Likely a Status Update
const statusPayload = payload as PlivoStatusWebhook;
request.log.info({ uuid: statusPayload.MessageUUID, status: statusPayload.Status }, 'Processing Plivo status update webhook');
// TODO: Handle status update (e.g., update message status in DB)
// Example: await updateMessageStatusInDb(statusPayload);
} else if ('Type' in payload && 'From' in payload && 'To' in payload && 'MessageUUID' in payload) {
// Likely an Incoming Message
const incomingPayload = payload as PlivoIncomingWebhook;
request.log.info({ uuid: incomingPayload.MessageUUID, from: incomingPayload.From, type: incomingPayload.Type }, 'Processing Plivo incoming message webhook');
// TODO: Handle incoming message (e.g., log message, trigger auto-reply)
// Example: await processIncomingMessage(incomingPayload);
} else {
// Unknown payload type
request.log.warn({ payload }, 'Received unknown Plivo webhook payload structure.');
// Still acknowledge to Plivo, but log warning
}
// Always acknowledge receipt to Plivo quickly
reply.code(200).send({ status: 'received' });
} catch (error: any) {
request.log.error(error, 'Error processing Plivo webhook');
// Acknowledge receipt even on error to prevent Plivo retries,
// but log the error for investigation.
reply.code(200).send({ status: 'error processing' });
}
}
);
}