This guide provides a step-by-step walkthrough for building a Node.js application using the Fastify framework to send and receive WhatsApp messages via the Infobip API. We will cover everything from initial project setup to handling webhooks, error management, and basic deployment considerations.
By the end of this tutorial, you will have a functional Fastify application capable of sending WhatsApp text messages and receiving incoming messages through Infobip's webhook service. This solves the common need for businesses to programmatically interact with customers on WhatsApp for notifications, support, or engagement, leveraging the speed of Fastify and the robust infrastructure of Infobip.
Project Overview and Goals
-
Goal: Create a Node.js application using Fastify to send outbound WhatsApp messages and process inbound messages via Infobip.
-
Problem Solved: Enables programmatic WhatsApp communication without directly managing the complexities of the WhatsApp Business API infrastructure. Provides a foundation for building chatbots, notification systems, or integrating WhatsApp into existing business workflows.
-
Technologies:
- Node.js: The JavaScript runtime environment.
- Fastify: A high-performance, low-overhead Node.js web framework chosen for its speed, extensibility, and developer-friendly features (like built-in logging and validation).
- Infobip: A communication platform-as-a-service (CPaaS) providing APIs for various channels, including WhatsApp. We'll use their official Node.js SDK.
- dotenv: A module to load environment variables from a
.env
file for secure configuration management.
-
Architecture:
graph LR A[User/Client App] -- HTTP POST --> B(Fastify App); B -- Send Message API Call --> C(Infobip API); C -- Delivers --> D(WhatsApp User); D -- Sends Reply --> C; C -- Webhook POST --> B; B -- Processes Incoming Message --> E(Log/Database/Logic); style B fill:#f9f,stroke:#333,stroke-width:2px; style C fill:#ccf,stroke:#333,stroke-width:2px;
-
Prerequisites:
- Node.js (v14 or later recommended) and npm installed.
- An active Infobip account (a free trial account is sufficient for testing, but has limitations). You can sign up here.
- Your Infobip API Key and Base URL.
- A WhatsApp sender registered and approved within your Infobip account.
- A publicly accessible URL for receiving webhooks. Tools like
ngrok
are useful for local development, but for production, you'll need a deployed server with a public IP or domain name.
-
Outcome: A running Fastify server with two primary endpoints:
POST /send
: Accepts a request to send a WhatsApp message.POST /infobip-webhook
: Listens for incoming WhatsApp messages forwarded by Infobip.
1. Setting up the project
Let's initialize our Node.js project and install the necessary dependencies.
-
Create Project Directory: Open your terminal and create a new directory for your project, then navigate into it:
mkdir fastify-infobip-whatsapp cd fastify-infobip-whatsapp
-
Initialize Node.js Project: Run
npm init
and follow the prompts. You can accept the defaults for most options.npm init -y
This creates a
package.json
file. -
Install Dependencies: We need Fastify for the web server, the Infobip Node.js SDK to interact with their API, and
dotenv
to manage our API credentials securely.npm install fastify @infobip-api/sdk dotenv
-
Create Project Structure: Set up a basic directory structure for clarity:
mkdir src touch src/server.js touch .env touch .gitignore
src/server.js
: Will contain our main application code..env
: Stores sensitive information like API keys (will be ignored by Git)..gitignore
: Specifies files and directories that Git should ignore.
-
Configure
.gitignore
: Addnode_modules
and.env
to your.gitignore
file to prevent committing dependencies and secrets:# .gitignore node_modules .env npm-debug.log
-
Set up Environment Variables: Open the
.env
file and add your Infobip credentials and the phone number associated with your Infobip WhatsApp sender. You'll also define the port for your server.-
Purpose:
INFOBIP_API_KEY
: Your secret API key for authenticating requests with Infobip.INFOBIP_BASE_URL
: The specific API endpoint domain provided by Infobip for your account (the full URL).INFOBIP_WHATSAPP_SENDER
: The registered phone number (including country code) used to send messages via Infobip.PORT
: The local port your Fastify server will listen on.WEBHOOK_SECRET
: A secret string you define, used to verify incoming webhooks (basic security).
-
How to Obtain Infobip Values:
- Log in to your Infobip Portal.
- Navigate to the
Developers
section orAPI Keys
(exact location might vary slightly). - Create or copy an existing API Key (
INFOBIP_API_KEY
). - Your Base URL (
INFOBIP_BASE_URL
) is usually displayed prominently on the API Keys page or homepage after login. Copy the complete Base URL provided (e.g.,xyz123.api.infobip.com
). - Your WhatsApp Sender number (
INFOBIP_WHATSAPP_SENDER
) is the number you registered with Infobip for sending WhatsApp messages. Find this under theChannels and Numbers
->WhatsApp
section.
# .env INFOBIP_API_KEY=YOUR_ACTUAL_INFOBIP_API_KEY INFOBIP_BASE_URL=YOUR_FULL_INFOBIP_BASE_URL INFOBIP_WHATSAPP_SENDER=YOUR_WHATSAPP_SENDER_NUMBER_WITH_COUNTRY_CODE PORT=3000 WEBHOOK_SECRET=a_very_secret_string_you_should_change
Important: Replace the placeholder values with your actual credentials. Never commit your
.env
file to version control. -
2. Implementing core functionality: Sending Messages
Now, let's write the code to initialize Fastify and the Infobip client, and create an endpoint to send messages.
-
Load Environment Variables and Initialize Clients: Open
src/server.js
. We'll start by loadingdotenv
, importing dependencies, and setting up Fastify and the Infobip client.// src/server.js 'use strict'; // Load environment variables from .env file require('dotenv').config(); // Import dependencies const Fastify = require('fastify'); const { Infobip, AuthType } = require('@infobip-api/sdk'); // --- Configuration Validation --- // Ensure essential environment variables are set const requiredEnv = ['INFOBIP_API_KEY', 'INFOBIP_BASE_URL', 'INFOBIP_WHATSAPP_SENDER', 'PORT', 'WEBHOOK_SECRET']; for (const variable of requiredEnv) { if (!process.env[variable]) { console.error(`Error: Missing required environment variable ${variable}. Please check your .env file.`); process.exit(1); // Exit if critical config is missing } } const PORT = process.env.PORT || 3000; const INFOBIP_API_KEY = process.env.INFOBIP_API_KEY; const INFOBIP_BASE_URL = process.env.INFOBIP_BASE_URL; const INFOBIP_WHATSAPP_SENDER = process.env.INFOBIP_WHATSAPP_SENDER; const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET; // --- Initialize Fastify --- // Enable Fastify's built-in logger for better debugging const fastify = Fastify({ logger: true }); // --- Initialize Infobip Client --- // Use API Key authentication as recommended let infobipClient; try { infobipClient = new Infobip({ baseUrl: INFOBIP_BASE_URL, apiKey: INFOBIP_API_KEY, authType: AuthType.ApiKey, // Specify the authentication type }); fastify.log.info('Infobip client initialized successfully.'); } catch (error) { fastify.log.error({ err: error }, 'Failed to initialize Infobip client'); process.exit(1); // Cannot proceed without the client } // --- Simple In-Memory Store for Received Messages (Illustrative) --- // In production, use a proper database (PostgreSQL, MongoDB, Redis, etc.) const receivedMessages = []; // --- Routes will go here --- // --- Start Server Function --- const start = async () => { try { await fastify.listen({ port: PORT, host: '0.0.0.0' }); // Listen on all available network interfaces fastify.log.info(`Server listening on port ${fastify.server.address().port}`); } catch (err) { fastify.log.error(err); process.exit(1); } }; // --- Run the server --- start();
- Why
dotenv.config()
first? It ensures environment variables are loaded before any other code tries to access them. - Why configuration validation? Catching missing configuration early prevents runtime errors.
- Why
Fastify({ logger: true })
? Enables detailed logging of requests, responses, and errors, crucial for development and debugging. - Why
AuthType.ApiKey
? Explicitly tells the SDK which authentication method to use, matching the Infobip standard. - Why
try...catch
aroundnew Infobip()
? Handles potential errors during client initialization (e.g., invalid base URL format). - Why
host: '0.0.0.0'
? Makes the server accessible from outside its container or local machine (important for Docker or receiving webhooks).
- Why
-
Create the Send Message Route: Add the following route definition inside
src/server.js
before thestart()
function call.// src/server.js // ... (previous code) ... // --- Routes --- // Route to send a WhatsApp text message fastify.post('/send', async (request, reply) => { const { to, text } = request.body; // Basic input validation if (!to || !text) { reply.code(400); // Bad Request return { error: 'Missing required fields: `to` and `text`' }; } // Validate 'to' number format (basic example - refine as needed) // This regex checks for a plus sign followed by digits. if (!/^\+\d+$/.test(to)) { reply.code(400); return { error: 'Invalid `to` phone number format. Use E.164 format (e.g., +14155552671).' }; } const messagePayload = { type: 'text', // Specify message type as text from: INFOBIP_WHATSAPP_SENDER, // Use sender from .env to: to, // Destination number from request body content: { text: text, // Message content from request body }, }; fastify.log.info({ msg: 'Attempting to send WhatsApp message', payload: messagePayload }); try { const response = await infobipClient.channels.whatsapp.send(messagePayload); fastify.log.info({ msg: 'WhatsApp message sent successfully', response }); return reply.code(200).send({ message: 'Message sent successfully', infobipResponse: response, // Include Infobip's response }); } catch (error) { fastify.log.error({ err: error, msg: 'Failed to send WhatsApp message' }); // Provide more specific feedback if possible const errorMessage = error.response?.data?.requestError?.serviceException?.text || error.message || 'Unknown error'; const errorCode = error.response?.status || 500; // Use Infobip's status code or default to 500 reply.code(errorCode); // Use appropriate HTTP status code return { error: 'Failed to send message', details: errorMessage }; } }); // --- Start Server Function --- // ... (rest of the code) ...
- Why
async (request, reply)
? The route handler needs to beasync
because sending the message (infobipClient.channels.whatsapp.send
) is an asynchronous operation (it returns a Promise). - Why Input Validation? Prevents sending invalid data to Infobip, reducing errors and API calls. We check for presence and a basic E.164 format.
- Why
messagePayload
structure? This follows the structure required by the Infobip SDK'ssend
method for text messages, as documented here. - Why
try...catch
? Essential for handling potential network errors or API errors returned by Infobip during the send operation. - Why Detailed Error Logging/Response? Logging the error object and returning specific error details (
errorMessage
,errorCode
) helps diagnose problems quickly. We try to extract the error message from Infobip's response structure if available.
- Why
3. Building the API Layer
The /send
route created above forms the basic API layer for sending messages.
-
Authentication/Authorization: For this simple example, there's no built-in user authentication. In a production scenario, you would add authentication middleware (e.g., using JWT with
fastify-jwt
or API keys) to protect this endpoint. -
Request Validation: We implemented basic validation for
to
andtext
. For more complex scenarios, Fastify's schema validation is highly recommended for robustness:const sendMessageSchema = { body: { type: 'object', required: ['to', 'text'], properties: { to: { type: 'string', pattern: '^\\+\\d+' }, // E.164 format; Note double backslash for JSON string text: { type: 'string', minLength: 1 } } } } // Add schema to the route options fastify.post('/send', { schema: sendMessageSchema }, async (request, reply) => { // ... handler logic ... // Fastify automatically validates request.body against the schema });
-
API Endpoint Documentation:
- Endpoint:
POST /send
- Description: Sends a WhatsApp text message via Infobip.
- Request Body (JSON):
{ ""to"": ""+14155552671"", ""text"": ""Hello from Fastify!"" }
- Success Response (200 OK - JSON):
{ ""message"": ""Message sent successfully"", ""infobipResponse"": { ""messages"": [ { ""to"": ""+14155552671"", ""status"": { ""groupId"": 1, ""groupName"": ""PENDING"", ""id"": 26, ""name"": ""PENDING_ACCEPTED"", ""description"": ""Message sent to next instance"" }, ""messageId"": ""some-unique-message-id"" } ], ""bulkId"": ""some-unique-bulk-id"" } }
- Error Response (e.g., 400 Bad Request - JSON):
{ ""error"": ""Missing required fields: \""to\"" and \""text\"""" }
- Error Response (e.g., 500 Internal Server Error / Infobip Error - JSON):
{ ""error"": ""Failed to send message"", ""details"": ""Invalid login details"" }
- Endpoint:
-
Testing with
curl
: Replace placeholders with your actual running server address, recipient number (must be your own number if using a free trial Infobip account), and desired message.curl -X POST http://localhost:3000/send \ -H ""Content-Type: application/json"" \ -d '{ ""to"": ""+1xxxxxxxxxx"", ""text"": ""Testing from curl!"" }'
4. Integrating with Infobip (Receiving Messages - Webhooks)
To receive incoming WhatsApp messages, Infobip needs a publicly accessible URL (a webhook) to send the message data to.
-
Create the Webhook Route: Add this route definition to
src/server.js
, before thestart()
function.// src/server.js // ... (previous code, including /send route) ... // Route to handle incoming WhatsApp messages from Infobip Webhook fastify.post('/infobip-webhook', async (request, reply) => { fastify.log.info({ msg: 'Received request on /infobip-webhook' }); // --- Basic Webhook Security Check --- // Compare a secret header/query param with your defined WEBHOOK_SECRET. // This is basic; STRONGLY prefer signature verification if Infobip offers it. const providedSecret = request.headers['x-webhook-secret'] || request.query.secret; if (providedSecret !== WEBHOOK_SECRET) { fastify.log.warn('Unauthorized webhook attempt: Invalid or missing secret.'); reply.code(401); // Unauthorized return { error: 'Unauthorized' }; } const incomingData = request.body; // Log the entire incoming payload for inspection fastify.log.info({ msg: 'Incoming Infobip Webhook Payload:', payload: incomingData }); // --- Process the Incoming Message --- // Infobip webhook payload structure can vary. Inspect the logs to adapt. // This assumes the standard structure for incoming WhatsApp messages. if (incomingData && incomingData.results && incomingData.results.length > 0) { incomingData.results.forEach(message => { if (message.messageId && message.from && message.to && message.message?.content?.text) { const received = { messageId: message.messageId, from: message.from, to: message.to, // Your WhatsApp sender number text: message.message.content.text, receivedAt: new Date().toISOString(), providerData: message // Store the full original message data if needed }; fastify.log.info({ msg: 'Processed incoming message:', receivedMessage: received }); // Store the message (using our simple in-memory store for now) receivedMessages.push(received); // TODO: Add your business logic here // - Store in a database // - Trigger a response message // - Forward to another system // Example: Echo back the message (Use with caution to avoid loops!) /* if (received.text.toLowerCase() !== 'stop') { // Basic stop condition const echoText = `You said: ${received.text}`; try { await infobipClient.channels.whatsapp.send({ type: 'text', from: INFOBIP_WHATSAPP_SENDER, to: received.from, // Send back to the original sender content: { text: echoText } }); fastify.log.info(`Echoed message back to ${received.from}`); } catch (echoError) { fastify.log.error({ err: echoError }, `Failed to echo message to ${received.from}`); } } */ } else { fastify.log.warn({ msg: 'Received webhook event is not a standard text message or missing fields', eventData: message }); } }); // Respond to Infobip quickly to acknowledge receipt reply.code(200).send({ status: 'Received' }); } else if (incomingData && incomingData.results && incomingData.results.length === 0) { fastify.log.info('Received empty results array from Infobip webhook (e.g., delivery reports).'); reply.code(200).send({ status: 'Received (empty results)' }); } else { fastify.log.warn({ msg: 'Received unexpected webhook payload structure', payload: incomingData }); reply.code(400); // Bad Request - Payload not understood return { error: 'Unexpected payload structure' }; } }); // --- Start Server Function --- // ... (rest of the code) ...
- Why Webhook Security? Anyone could potentially send data to your webhook URL. A simple shared secret provides a basic layer of protection. Strongly Recommended: Check Infobip's documentation for signature verification options. If available, implement signature verification as it is significantly more secure than a shared secret.
- Why Log the Full Payload? Infobip's webhook structure might change or contain different event types (delivery reports, seen status, non-text messages). Logging helps you understand the data you're receiving.
- Why Process
incomingData.results
Array? Infobip often sends webhook events in batches within theresults
array. - Why
reply.code(200).send()
Quickly? Webhook providers expect a fast acknowledgment (typically within a few seconds). Perform complex processing asynchronously (e.g., using message queues or background jobs) if needed, but respond to the webhook request promptly. - Why commented-out Echo Logic? Provides an example of acting on an incoming message but is commented out to prevent accidental loops during initial testing.
-
Configure Webhook in Infobip:
- Get a Public URL: During local development, use a tool like
ngrok
to expose your local server to the internet.# Install ngrok if you haven't already (https://ngrok.com/download) ngrok http 3000 # Exposes localhost:3000
ngrok
will provide a public HTTPS URL (e.g.,https://<unique-id>.ngrok.io
). Use this URL. For production, you must use your deployed server's actual public domain/IP address and ensure it's configured for HTTPS. - Add Webhook URL in Infobip Portal:
- Log in to the Infobip Portal.
- Navigate to
Channels and Numbers
->WhatsApp
. - Select your registered sender number.
- Find the section for configuring webhooks or inbound message URLs (often called
Forward messages to URL
or similar). - Enter your public webhook URL:
https://<your-public-url>/infobip-webhook
- (Optional but Recommended) If Infobip has a field for a custom header or query parameter for the secret, configure it there (e.g., Header
X-Webhook-Secret
with valuea_very_secret_string_you_should_change
). Otherwise, you might need to append it as a query parameter:https://<your-public-url>/infobip-webhook?secret=a_very_secret_string_you_should_change
. Again, prioritize signature verification if available over this method. - Save the configuration.
- Get a Public URL: During local development, use a tool like
-
Test Receiving: Send a WhatsApp message from your personal phone to your registered Infobip WhatsApp sender number. Check your Fastify application's console logs. You should see the
Received request on /infobip-webhook
message, followed by the payload details and theProcessed incoming message
log.
5. Error Handling, Logging, and Retry Mechanisms
- Error Handling Strategy:
- Use
try...catch
blocks for asynchronous operations (like API calls). - Validate inputs early to prevent unnecessary processing or API calls.
- Log errors with sufficient context (error object, relevant request data).
- Return meaningful HTTP status codes and error messages to the client (for the
/send
endpoint) or respond appropriately to the webhook provider (200 OK for successful receipt, 4xx/5xx for errors). - Centralized error handling can be implemented using Fastify's
setErrorHandler
for more complex applications.
- Use
- Logging:
- Fastify's built-in Pino logger (
logger: true
) is excellent and performant. - Log key events: Server start, successful/failed API calls, incoming webhooks, processed messages, critical errors.
- Use structured logging (JSON format) – Pino does this by default – which makes logs easier to parse and analyze with tools like Datadog, Splunk, or ELK stack.
- Adjust log levels (e.g.,
info
for general operations,warn
for potential issues,error
for failures) based on environment (more verbose in dev, less in prod).
- Fastify's built-in Pino logger (
- Retry Mechanisms:
- Sending messages: If an Infobip API call fails due to transient network issues or temporary Infobip errors (like 5xx status codes), implement a retry strategy. Libraries like
async-retry
can help. Use exponential backoff (wait longer between each retry) to avoid overwhelming the API. - Receiving webhooks: Retries are typically handled by the sender (Infobip). Ensure your webhook endpoint is reliable and responds quickly. If processing fails after acknowledging the webhook (sending 200 OK), you'll need internal retry mechanisms (e.g., using a message queue like RabbitMQ or Kafka) to re-process the job later. For this simple guide, we are not implementing explicit retries.
- Sending messages: If an Infobip API call fails due to transient network issues or temporary Infobip errors (like 5xx status codes), implement a retry strategy. Libraries like
6. Database Schema and Data Layer (Conceptual)
While this guide uses an in-memory array (receivedMessages
), a production application requires a persistent database.
-
Why? To store message history, track conversation states, manage user data, and prevent data loss on server restarts.
-
Choice: PostgreSQL, MongoDB, MySQL, Redis (depending on needs).
-
Example Schema (Conceptual - PostgreSQL):
CREATE TABLE whatsapp_messages ( id SERIAL PRIMARY KEY, infobip_message_id VARCHAR(255) UNIQUE, -- Infobip's ID for deduplication direction VARCHAR(10) NOT NULL, -- 'inbound' or 'outbound' sender_number VARCHAR(20) NOT NULL, recipient_number VARCHAR(20) NOT NULL, message_body TEXT, status VARCHAR(50), -- e.g., PENDING, SENT, DELIVERED, FAILED, RECEIVED infobip_status_details JSONB, -- Store full status object from Infobip created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); -- Index for querying by numbers or status CREATE INDEX idx_messages_sender ON whatsapp_messages(sender_number); CREATE INDEX idx_messages_recipient ON whatsapp_messages(recipient_number); CREATE INDEX idx_messages_status ON whatsapp_messages(status);
-
Data Layer: Use an ORM (like Prisma, Sequelize, TypeORM) or a query builder (like Knex.js) to interact with the database, handle migrations, and abstract SQL queries. Replace the
receivedMessages.push()
logic with database insertion calls within the webhook handler. Similarly, log sent messages to the database after a successful API call in the/send
route.
7. Security Features
- Input Validation: Already implemented basic checks. Use Fastify's schema validation for robust protection against malformed requests. Sanitize any data received from users (like message text) before storing or displaying it elsewhere to prevent XSS attacks (though less critical in a backend-only context unless reflecting data).
- Webhook Security: Implemented basic secret check. Prioritize Infobip's signature verification if available. Ensure your webhook endpoint uses HTTPS.
- API Key Security: Use environment variables (
dotenv
) and.gitignore
to keep keys out of source control. Use tools like HashiCorp Vault or cloud provider secret managers for production environments. - Rate Limiting: Protect your API endpoints (especially
/send
) from abuse. Usefastify-rate-limit
:npm install fastify-rate-limit
// src/server.js // ... imports ... const rateLimit = require('fastify-rate-limit'); // ... fastify initialization ... // Register rate limiting plugin fastify.register(rateLimit, { max: 100, // Max requests per window timeWindow: '1 minute' // Time window }); // ... routes ...
- Common Vulnerabilities: Be mindful of Node.js security best practices (e.g., keeping dependencies updated with
npm audit
, avoiding insecure code patterns). Use security linters. - HTTPS: Always use HTTPS for production deployment to encrypt data in transit. Use a reverse proxy like Nginx or Caddy, or deploy to platforms that handle TLS termination.
8. Handling Special Cases
- Message Types: This guide focuses on text messages. Infobip supports images, documents, templates, buttons, etc. Adapt the
messagePayload
in the/send
route and the parsing logic in the/infobip-webhook
according to the Infobip WhatsApp API documentation. - Delivery Reports: Infobip can send webhook events for message status updates (e.g., delivered, seen). Your webhook handler needs to identify these different event types (based on the payload structure) and update message status in your database accordingly.
- Rate Limits (Infobip): Be aware of Infobip's sending limits. Implement throttling or queuing if sending bulk messages. Handle
429 Too Many Requests
errors gracefully. - Free Trial Limitations: Remember, free trial accounts can typically only send messages to the phone number used during registration. Sending from your personal number to the Infobip sender should work for testing webhooks.
- Character Limits/Encoding: WhatsApp messages have limits. Be mindful of message length. Ensure correct text encoding (usually UTF-8).
- Opt-ins/Opt-outs: Respect WhatsApp policies regarding user consent. Implement logic to handle STOP/START commands received via webhook.
9. Performance Optimizations
- Fastify: Already a high-performance framework.
- Infobip Client: The SDK client is initialized once and reused, which is efficient.
- Asynchronous Operations: Node.js and Fastify are inherently asynchronous. Ensure you are not blocking the event loop with synchronous code, especially in route handlers.
- Database Queries: Optimize database interactions (indexing, efficient queries) if using a database.
- Payload Size: Keep request/response payloads concise where possible.
- Caching: Not directly applicable for simple message sending/receiving, but could be used for frequently accessed configuration or user data if integrated.
- Load Testing: For high-throughput applications, use tools like
k6
,autocannon
, orwrk
to simulate load and identify bottlenecks in your/send
endpoint or webhook processing.
10. Monitoring, Observability, and Analytics
- Logging: Already set up with Fastify/Pino. Ship logs to a centralized logging platform for analysis.
- Health Checks: Add a simple health check endpoint:
Monitoring tools can ping this endpoint to verify service availability.
// src/server.js // ... routes ... // Health check endpoint fastify.get('/health', async (request, reply) => { // Optional: Add checks for database connectivity or Infobip API status return { status: 'ok', timestamp: new Date().toISOString() }; }); // ... start function ...
- Metrics: Use libraries like
fastify-metrics
to expose application metrics (request counts, latency, error rates) in Prometheus format. Visualize these metrics using Grafana or similar tools. - Error Tracking: Integrate services like Sentry or Bugsnag using their Node.js SDKs to capture, aggregate, and alert on runtime errors.