code examples
code examples
Sinch WhatsApp API Integration with Fastify and Node.js: Complete 2025 Guide
Build production-ready WhatsApp messaging with Sinch Conversation API, Fastify, and Node.js. Complete guide with OAuth2 authentication, webhook security, and real-time messaging.
Send WhatsApp Messages with Sinch API in Node.js using Fastify
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-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) - dotenv: Module to load environment variables from a
.envfile. - ngrok: Tool to expose local servers to the internet for webhook testing during development. Free
ngroktiers have limitations like session expiry and changing URLs; use paid tiers or alternatives likecloudflaredfor 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
ngrokglobally or make it available vianpx. - Understand Node.js fundamentals, RESTful APIs, and webhook concepts.
- Outcome: Your Fastify WhatsApp integration will:
- Send text messages via WhatsApp using the Sinch Conversation API.
- Receive incoming WhatsApp messages via a secure webhook endpoint.
- Validate incoming webhook requests using HMAC-SHA256 signature verification.
- Log message events and handle errors gracefully with production-ready patterns.
System Architecture
+-----------------+ +----------------------+ +-----------------+ +----------+
| Your Application|------>| Fastify Backend |<----->| Sinch API |<----->| WhatsApp |
| (e.g., Frontend)| | (Node.js, Sinch SDK)| | (Conversation API)| | Platform |
+-----------------+ +----------------------+ +-----------------+ +----------+
^ | Webhook Call ^ | Message Delivery
| v | v
+-----+----------------------+-----+
| Incoming Message Notification
| (WhatsApp → Sinch → ngrok → Fastify)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.
bashmkdir sinch-whatsapp-fastify cd sinch-whatsapp-fastify -
Initialize npm: Run
npm init -yto create yourpackage.jsonfile.bashnpm init -y -
Install Dependencies: Install Fastify, the Sinch SDK, and
dotenv.bashnpm install fastify @sinch/sdk-core dotenv -
Install Development Dependencies (Optional but Recommended): Install TypeScript tooling for a better development experience.
bashnpm install -D typescript @types/node ts-node nodemon @fastify/typescript # Install TypeBox later for validation if needed # npm install @sinclair/typebox @fastify/type-provider-typebox -
Create Project Structure: Organize your files with this structure.
textsinch-whatsapp-fastify/ ├── src/ │ ├── server.ts # (or server.js if not using TypeScript) │ ├── routes/ │ │ └── webhooks.ts # (or webhooks.js) │ └── services/ │ └── sinch.ts # (or sinch.js) – Logic for Sinch interaction ├── .env # Environment variables (DO NOT COMMIT) ├── .gitignore # Git ignore file ├── package.json ├── tsconfig.json # (If using TypeScript) └── node_modules/ -
Configure
.gitignore: Create a.gitignorefile in the root directory to prevent committing sensitive information.text# .gitignore # Dependencies node_modules/ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* # Environment variables .env .env.* !.env.example # Build output dist/ build/ # OS generated files .DS_Store Thumbs.db -
Set up Environment Variables (
.env): Create a.envfile in the root directory. Populate this with credentials from the Sinch dashboard later.dotenv# .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=.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.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
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.- Modular architecture: Separating Sinch logic into its own service creates modular, testable code and keeps route handlers clean.
- OAuth2 Authentication: The
@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.- 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
// 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 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-bodyplugin 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.
// 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:
- String to sign:
rawBody + timestamp(concatenation) - Secret: Application Secret from Sinch App settings
- Headers:
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)
- Payload Processing: The structure of
request.bodydepends on the Sinch event. Always consult the latest Sinch Conversation API documentation. Use TypeScript interfaces (likeSinchWebhookPayload) for type safety. - Acknowledgement: Respond with a
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:
- 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
.envfile. 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.envfile.
- 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.envfile (WHATSAPP_SENDER_ID).
- 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
-
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:
ngrokwill display a forwarding URL likehttps://<random-string>.ngrok-free.app. Copy this HTTPS URL. Free tier URLs change frequently. - Update
.env: Paste the ngrok URL into theNGROK_URLvariable in your.envfile (optional, but helpful for reference).dotenvNGROK_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_INBOUNDis selected for the Target URL. EnsureMESSAGE_DELIVERY(and potentially others likeMESSAGE_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.envfile. - Save the webhook configuration.
- Target URL (for Inbound Messages): Enter your ngrok URL followed by the inbound webhook path:
- Tell Sinch where to send incoming messages and status updates. This requires a publicly accessible URL. During development, use
-
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:
- We've set up a global error handler in
server.tsusingserver.setErrorHandler. - Use
try...catchblocks 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.
- We've set up a global error handler in
-
Logging:
- Fastify's built-in
pinologger (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. - Log key events: server start, requests, outgoing messages (attempts, success, failure), webhook verification results, errors. Include relevant IDs (message IDs, sender IDs).
- Configure log levels and destinations appropriately for production.
- Fastify's built-in
-
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):typescriptimport 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}`); } - 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.
- Sinch API calls might fail transiently. Implement retries with exponential backoff for outgoing messages using libraries like
6. Testing Your Sinch WhatsApp Integration
-
Start Your Fastify Server:
bashnpm run dev:ts # or npm run dev:js -
Start ngrok (if not already running):
bashngrok 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
- Incoming webhook request on
-
Check Delivery Status:
- Monitor the
/webhooks/statusendpoint logs for delivery reports from Sinch. - You should see status updates (QUEUED, SENT, DELIVERED, READ) for your sent messages.
- Monitor the
-
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
.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
Frequently Asked Questions
how to send whatsapp message with node js and sinch
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.
what is sinch conversation api used for
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.
why use fastify for whatsapp integration
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.
when to use ngrok with sinch whatsapp
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.
can i send different message types with sinch
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.
how to set up sinch whatsapp webhooks
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.
what is the waid prefix used for in sinch
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.
how to secure sinch whatsapp webhooks
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.
what is the purpose of the x-sinch-signature header
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.
how to handle sinch webhook errors
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.
why does sinch webhook need raw body
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
what environment variables are needed for sinch whatsapp
`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.
how to structure a node js sinch whatsapp integration
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.