Build a production-ready WhatsApp messaging system using Sinch Conversation API with Fastify and Node.js. This comprehensive guide demonstrates how to integrate Sinch WhatsApp API with Fastify's high-performance framework to create a scalable messaging backend that handles two-way communication, webhook verification, and OAuth2 authentication.
By completing this tutorial, you'll deploy a fully functional Fastify application that sends and receives WhatsApp messages through Sinch's unified Conversation API, with production-grade security, error handling, and best practices for customer notifications, support, and marketing automation.
Project Overview and Goals
Goal: Create a Node.js backend service using Fastify that integrates with the Sinch Conversation API to send outgoing WhatsApp messages and receive incoming messages via webhooks.
Problem Solved: Automate WhatsApp Business messaging reliably and at scale, eliminating the complexities of direct WhatsApp Business API integration while leveraging Fastify's performance.
Technologies:
Node.js: Asynchronous JavaScript runtime. Node.js v20 LTS (Maintenance LTS until April 30, 2026) or v22 LTS (Active LTS until October 21, 2025) recommended as of October 2025. Node.js v18 (Hydrogen) reached End-of-Life on April 30, 2025. Node.js v24 entered Current release on May 6, 2025 and will enter Active LTS on October 20, 2026. (Node.js Release Schedule | endoflife.date/nodejs, accessed October 2025)
Fastify: High-performance, low-overhead web framework for Node.js. Fastify v5 is the current stable version as of October 2025, with v4 maintained on the 4.x branch. Fastify delivers exceptional performance with benchmarks showing 47,001 requests/second in standardized "Hello World" tests. (npm: fastify | Fastify Benchmarks, accessed October 2025)
Sinch Conversation API: Unified API for multiple messaging channels, including WhatsApp. Use the @sinch/sdk-core Node.js SDK (official Sinch SDK supporting OAuth2 authentication). The SDK supports all current Sinch APIs including Conversation API. (npm: @sinch/sdk-core | GitHub: sinch-sdk-node, accessed October 2025)
dotenv: Module to load environment variables from a .env file.
ngrok: Tool to expose local servers to the internet for webhook testing during development. Free ngrok tiers have limitations like session expiry and changing URLs; use paid tiers or alternatives like cloudflared for stable development URLs.
Prerequisites:
Install Node.js v20 LTS or v22 LTS and npm (or yarn).
Create a Sinch account with Conversation API access.
Provision a WhatsApp Sender ID via your Sinch account.
Set up a WhatsApp-enabled phone number for testing.
Install ngrok globally or make it available via npx.
Set up Environment Variables (.env):
Create a .env file in the root directory. Populate this with credentials from the Sinch dashboard later.
.env
# .env
# Sinch API Credentials (Get from Sinch Dashboard → Access Keys)
SINCH_PROJECT_ID=
SINCH_KEY_ID=
SINCH_KEY_SECRET=
# Sinch Application Credentials (Get from Sinch Dashboard → Apps → Your App)
SINCH_APP_ID=
SINCH_APPLICATION_SECRET= # Used for Webhook Signature Validation
# Sinch WhatsApp Sender ID (The phone number provisioned by Sinch)
WHATSAPP_SENDER_ID= # e.g., 447537400000 (use E.164 format without +)
# Server Configuration
PORT=8000
HOST=0.0.0.0 # Bind to all network interfaces
# ngrok URL (Update this when ngrok starts)
NGROK_URL=
.env keeps sensitive credentials separate from code and makes configuration environment-specific. Never commit your .env file to version control.
(Optional) Configure tsconfig.json (if using TypeScript):
Create a tsconfig.json file in the root directory.
JSON
// tsconfig.json{"compilerOptions":{"target":"ES2020",// Or newer"module":"CommonJS","outDir":"./dist","rootDir":"./src","strict":true,"esModuleInterop":true,"skipLibCheck":true,"forceConsistentCasingInFileNames":true,"moduleResolution":"node","sourceMap":true,"resolveJsonModule":true},"include":["src/**/*"],"exclude":["node_modules","**/*.spec.ts"]}
(Optional) Add Run Scripts to package.json:
JSON
// package.json (add under "scripts")"scripts":{"build":"tsc",// If using TypeScript"start":"node dist/server.js",// If using TypeScript build step"dev:js":"nodemon src/server.js",// If using JavaScript"dev:ts":"nodemon --watch 'src/**/*.ts' --exec 'ts-node' src/server.ts"// If using TypeScript},
Choose the appropriate dev script based on whether you're using JavaScript or TypeScript.
2. Implementing Sinch WhatsApp Messaging Service
Create a dedicated service file to handle Sinch Conversation API interactions and WhatsApp message sending.
TypeScript
// src/services/sinch.ts (or sinch.js)importSinchClientfrom"@sinch/sdk-core";
import dotenv from'dotenv';
dotenv.config(); // Load environment variables// Ensure required environment variables are setconst requiredEnvVars = [
'SINCH_PROJECT_ID',
'SINCH_KEY_ID',
'SINCH_KEY_SECRET',
'SINCH_APP_ID',
'WHATSAPP_SENDER_ID',
];
for (const envVar of requiredEnvVars) {
if (!process.env[envVar]) {
console.error(`Error: Environment variable ${envVar} is not set.`);
process.exit(1); // Exit if critical config is missing
}
}
// Initialize Sinch Client with OAuth2 authentication// The Conversation API uses OAuth2 authentication with projectId, keyId, and keySecret// Reference: https://www.npmjs.com/package/@sinch/sdk-coreconst sinchClient = newSinchClient({
projectId: process.env.SINCH_PROJECT_ID!,
keyId: process.env.SINCH_KEY_ID!,
keySecret: process.env.SINCH_KEY_SECRET!,
});
/**
* Sends a WhatsApp text message via the Sinch Conversation API.
* @param recipientPhoneNumber – The recipient's phone number in E.164 format (e.g., 12223334444).
* @param textContent – The content of the text message.
* @returns The Sinch message ID if successful.
* @throws Error if the message sending fails.
*/exportconst sendWhatsAppTextMessage = async (
recipientPhoneNumber: string,
textContent: string
): Promise<string | undefined> => {
console.log(`Attempting to send message to: ${recipientPhoneNumber}`);
try {
const response = await sinchClient.conversationApi.message.send({
app_id: process.env.SINCH_APP_ID!,
message: {
channel_identity: {
channel: 'WHATSAPP',
identity: process.env.WHATSAPP_SENDER_ID!, // Your Sinch WhatsApp Number
},
content: {
text_message: {
text: textContent,
},
},
},
recipient: {
contact_id: `waid:${recipientPhoneNumber}`, // Use 'waid:' prefix for WhatsApp ID
},
});
console.log('Sinch send message response:', response);
if (response.accepted_message_id) {
console.log(`Message successfully sent with ID: ${response.accepted_message_id}`);
return response.accepted_message_id;
} else {
// Handle cases where the message might be rejected immediately (though less common)console.error('Message sending accepted but no message ID returned.', response);
thrownewError('Sinch API accepted the request but did not return a message ID.');
}
} catch (error: any) {
console.error('Error sending WhatsApp message via Sinch:');
// Log detailed error information if availableif (error.response) {
console.error('Status:', error.response.status);
console.error('Headers:', error.response.headers);
console.error('Data:', error.response.data);
} else {
console.error('Error message:', error.message);
}
// Rethrow or handle the error appropriately for the calling functionthrownewError(`Failed to send WhatsApp message: ${error.message}`);
}
};
// Add functions for other message types (images, templates) as needed.// Example: sendWhatsAppTemplateMessage, sendWhatsAppImageMessage etc.
Modular architecture: Separating Sinch logic into its own service creates modular, testable code and keeps route handlers clean.
OAuth2 Authentication: The @sinch/sdk-core package uses OAuth2 authentication for the Conversation API, requiring projectId, keyId, and keySecret. This is the recommended authentication method for production applications. (Sinch SDK Core Documentation, accessed October 2025)
waid: prefix: The Sinch Conversation API requires channel-specific prefixes for contact identifiers. For WhatsApp, use waid: followed by the E.164 number. Consult the latest Sinch API documentation if you encounter issues.
Error Handling: This function logs detailed error information from the Sinch API response when available.
3. Building the Fastify API and WhatsApp Webhook Handler
Set up the Fastify server with webhook endpoints to receive incoming WhatsApp messages from Sinch.
Important: Sinch API payload structures (like request.body format in webhooks) can change. Always refer to the official Sinch Conversation API documentation for current details. Adjust the code examples based on your specific API version.
Fastify Server Setup
TypeScript
// src/server.ts (or server.js)importFastify, { FastifyRequest, FastifyReply } from'fastify';
import dotenv from'dotenv';
import webhookRoutes from'./routes/webhooks'; // Import webhook routes
dotenv.config();
const server = Fastify({
logger: true, // Enable built-in Pino logger
});
// --- Add Raw Body Access (Needed for Signature Verification) ---// This is crucial because Fastify parses the body by default *before* the handler.// Signature verification needs the *raw*, unparsed body string.// Recommended approach: Using Fastify's addContentTypeParser with parseAs: 'buffer'// This stores the raw body before JSON parsing occurs.
server.addContentTypeParser('application/json', { parseAs: 'buffer' }, function (req, body, done) {
try {
// Store raw body as string for signature verification
(req asany).rawBody = body.toString('utf8');
// Parse JSONconst json = JSON.parse(body.toString('utf8'));
done(null, json);
} catch (err: any) {
err.statusCode = 400;
done(err, undefined);
}
});
// --- Global Error Handler ---// Catches unhandled errors in routes
server.setErrorHandler((error: Error, request: FastifyRequest, reply: FastifyReply) => {
server.log.error(error); // Log the full error// Send a generic error response to the client// Avoid leaking sensitive error details in production
reply.status(500).send({
statusCode: 500,
error: 'Internal Server Error',
message: 'An unexpected error occurred.',
});
});
// --- Register Routes ---
server.register(webhookRoutes, { prefix: '/webhooks' }); // Register webhook routes under /webhooks// --- Health Check Route ---
server.get('/health', async (request, reply) => {
return { status: 'ok', timestamp: newDate().toISOString() };
});
// --- Start Server ---conststart = async () => {
try {
const port = parseInt(process.env.PORT || '8000', 10);
const host = process.env.HOST || '0.0.0.0';
await server.listen({ port, host });
server.log.info(`Server listening on http://${host}:${port}`);
server.log.info(`Webhook endpoint available at /webhooks/inbound`);
server.log.info(`Webhook status endpoint available at /webhooks/status`);
server.log.info(`Make sure ngrok forwards to this port.`);
} catch (err) {
server.log.error(err);
process.exit(1);
}
};
start();
Raw Body Access Implementation:
The code above uses Fastify's addContentTypeParser with parseAs: 'buffer' to capture the raw request body before JSON parsing. This approach is recommended for webhook signature verification in Fastify v5. The raw body is stored in request.rawBody for use in signature verification. (Fastify Content Type Parser Documentation, accessed October 2025)
Alternative Approaches:
fastify-raw-body plugin: Install and configure fastify-raw-body plugin for simpler setup
Route-specific parser: Apply custom content type parser only to webhook routes
Webhook Route Implementation
This route handles incoming POST requests from Sinch when a user sends a message to your WhatsApp number.
TypeScript
// src/routes/webhooks.ts (or webhooks.js)import { FastifyInstance, FastifyRequest, FastifyReply } from'fastify';
import crypto from'crypto';
import { sendWhatsAppTextMessage } from'../services/sinch'; // Import the sending function// --- Type Definitions (Recommended Best Practice) ---// Define interfaces for expected Sinch payloads for better type safety.// These are examples; refer to Sinch docs for exact structures.interfaceSinchMessageContent {
text_message?: { text: string };
// Add other content types (media_message, etc.) as needed
}
interfaceSinchContactMessage {
sender_id?: string; // e.g., "waid:1234567890"// Add other fields if needed
}
interfaceSinchInboundMessage {
contact_message?: SinchContactMessage;
content?: SinchMessageContent;
// Add other fields from the inbound message payload
}
interfaceSinchDeliveryReport {
message_id?: string;
status?: 'DELIVERED' | 'FAILED' | 'QUEUED' | 'SENT' | 'READ'; // Example statusesreason?: string;
// Add other fields from the delivery report payload
}
interfaceSinchWebhookPayload {
event: 'MESSAGE_INBOUND' | 'MESSAGE_DELIVERY' | string; // Allow other event typesapp_id?: string;
timestamp?: string; // ISO 8601 formatmessage?: SinchInboundMessage;
message_delivery_report?: SinchDeliveryReport;
// Add other potential top-level fields
}
// --- Helper: Verify Sinch Webhook Signature ---// IMPORTANT: Use the "Application Secret" from your Sinch App settings// Sinch uses HMAC-SHA256 with the following string-to-sign format:// rawBody + timestamp// Reference: https://developers.sinch.com/docs/conversation/callbacksconst verifySinchSignature = (request: FastifyRequest & { rawBody?: string }): boolean => {
const sinchSignature = request.headers['x-sinch-webhook-signature'] asstring;
const timestamp = request.headers['x-sinch-webhook-timestamp'] asstring;
const applicationSecret = process.env.SINCH_APPLICATION_SECRET;
const requestBody = request.rawBody; // Use the raw body stringif (!sinchSignature || !timestamp || !applicationSecret) {
request.log.warn('Missing signature headers or application secret for webhook validation.');
returnfalse;
}
// Check if rawBody is availableif (typeof requestBody !== 'string') {
request.log.error('Raw request body is not available for signature verification. Ensure it is populated before this handler.');
returnfalse;
}
try {
// 1. Construct the string to sign: raw_request_body + timestampconst stringToSign = requestBody + timestamp;
// 2. Calculate the HMAC-SHA256 hashconst hmac = crypto.createHmac('sha256', applicationSecret);
const calculatedSignature = hmac.update(stringToSign).digest('base64');
// 3. Compare calculated signature with the one from the header// Handle comma-separated multiple signatures (if Sinch sends multiple)const signatures = sinchSignature.split(',');
const receivedSignature = signatures[0].trim();
if (calculatedSignature !== receivedSignature) {
request.log.warn(`Signature mismatch. Calculated: ${calculatedSignature}, Received: ${receivedSignature}`);
returnfalse;
}
// Optional: Check timestamp validity to prevent replay attacks// Sinch timestamp is ISO 8601 string (e.g., "2023-10-27T10:00:00.123Z")letrequestTimestampSeconds: number;
try {
const requestDate = newDate(timestamp);
if (isNaN(requestDate.getTime())) {
thrownewError('Invalid timestamp format');
}
requestTimestampSeconds = Math.floor(requestDate.getTime() / 1000);
} catch (e: any) {
request.log.warn(`Could not parse timestamp '${timestamp}': ${e.message}`);
returnfalse;
}
const currentTimestampSeconds = Math.floor(Date.now() / 1000);
const fiveMinutesInSeconds = 5 * 60;
if (Math.abs(currentTimestampSeconds - requestTimestampSeconds) > fiveMinutesInSeconds) {
request.log.warn(`Timestamp validation failed. Request time: ${requestTimestampSeconds}, Current time: ${currentTimestampSeconds}. Difference > 5 minutes.`);
returnfalse;
}
request.log.info('Sinch webhook signature verified successfully.');
returntrue;
} catch (error: any) {
request.log.error(`Error during signature verification: ${error.message}`);
returnfalse;
}
};
// --- Define Webhook Routes ---asyncfunctionwebhookRoutes(fastify: FastifyInstance) {
// Endpoint for INCOMING messages from users
fastify.post('/inbound', async (request: FastifyRequest, reply: FastifyReply) => {
fastify.log.info('Received POST request on /webhooks/inbound');
fastify.log.info({ headers: request.headers }, 'Request Headers');
fastify.log.info({ body: request.body }, 'Parsed Request Body');
// 1. Verify Signature (SECURITY CRITICAL)if (!verifySinchSignature(request asFastifyRequest & { rawBody?: string })) {
fastify.log.error('Invalid webhook signature received.');
return reply.status(403).send({ error: 'Forbidden', message: 'Invalid signature.' });
}
// 2. Process the webhook payloadconst payload = request.bodyasSinchWebhookPayload;
if (payload.event === 'MESSAGE_INBOUND' && payload.message) {
const message = payload.message;
const sender = message.contact_message?.sender_id; // Extract sender ID (e.g., waid:12223334444)const content = message.content?.text_message?.text; // Extract text contentif (sender && content) {
fastify.log.info(`Received message from ${sender}: "${content}"`);
// Example: Simple echo bot logictry {
// Extract phone number from sender_id (remove 'waid:')const recipientPhoneNumber = sender.replace(/^waid:/, '');
awaitsendWhatsAppTextMessage(recipientPhoneNumber, `You said: "${content}"`);
fastify.log.info(`Sent echo reply to ${recipientPhoneNumber}`);
} catch (error) {
fastify.log.error(error, 'Failed to send echo reply');
}
} else {
fastify.log.warn('Received MESSAGE_INBOUND but could not parse sender or content.');
}
} else {
fastify.log.warn(`Received unhandled webhook event type on /inbound: ${payload.event ?? 'Unknown'}`);
}
// 3. Acknowledge Receipt// Always send a 200 OK quickly to Sinch to prevent retries.
reply.status(200).send({ message: 'Webhook received successfully.' });
});
// Endpoint for message STATUS updates (Delivery reports, failures etc.)
fastify.post('/status', async (request: FastifyRequest, reply: FastifyReply) => {
fastify.log.info('Received POST request on /webhooks/status');
fastify.log.info({ headers: request.headers }, 'Request Headers');
fastify.log.info({ body: request.body }, 'Parsed Request Body');
// 1. Verify Signature (SECURITY CRITICAL)if (!verifySinchSignature(request asFastifyRequest & { rawBody?: string })) {
fastify.log.error('Invalid webhook signature received for status update.');
return reply.status(403).send({ error: 'Forbidden', message: 'Invalid signature.' });
}
// 2. Process the status payloadconst payload = request.bodyasSinchWebhookPayload;
if (payload.event === 'MESSAGE_DELIVERY' && payload.message_delivery_report) {
const report = payload.message_delivery_report;
const status = report.status;
const messageId = report.message_id;
const reason = report.reason;
if (messageId && status) {
fastify.log.info(`Received delivery status for message ${messageId}: ${status}${reason ? `(Reason: ${reason})` : ''}`);
// Update message status in your database here
} else {
fastify.log.warn('Received MESSAGE_DELIVERY status but messageId or status was missing.', report);
}
} else {
fastify.log.warn(`Received unhandled event type on /status: ${payload.event ?? 'Unknown'}`);
}
// 3. Acknowledge Receipt
reply.status(200).send({ message: 'Status webhook received successfully.' });
});
}
exportdefault webhookRoutes;
Webhook Security Implementation:
The signature verification implementation follows Sinch's webhook security specifications. Sinch signs webhook requests using HMAC-SHA256 with the following components:
String to sign:rawBody + timestamp (concatenation)
Secret: Application Secret from Sinch App settings
Headers:x-sinch-webhook-signature (Base64-encoded signature) and x-sinch-webhook-timestamp (ISO 8601 timestamp)
Payload Processing: The structure of request.body depends on the Sinch event. Always consult the latest Sinch Conversation API documentation. Use TypeScript interfaces (like SinchWebhookPayload) for type safety.
Acknowledgement: Respond with a 200 OK quickly. Process asynchronously if needed.
/inbound vs /status: Logic for MESSAGE_DELIVERY events is in the /status endpoint. /inbound focuses on MESSAGE_INBOUND.
4. Configuring Sinch WhatsApp API Credentials
Obtain Sinch Credentials:
Project ID, Key ID, Key Secret: Navigate to your Sinch Customer Dashboard → Access Keys. Create a new key if needed. Copy these values into your .env file. These credentials are used for OAuth2 authentication with the Conversation API.
App ID, Application Secret: Go to Apps → Create App (or select an existing one).
Enable the "Conversation API" capability.
Under Conversation API settings, you'll find the App ID.
Under Webhook Settings for the Conversation API, you'll find or generate the Application Secret. This is used for signature validation. Copy both into your .env file.
WhatsApp Sender ID: This is the WhatsApp number provisioned by Sinch for your account. Find this in your Conversation API or Numbers section of the dashboard. Enter it in E.164 format (e.g., 447537400000) without the + in your .env file (WHATSAPP_SENDER_ID).
Configure Webhooks in Sinch Dashboard:
Tell Sinch where to send incoming messages and status updates. This requires a publicly accessible URL. During development, use ngrok.
Start ngrok: Open a new terminal window and run:
Bash
# Replace 8000 with the PORT defined in your .env file if different
ngrok http 8000
Copy the ngrok URL:ngrok will display a forwarding URL like https://<random-string>.ngrok-free.app. Copy this HTTPS URL. Free tier URLs change frequently.
Update .env: Paste the ngrok URL into the NGROK_URL variable in your .env file (optional, but helpful for reference).
.env
NGROK_URL=https://<random-string>.ngrok-free.app
Set Webhooks in Sinch: Go back to your Sinch App settings → Conversation API → Webhook Settings.
Target URL (for Inbound Messages): Enter your ngrok URL followed by the inbound webhook path: https://<random-string>.ngrok-free.app/webhooks/inbound
Delivery Report URL (for Status Updates): Enter your ngrok URL followed by the status webhook path: https://<random-string>.ngrok-free.app/webhooks/status
Select Event Types: Ensure MESSAGE_INBOUND is selected for the Target URL. Ensure MESSAGE_DELIVERY (and potentially others like MESSAGE_SUBMIT_FAILED, MESSAGE_REJECTED) are selected for the Delivery Report URL.
Generate/Configure Webhook Secret: This is your SINCH_APPLICATION_SECRET. Make sure it matches what's in your .env file.
Save the webhook configuration.
Review Environment Variables: Double-check your .env file ensures all values obtained from Sinch are correctly copied.
5. Production-Ready Error Handling and Logging
Error Handling:
We've set up a global error handler in server.ts using server.setErrorHandler.
Use try...catch blocks in specific functions (e.g., sendWhatsAppTextMessage) for graceful handling of expected errors.
Return appropriate HTTP status codes for webhook validation failures (403 Forbidden) or other request errors.
Logging:
Fastify's built-in pino logger (logger: true) is excellent for production use. Pino is one of the fastest JSON loggers for Node.js.
Use fastify.log.info(), fastify.log.warn(), fastify.log.error() contextually.
Configure log levels and destinations appropriately for production.
Retry Mechanisms (for Outgoing Messages):
Sinch API calls might fail transiently. Implement retries with exponential backoff for outgoing messages using libraries like async-retry.
Example (Conceptual within sinch.ts):
TypeScript
import retry from'async-retry';
// Inside sendWhatsAppTextMessage, wrap the API call:try {
const response = awaitretry(
async (bail, attempt) => {
console.log(`Attempt ${attempt} to send message…`);
try {
const apiResponse = await sinchClient.conversationApi.message.send({
app_id: process.env.SINCH_APP_ID!,
message: { /* … message content … */ },
recipient: { /* … recipient info … */ },
});
if (!apiResponse.accepted_message_id) {
console.warn('Sinch accepted but no message ID. Not retrying immediately.');
return apiResponse;
}
return apiResponse;
} catch (error: any) {
// Don't retry on client errors (4xx)if (error.response && error.response.status >= 400 && error.response.status < 500) {
console.error(`Client-side error from Sinch (attempt ${attempt}). Not retrying. Status: ${error.response.status}`, error.response.data);
bail(error); // Stop retryingthrow error;
}
// For server errors (5xx) or network errors, throw to trigger retryconsole.warn(`Sinch API call failed (attempt ${attempt}). Retrying…`, error.message);
throw error;
}
},
{
retries: 3,
factor: 2,
minTimeout: 1000,
maxTimeout: 5000,
onRetry: (error, attempt) => {
console.warn(`Retry attempt ${attempt} failed: ${error.message}`);
},
}
);
if (response.accepted_message_id) {
console.log(`Message successfully sent with ID: ${response.accepted_message_id}`);
return response.accepted_message_id;
} else {
console.warn('Message sending succeeded after retries, but no message ID was returned.');
returnundefined;
}
} catch (error: any) {
console.error('Failed to send message after all retries:', error.message);
if (error.response) {
console.error('Final failure details:', error.response.data);
}
thrownewError(`Failed to send WhatsApp message after retries: ${error.message}`);
}
Best Practices: Implement retries carefully. Avoid retrying operations that are not idempotent or errors that indicate a permanent failure (like invalid credentials or invalid recipient phone numbers – 4xx errors). Always log retry attempts clearly with attempt numbers and error details.
6. Testing Your Sinch WhatsApp Integration
Start Your Fastify Server:
Bash
npm run dev:ts # or npm run dev:js
Start ngrok (if not already running):
Bash
ngrok http 8000
Configure Sinch Webhooks: Ensure your ngrok URL is correctly set in the Sinch dashboard webhook settings.
Send a Test Message:
Send a WhatsApp message from your test phone to your Sinch WhatsApp number.
Check your Fastify server logs. You should see:
Incoming webhook request on /webhooks/inbound
Signature verification success
Parsed message content
Echo reply sent
Check Delivery Status:
Monitor the /webhooks/status endpoint logs for delivery reports from Sinch.
You should see status updates (QUEUED, SENT, DELIVERED, READ) for your sent messages.
Test Error Scenarios:
Try sending to an invalid phone number
Temporarily disable ngrok to test Sinch retry behavior
Test with an incorrect webhook secret to verify signature validation
7. Deploying Your Fastify WhatsApp Application to Production
HTTPS Required: In production, your webhook endpoints must use HTTPS. Use a proper SSL/TLS certificate (Let's Encrypt, AWS Certificate Manager, etc.). Sinch will not send webhooks to insecure HTTP endpoints in production.
Environment Variables: Use environment-specific .env files or a secrets manager (AWS Secrets Manager, HashiCorp Vault, Google Secret Manager) to manage credentials securely.
Database Integration: Store message history, conversation state, and 24-hour window tracking in a database (PostgreSQL, MongoDB, MySQL). The 24-hour customer service window is critical for WhatsApp compliance – you can only send free-form messages within 24 hours of the last user message; outside this window, you must use approved template messages.
Rate Limiting: Implement rate limiting on your API endpoints using @fastify/rate-limit to prevent abuse and comply with WhatsApp Business Platform messaging limits.
Message Queue: For high-volume applications, use a message queue (Redis, RabbitMQ, AWS SQS) to handle webhook processing asynchronously and prevent blocking.
Monitoring and Alerting: Set up monitoring (Datadog, New Relic, Sentry) to track error rates, webhook failures, message delivery rates, and API latency.
Load Balancing: Deploy multiple Fastify instances behind a load balancer (Nginx, AWS ALB, Google Cloud Load Balancer) for high availability.
Webhook Retries: Sinch will retry failed webhook deliveries. Ensure your endpoint is idempotent – processing the same webhook multiple times should not cause duplicate actions. Use message IDs to track processed webhooks.
Template Messages: For conversations outside the 24-hour window, you must use pre-approved WhatsApp template messages. Register templates in your Sinch dashboard and use the Sinch SDK's template message sending methods.
Conclusion: Your Sinch WhatsApp API Integration is Ready
You've successfully built a production-ready WhatsApp messaging integration using Node.js, Fastify, and the Sinch Conversation API. Your Fastify application handles secure webhook signature verification using HMAC-SHA256, sends WhatsApp messages using OAuth2-authenticated API calls, processes incoming messages with proper error handling, and implements best practices for logging and retry logic.
The Sinch Conversation API provides a unified, modern interface for WhatsApp Business Platform communication with OAuth2 authentication and robust webhook security. Your Fastify application leverages high-performance request handling (47,001 req/sec) with minimal overhead, making it suitable for production-scale WhatsApp messaging.
As you scale your integration, consider implementing database storage for conversation history and 24-hour window tracking, message queueing with Redis or RabbitMQ for high-volume scenarios, rate limiting with @fastify/rate-limit, monitoring with Sentry or Datadog, and proper HTTPS deployment with load balancing for high availability. Your WhatsApp integration is ready to power customer communications, notifications, and engagement at scale.
Use the sendWhatsAppTextMessage function in the provided sinch.ts service file. This function utilizes the Sinch Conversation API to send text messages via WhatsApp. It requires the recipient's phone number in E.164 format and the message content as input. The function handles the interaction with the Sinch SDK and returns the Sinch message ID upon successful delivery.
The Sinch Conversation API is a unified messaging API that allows developers to integrate various messaging channels, including WhatsApp, into their applications. It simplifies the process of sending and receiving messages, managing contacts, and handling other communication tasks, providing a more streamlined experience than directly integrating with each messaging platform's API.
Fastify is a high-performance, low-overhead web framework for Node.js. Its speed and efficiency make it well-suited for handling real-time communication, like receiving webhook notifications from Sinch. It offers a robust routing system for managing incoming messages and a flexible plugin system for extensibility.
ngrok is primarily used during development to create a publicly accessible URL for your local Fastify server. This allows Sinch to send webhook notifications to your application even while it's running locally. Consider alternatives or paid tiers for production for more stable URLs.
Yes, the Sinch Conversation API supports various message types, including text messages, images, and interactive templates. The provided example focuses on text messages, but you can extend the sinch.ts service file to include functions for sending other message types using the appropriate Sinch SDK methods.
In your Sinch app dashboard, go to "Conversation API" -> "Webhook Settings". Enter your public URL (e.g., ngrok URL) followed by /webhooks/inbound for incoming messages and /webhooks/status for delivery reports. Select the relevant event types (MESSAGE_INBOUND, MESSAGE_DELIVERY) for each endpoint.
The waid: prefix is a specific identifier used by the Sinch Conversation API to denote WhatsApp contacts. It's followed by the recipient's phone number in E.164 format (e.g., waid:12223334444). It ensures that the message is routed correctly through the Sinch system to the intended WhatsApp user.
Webhook security is paramount. Use the verifySinchSignature function in the example code. This function compares the signature provided by Sinch in the x-sinch-signature header with your calculated signature using the raw request body, timestamp, and your Sinch "Application Secret." This prevents unauthorized requests.
The x-sinch-signature header contains a cryptographic signature generated by Sinch for each webhook request. This signature, along with the x-sinch-timestamp header, allows your application to verify the authenticity of the webhook and ensure it originated from Sinch, preventing malicious actors from sending fake requests.
The provided code includes a global error handler in server.ts to catch unhandled exceptions within routes. Specific error handling within the webhook route and the sendWhatsAppTextMessage function provides contextual error logging and responses. Always respond with a 200 OK to Sinch webhooks even if internal processing fails to prevent retries.
The Sinch webhook signature verification process typically uses the raw, unparsed request body as part of the signed data. Accessing this raw body is crucial for verifying the integrity of the request. The provided Fastify server setup includes instructions and examples to ensure request.rawBody is populated correctly for use in signature verification. This can be accomplished using middleware or adjusting content parser settings
SINCH_PROJECT_ID, SINCH_KEY_ID, SINCH_KEY_SECRET, SINCH_APP_ID, SINCH_APPLICATION_SECRET (for webhooks), and WHATSAPP_SENDER_ID (your provisioned number). These are loaded from the .env file in the provided example, keeping credentials secure.
The article recommends a modular structure. Create a dedicated service file (sinch.ts) to encapsulate interactions with the Sinch SDK. Define separate route handlers in Fastify (routes/webhooks.ts) to manage incoming webhooks and outgoing message requests. Use environment variables (.env) for configuration.
Build a production-ready WhatsApp messaging system using Sinch Conversation API with Fastify and Node.js. This comprehensive guide demonstrates how to integrate Sinch WhatsApp API with Fastify's high-performance framework to create a scalable messaging backend that handles two-way communication, webhook verification, and OAuth2 authentication.
By completing this tutorial, you'll deploy a fully functional Fastify application that sends and receives WhatsApp messages through Sinch's unified Conversation API, with production-grade security, error handling, and best practices for customer notifications, support, and marketing automation.
Project Overview and Goals
@sinch/sdk-coreNode.js SDK (official Sinch SDK supporting OAuth2 authentication). The SDK supports all current Sinch APIs including Conversation API. (npm: @sinch/sdk-core | GitHub: sinch-sdk-node, accessed October 2025).envfile.ngroktiers have limitations like session expiry and changing URLs; use paid tiers or alternatives likecloudflaredfor stable development URLs.ngrokglobally or make it available vianpx.System Architecture
1. Setting up Your Fastify WhatsApp Project
Initialize your Node.js project with Fastify and configure the Sinch SDK for WhatsApp integration.
Create Project Directory: Open your terminal and create a new directory for the project.
mkdir sinch-whatsapp-fastify cd sinch-whatsapp-fastifyInitialize npm: Run
npm init -yto create yourpackage.jsonfile.Install Dependencies: Install Fastify, the Sinch SDK, and
dotenv.Install Development Dependencies (Optional but Recommended): Install TypeScript tooling for a better development experience.
npm install -D typescript @types/node ts-node nodemon @fastify/typescript # Install TypeBox later for validation if needed # npm install @sinclair/typebox @fastify/type-provider-typeboxCreate Project Structure: Organize your files with this structure.
Configure
.gitignore: Create a.gitignorefile in the root directory to prevent committing sensitive information.Set up Environment Variables (
.env): Create a.envfile in the root directory. Populate this with credentials from the Sinch dashboard later..envkeeps sensitive credentials separate from code and makes configuration environment-specific. Never commit your.envfile to version control.(Optional) Configure
tsconfig.json(if using TypeScript): Create atsconfig.jsonfile in the root directory.// tsconfig.json { "compilerOptions": { "target": "ES2020", // Or newer "module": "CommonJS", "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "moduleResolution": "node", "sourceMap": true, "resolveJsonModule": true }, "include": ["src/**/*"], "exclude": ["node_modules", "**/*.spec.ts"] }(Optional) Add Run Scripts to
package.json:// package.json (add under "scripts") "scripts": { "build": "tsc", // If using TypeScript "start": "node dist/server.js", // If using TypeScript build step "dev:js": "nodemon src/server.js", // If using JavaScript "dev:ts": "nodemon --watch 'src/**/*.ts' --exec 'ts-node' src/server.ts" // If using TypeScript },Choose the appropriate
devscript based on whether you're using JavaScript or TypeScript.2. Implementing Sinch WhatsApp Messaging Service
Create a dedicated service file to handle Sinch Conversation API interactions and WhatsApp message sending.
// src/services/sinch.ts (or sinch.js) import SinchClient from "@sinch/sdk-core"; import dotenv from 'dotenv'; dotenv.config(); // Load environment variables // Ensure required environment variables are set const requiredEnvVars = [ 'SINCH_PROJECT_ID', 'SINCH_KEY_ID', 'SINCH_KEY_SECRET', 'SINCH_APP_ID', 'WHATSAPP_SENDER_ID', ]; for (const envVar of requiredEnvVars) { if (!process.env[envVar]) { console.error(`Error: Environment variable ${envVar} is not set.`); process.exit(1); // Exit if critical config is missing } } // Initialize Sinch Client with OAuth2 authentication // The Conversation API uses OAuth2 authentication with projectId, keyId, and keySecret // Reference: https://www.npmjs.com/package/@sinch/sdk-core const sinchClient = new SinchClient({ projectId: process.env.SINCH_PROJECT_ID!, keyId: process.env.SINCH_KEY_ID!, keySecret: process.env.SINCH_KEY_SECRET!, }); /** * Sends a WhatsApp text message via the Sinch Conversation API. * @param recipientPhoneNumber – The recipient's phone number in E.164 format (e.g., 12223334444). * @param textContent – The content of the text message. * @returns The Sinch message ID if successful. * @throws Error if the message sending fails. */ export const sendWhatsAppTextMessage = async ( recipientPhoneNumber: string, textContent: string ): Promise<string | undefined> => { console.log(`Attempting to send message to: ${recipientPhoneNumber}`); try { const response = await sinchClient.conversationApi.message.send({ app_id: process.env.SINCH_APP_ID!, message: { channel_identity: { channel: 'WHATSAPP', identity: process.env.WHATSAPP_SENDER_ID!, // Your Sinch WhatsApp Number }, content: { text_message: { text: textContent, }, }, }, recipient: { contact_id: `waid:${recipientPhoneNumber}`, // Use 'waid:' prefix for WhatsApp ID }, }); console.log('Sinch send message response:', response); if (response.accepted_message_id) { console.log(`Message successfully sent with ID: ${response.accepted_message_id}`); return response.accepted_message_id; } else { // Handle cases where the message might be rejected immediately (though less common) console.error('Message sending accepted but no message ID returned.', response); throw new Error('Sinch API accepted the request but did not return a message ID.'); } } catch (error: any) { console.error('Error sending WhatsApp message via Sinch:'); // Log detailed error information if available if (error.response) { console.error('Status:', error.response.status); console.error('Headers:', error.response.headers); console.error('Data:', error.response.data); } else { console.error('Error message:', error.message); } // Rethrow or handle the error appropriately for the calling function throw new Error(`Failed to send WhatsApp message: ${error.message}`); } }; // Add functions for other message types (images, templates) as needed. // Example: sendWhatsAppTemplateMessage, sendWhatsAppImageMessage etc.@sinch/sdk-corepackage uses OAuth2 authentication for the Conversation API, requiringprojectId,keyId, andkeySecret. This is the recommended authentication method for production applications. (Sinch SDK Core Documentation, accessed October 2025)waid:prefix: The Sinch Conversation API requires channel-specific prefixes for contact identifiers. For WhatsApp, usewaid:followed by the E.164 number. Consult the latest Sinch API documentation if you encounter issues.3. Building the Fastify API and WhatsApp Webhook Handler
Set up the Fastify server with webhook endpoints to receive incoming WhatsApp messages from Sinch.
Important: Sinch API payload structures (like
request.bodyformat in webhooks) can change. Always refer to the official Sinch Conversation API documentation for current details. Adjust the code examples based on your specific API version.Fastify Server Setup
// src/server.ts (or server.js) import Fastify, { FastifyRequest, FastifyReply } from 'fastify'; import dotenv from 'dotenv'; import webhookRoutes from './routes/webhooks'; // Import webhook routes dotenv.config(); const server = Fastify({ logger: true, // Enable built-in Pino logger }); // --- Add Raw Body Access (Needed for Signature Verification) --- // This is crucial because Fastify parses the body by default *before* the handler. // Signature verification needs the *raw*, unparsed body string. // Recommended approach: Using Fastify's addContentTypeParser with parseAs: 'buffer' // This stores the raw body before JSON parsing occurs. server.addContentTypeParser('application/json', { parseAs: 'buffer' }, function (req, body, done) { try { // Store raw body as string for signature verification (req as any).rawBody = body.toString('utf8'); // Parse JSON const json = JSON.parse(body.toString('utf8')); done(null, json); } catch (err: any) { err.statusCode = 400; done(err, undefined); } }); // --- Global Error Handler --- // Catches unhandled errors in routes server.setErrorHandler((error: Error, request: FastifyRequest, reply: FastifyReply) => { server.log.error(error); // Log the full error // Send a generic error response to the client // Avoid leaking sensitive error details in production reply.status(500).send({ statusCode: 500, error: 'Internal Server Error', message: 'An unexpected error occurred.', }); }); // --- Register Routes --- server.register(webhookRoutes, { prefix: '/webhooks' }); // Register webhook routes under /webhooks // --- Health Check Route --- server.get('/health', async (request, reply) => { return { status: 'ok', timestamp: new Date().toISOString() }; }); // --- Start Server --- const start = async () => { try { const port = parseInt(process.env.PORT || '8000', 10); const host = process.env.HOST || '0.0.0.0'; await server.listen({ port, host }); server.log.info(`Server listening on http://${host}:${port}`); server.log.info(`Webhook endpoint available at /webhooks/inbound`); server.log.info(`Webhook status endpoint available at /webhooks/status`); server.log.info(`Make sure ngrok forwards to this port.`); } catch (err) { server.log.error(err); process.exit(1); } }; start();Raw Body Access Implementation:
The code above uses Fastify's
addContentTypeParserwithparseAs: 'buffer'to capture the raw request body before JSON parsing. This approach is recommended for webhook signature verification in Fastify v5. The raw body is stored inrequest.rawBodyfor use in signature verification. (Fastify Content Type Parser Documentation, accessed October 2025)Alternative Approaches:
fastify-raw-bodyplugin for simpler setupWebhook Route Implementation
This route handles incoming POST requests from Sinch when a user sends a message to your WhatsApp number.
// src/routes/webhooks.ts (or webhooks.js) import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import crypto from 'crypto'; import { sendWhatsAppTextMessage } from '../services/sinch'; // Import the sending function // --- Type Definitions (Recommended Best Practice) --- // Define interfaces for expected Sinch payloads for better type safety. // These are examples; refer to Sinch docs for exact structures. interface SinchMessageContent { text_message?: { text: string }; // Add other content types (media_message, etc.) as needed } interface SinchContactMessage { sender_id?: string; // e.g., "waid:1234567890" // Add other fields if needed } interface SinchInboundMessage { contact_message?: SinchContactMessage; content?: SinchMessageContent; // Add other fields from the inbound message payload } interface SinchDeliveryReport { message_id?: string; status?: 'DELIVERED' | 'FAILED' | 'QUEUED' | 'SENT' | 'READ'; // Example statuses reason?: string; // Add other fields from the delivery report payload } interface SinchWebhookPayload { event: 'MESSAGE_INBOUND' | 'MESSAGE_DELIVERY' | string; // Allow other event types app_id?: string; timestamp?: string; // ISO 8601 format message?: SinchInboundMessage; message_delivery_report?: SinchDeliveryReport; // Add other potential top-level fields } // --- Helper: Verify Sinch Webhook Signature --- // IMPORTANT: Use the "Application Secret" from your Sinch App settings // Sinch uses HMAC-SHA256 with the following string-to-sign format: // rawBody + timestamp // Reference: https://developers.sinch.com/docs/conversation/callbacks const verifySinchSignature = (request: FastifyRequest & { rawBody?: string }): boolean => { const sinchSignature = request.headers['x-sinch-webhook-signature'] as string; const timestamp = request.headers['x-sinch-webhook-timestamp'] as string; const applicationSecret = process.env.SINCH_APPLICATION_SECRET; const requestBody = request.rawBody; // Use the raw body string if (!sinchSignature || !timestamp || !applicationSecret) { request.log.warn('Missing signature headers or application secret for webhook validation.'); return false; } // Check if rawBody is available if (typeof requestBody !== 'string') { request.log.error('Raw request body is not available for signature verification. Ensure it is populated before this handler.'); return false; } try { // 1. Construct the string to sign: raw_request_body + timestamp const stringToSign = requestBody + timestamp; // 2. Calculate the HMAC-SHA256 hash const hmac = crypto.createHmac('sha256', applicationSecret); const calculatedSignature = hmac.update(stringToSign).digest('base64'); // 3. Compare calculated signature with the one from the header // Handle comma-separated multiple signatures (if Sinch sends multiple) const signatures = sinchSignature.split(','); const receivedSignature = signatures[0].trim(); if (calculatedSignature !== receivedSignature) { request.log.warn(`Signature mismatch. Calculated: ${calculatedSignature}, Received: ${receivedSignature}`); return false; } // Optional: Check timestamp validity to prevent replay attacks // Sinch timestamp is ISO 8601 string (e.g., "2023-10-27T10:00:00.123Z") let requestTimestampSeconds: number; try { const requestDate = new Date(timestamp); if (isNaN(requestDate.getTime())) { throw new Error('Invalid timestamp format'); } requestTimestampSeconds = Math.floor(requestDate.getTime() / 1000); } catch (e: any) { request.log.warn(`Could not parse timestamp '${timestamp}': ${e.message}`); return false; } const currentTimestampSeconds = Math.floor(Date.now() / 1000); const fiveMinutesInSeconds = 5 * 60; if (Math.abs(currentTimestampSeconds - requestTimestampSeconds) > fiveMinutesInSeconds) { request.log.warn(`Timestamp validation failed. Request time: ${requestTimestampSeconds}, Current time: ${currentTimestampSeconds}. Difference > 5 minutes.`); return false; } request.log.info('Sinch webhook signature verified successfully.'); return true; } catch (error: any) { request.log.error(`Error during signature verification: ${error.message}`); return false; } }; // --- Define Webhook Routes --- async function webhookRoutes(fastify: FastifyInstance) { // Endpoint for INCOMING messages from users fastify.post('/inbound', async (request: FastifyRequest, reply: FastifyReply) => { fastify.log.info('Received POST request on /webhooks/inbound'); fastify.log.info({ headers: request.headers }, 'Request Headers'); fastify.log.info({ body: request.body }, 'Parsed Request Body'); // 1. Verify Signature (SECURITY CRITICAL) if (!verifySinchSignature(request as FastifyRequest & { rawBody?: string })) { fastify.log.error('Invalid webhook signature received.'); return reply.status(403).send({ error: 'Forbidden', message: 'Invalid signature.' }); } // 2. Process the webhook payload const payload = request.body as SinchWebhookPayload; if (payload.event === 'MESSAGE_INBOUND' && payload.message) { const message = payload.message; const sender = message.contact_message?.sender_id; // Extract sender ID (e.g., waid:12223334444) const content = message.content?.text_message?.text; // Extract text content if (sender && content) { fastify.log.info(`Received message from ${sender}: "${content}"`); // Example: Simple echo bot logic try { // Extract phone number from sender_id (remove 'waid:') const recipientPhoneNumber = sender.replace(/^waid:/, ''); await sendWhatsAppTextMessage(recipientPhoneNumber, `You said: "${content}"`); fastify.log.info(`Sent echo reply to ${recipientPhoneNumber}`); } catch (error) { fastify.log.error(error, 'Failed to send echo reply'); } } else { fastify.log.warn('Received MESSAGE_INBOUND but could not parse sender or content.'); } } else { fastify.log.warn(`Received unhandled webhook event type on /inbound: ${payload.event ?? 'Unknown'}`); } // 3. Acknowledge Receipt // Always send a 200 OK quickly to Sinch to prevent retries. reply.status(200).send({ message: 'Webhook received successfully.' }); }); // Endpoint for message STATUS updates (Delivery reports, failures etc.) fastify.post('/status', async (request: FastifyRequest, reply: FastifyReply) => { fastify.log.info('Received POST request on /webhooks/status'); fastify.log.info({ headers: request.headers }, 'Request Headers'); fastify.log.info({ body: request.body }, 'Parsed Request Body'); // 1. Verify Signature (SECURITY CRITICAL) if (!verifySinchSignature(request as FastifyRequest & { rawBody?: string })) { fastify.log.error('Invalid webhook signature received for status update.'); return reply.status(403).send({ error: 'Forbidden', message: 'Invalid signature.' }); } // 2. Process the status payload const payload = request.body as SinchWebhookPayload; if (payload.event === 'MESSAGE_DELIVERY' && payload.message_delivery_report) { const report = payload.message_delivery_report; const status = report.status; const messageId = report.message_id; const reason = report.reason; if (messageId && status) { fastify.log.info(`Received delivery status for message ${messageId}: ${status} ${reason ? `(Reason: ${reason})` : ''}`); // Update message status in your database here } else { fastify.log.warn('Received MESSAGE_DELIVERY status but messageId or status was missing.', report); } } else { fastify.log.warn(`Received unhandled event type on /status: ${payload.event ?? 'Unknown'}`); } // 3. Acknowledge Receipt reply.status(200).send({ message: 'Status webhook received successfully.' }); }); } export default webhookRoutes;Webhook Security Implementation:
The signature verification implementation follows Sinch's webhook security specifications. Sinch signs webhook requests using HMAC-SHA256 with the following components:
rawBody + timestamp(concatenation)x-sinch-webhook-signature(Base64-encoded signature) andx-sinch-webhook-timestamp(ISO 8601 timestamp)The verification includes timestamp validation (5-minute window) to prevent replay attacks. (Sinch Conversation API Callbacks Documentation, accessed October 2025)
request.bodydepends on the Sinch event. Always consult the latest Sinch Conversation API documentation. Use TypeScript interfaces (likeSinchWebhookPayload) for type safety.200 OKquickly. Process asynchronously if needed./inboundvs/status: Logic forMESSAGE_DELIVERYevents is in the/statusendpoint./inboundfocuses onMESSAGE_INBOUND.4. Configuring Sinch WhatsApp API Credentials
Obtain Sinch Credentials:
.envfile. These credentials are used for OAuth2 authentication with the Conversation API.App ID.Application Secret. This is used for signature validation. Copy both into your.envfile.447537400000) without the+in your.envfile (WHATSAPP_SENDER_ID).Configure Webhooks in Sinch Dashboard:
ngrok.# Replace 8000 with the PORT defined in your .env file if different ngrok http 8000ngrokwill display a forwarding URL likehttps://<random-string>.ngrok-free.app. Copy this HTTPS URL. Free tier URLs change frequently..env: Paste the ngrok URL into theNGROK_URLvariable in your.envfile (optional, but helpful for reference).https://<random-string>.ngrok-free.app/webhooks/inboundhttps://<random-string>.ngrok-free.app/webhooks/statusMESSAGE_INBOUNDis selected for the Target URL. EnsureMESSAGE_DELIVERY(and potentially others likeMESSAGE_SUBMIT_FAILED,MESSAGE_REJECTED) are selected for the Delivery Report URL.SINCH_APPLICATION_SECRET. Make sure it matches what's in your.envfile.Review Environment Variables: Double-check your
.envfile ensures all values obtained from Sinch are correctly copied.5. Production-Ready Error Handling and Logging
Error Handling:
server.tsusingserver.setErrorHandler.try...catchblocks in specific functions (e.g.,sendWhatsAppTextMessage) for graceful handling of expected errors.403 Forbidden) or other request errors.Logging:
pinologger (logger: true) is excellent for production use. Pino is one of the fastest JSON loggers for Node.js.fastify.log.info(),fastify.log.warn(),fastify.log.error()contextually.Retry Mechanisms (for Outgoing Messages):
async-retry.sinch.ts):import retry from 'async-retry'; // Inside sendWhatsAppTextMessage, wrap the API call: try { const response = await retry( async (bail, attempt) => { console.log(`Attempt ${attempt} to send message…`); try { const apiResponse = await sinchClient.conversationApi.message.send({ app_id: process.env.SINCH_APP_ID!, message: { /* … message content … */ }, recipient: { /* … recipient info … */ }, }); if (!apiResponse.accepted_message_id) { console.warn('Sinch accepted but no message ID. Not retrying immediately.'); return apiResponse; } return apiResponse; } catch (error: any) { // Don't retry on client errors (4xx) if (error.response && error.response.status >= 400 && error.response.status < 500) { console.error(`Client-side error from Sinch (attempt ${attempt}). Not retrying. Status: ${error.response.status}`, error.response.data); bail(error); // Stop retrying throw error; } // For server errors (5xx) or network errors, throw to trigger retry console.warn(`Sinch API call failed (attempt ${attempt}). Retrying…`, error.message); throw error; } }, { retries: 3, factor: 2, minTimeout: 1000, maxTimeout: 5000, onRetry: (error, attempt) => { console.warn(`Retry attempt ${attempt} failed: ${error.message}`); }, } ); if (response.accepted_message_id) { console.log(`Message successfully sent with ID: ${response.accepted_message_id}`); return response.accepted_message_id; } else { console.warn('Message sending succeeded after retries, but no message ID was returned.'); return undefined; } } catch (error: any) { console.error('Failed to send message after all retries:', error.message); if (error.response) { console.error('Final failure details:', error.response.data); } throw new Error(`Failed to send WhatsApp message after retries: ${error.message}`); }6. Testing Your Sinch WhatsApp Integration
Start Your Fastify Server:
npm run dev:ts # or npm run dev:jsStart ngrok (if not already running):
Configure Sinch Webhooks: Ensure your ngrok URL is correctly set in the Sinch dashboard webhook settings.
Send a Test Message:
/webhooks/inboundCheck Delivery Status:
/webhooks/statusendpoint logs for delivery reports from Sinch.Test Error Scenarios:
7. Deploying Your Fastify WhatsApp Application to Production
HTTPS Required: In production, your webhook endpoints must use HTTPS. Use a proper SSL/TLS certificate (Let's Encrypt, AWS Certificate Manager, etc.). Sinch will not send webhooks to insecure HTTP endpoints in production.
Environment Variables: Use environment-specific
.envfiles or a secrets manager (AWS Secrets Manager, HashiCorp Vault, Google Secret Manager) to manage credentials securely.Database Integration: Store message history, conversation state, and 24-hour window tracking in a database (PostgreSQL, MongoDB, MySQL). The 24-hour customer service window is critical for WhatsApp compliance – you can only send free-form messages within 24 hours of the last user message; outside this window, you must use approved template messages.
Rate Limiting: Implement rate limiting on your API endpoints using
@fastify/rate-limitto prevent abuse and comply with WhatsApp Business Platform messaging limits.Message Queue: For high-volume applications, use a message queue (Redis, RabbitMQ, AWS SQS) to handle webhook processing asynchronously and prevent blocking.
Monitoring and Alerting: Set up monitoring (Datadog, New Relic, Sentry) to track error rates, webhook failures, message delivery rates, and API latency.
Load Balancing: Deploy multiple Fastify instances behind a load balancer (Nginx, AWS ALB, Google Cloud Load Balancer) for high availability.
Webhook Retries: Sinch will retry failed webhook deliveries. Ensure your endpoint is idempotent – processing the same webhook multiple times should not cause duplicate actions. Use message IDs to track processed webhooks.
Template Messages: For conversations outside the 24-hour window, you must use pre-approved WhatsApp template messages. Register templates in your Sinch dashboard and use the Sinch SDK's template message sending methods.
Conclusion: Your Sinch WhatsApp API Integration is Ready
You've successfully built a production-ready WhatsApp messaging integration using Node.js, Fastify, and the Sinch Conversation API. Your Fastify application handles secure webhook signature verification using HMAC-SHA256, sends WhatsApp messages using OAuth2-authenticated API calls, processes incoming messages with proper error handling, and implements best practices for logging and retry logic.
The Sinch Conversation API provides a unified, modern interface for WhatsApp Business Platform communication with OAuth2 authentication and robust webhook security. Your Fastify application leverages high-performance request handling (47,001 req/sec) with minimal overhead, making it suitable for production-scale WhatsApp messaging.
As you scale your integration, consider implementing database storage for conversation history and 24-hour window tracking, message queueing with Redis or RabbitMQ for high-volume scenarios, rate limiting with
@fastify/rate-limit, monitoring with Sentry or Datadog, and proper HTTPS deployment with load balancing for high availability. Your WhatsApp integration is ready to power customer communications, notifications, and engagement at scale.Additional Resources