This guide provides a complete walkthrough for building a robust Node.js application using the Fastify framework to send and receive WhatsApp messages via the Sinch Conversation API. We will cover everything from initial project setup and core messaging functionality to advanced topics like security, error handling, deployment, and verification.
By the end of this tutorial, you will have a functional application capable of handling two-way WhatsApp communication, complete with best practices for production environments. This integration enables businesses to programmatically interact with customers on WhatsApp for notifications, support, marketing, and more.
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: Provides a reliable and scalable way to automate WhatsApp communication, overcoming the complexities of direct WhatsApp Business API integration.
- Technologies:
- Node.js: Asynchronous JavaScript runtime (v18+ recommended).
- Fastify: High-performance, low-overhead web framework for Node.js.
- Sinch Conversation API: Unified API for multiple messaging channels, including WhatsApp. We'll use the
@sinch/sdk-core
Node.js SDK. - dotenv: Module to load environment variables from a
.env
file. - ngrok: Tool to expose local servers to the internet for webhook testing during development. (Note: Free
ngrok
tiers have limitations like session expiry and changing URLs; consider paid tiers or alternatives likecloudflared
for more stable development URLs if needed.)
- Prerequisites:
- Node.js and npm (or yarn) installed.
- A Sinch account with access to the Conversation API.
- A provisioned WhatsApp Sender ID via your Sinch account.
- A WhatsApp-enabled phone number for testing.
ngrok
installed globally or available vianpx
.- Basic understanding of Node.js, APIs, and webhooks.
- Outcome: A Fastify application that can:
- Send text messages via WhatsApp using the Sinch API.
- Receive incoming WhatsApp messages via a secure webhook endpoint.
- Validate incoming webhook requests using Sinch signatures.
- Log message events and handle errors gracefully.
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 the Project
Let's initialize our Node.js project using Fastify.
-
Create Project Directory: Open your terminal and create a new directory for the project.
mkdir sinch-whatsapp-fastify cd sinch-whatsapp-fastify
-
Initialize npm: This creates a
package.json
file.npm init -y
-
Install Dependencies: We need Fastify, the Sinch SDK, and
dotenv
.npm install fastify @sinch/sdk-core dotenv
-
Install Development Dependencies (Optional but Recommended): For better development experience, especially if using TypeScript later.
npm install -D typescript @types/node ts-node nodemon @fastify/typescript # If using TypeScript, you might also install TypeBox later for validation # npm install @sinclair/typebox @fastify/type-provider-typebox
-
Create Project Structure: Set up a basic structure for clarity.
sinch-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.gitignore
file in the root directory to prevent committing sensitive information and unnecessary files.# .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.env
file in the root directory. We will populate this with credentials obtained from the Sinch dashboard later.# .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=
- Why
.env
? It keeps sensitive credentials separate from code and makes configuration environment-specific. Remember to never commit your.env
file to version control.
- Why
-
(Optional) Configure
tsconfig.json
(if using TypeScript): Create atsconfig.json
file 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
dev
script based on whether you're using JavaScript or TypeScript.
2. Implementing Core Functionality (Sinch Service)
Let's create a dedicated service file to handle interactions with the Sinch SDK.
// 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
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.
- Why this structure? Separating Sinch logic into its own service makes the code modular, easier to test, and keeps the route handlers clean.
waid:
prefix: The Sinch Conversation API often requires a specific prefix for contact identifiers depending on the channel. For WhatsApp,waid:
followed by the E.164 number is standard. Check the latest Sinch API documentation if you encounter issues.- Error Handling: The function includes basic error logging, including trying to log details from the Sinch API response if available.
3. Building the API Layer (Fastify Server and Webhook Route)
Now, let's set up the Fastify server and the webhook endpoint to receive incoming messages.
Important Caveat: Sinch API payload structures (like the format of request.body
in webhooks) can change. Always refer to the official Sinch Conversation API documentation for the most up-to-date details. The code examples provided here might require adjustments based on the specific API version you are using.
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.
// Option 1: Using a plugin (if available/needed)
// import fastifyRawBody from 'fastify-raw-body';
// server.register(fastifyRawBody, {
// field: 'rawBody', // field name accessible in request object
// global: false, // apply only to routes that need it
// encoding: 'utf8', // set it to utf8 to get string right away
// runFirst: true // ensure it runs before Fastify's parser
// });
// Option 2: Using Fastify's built-in capabilities (v3/v4+)
// Add a content type parser to store the raw body before JSON parsing
// Note: This approach might require careful ordering or route-specific config.
// server.addContentTypeParser('application/json', { parseAs: 'string' }, function (req, body, done) {
// try {
// req.rawBody = body; // Store raw body
// const json = JSON.parse(body as string); // Parse JSON
// done(null, json);
// } catch (err: any) {
// err.statusCode = 400;
// done(err, undefined);
// }
// });
// **For this guide, we'll assume rawBody is made available via one of these methods.**
// **The verifySinchSignature function will expect `request.rawBody`.**
// --- 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();
Webhook Route Implementation
This route will handle 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
// This function now assumes request.rawBody contains the raw request body string.
// Ensure rawBody is populated correctly in server.ts (see comments there).
const verifySinchSignature = (request: FastifyRequest & { rawBody?: string }): boolean => {
const sinchSignature = request.headers['x-sinch-signature'] as string;
const timestamp = request.headers['x-sinch-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.');
// If Sinch *does* sign the stringified JSON (less common), you might revert to:
// const requestBody = JSON.stringify(request.body);
// But this needs confirmation based on Sinch's signing method.
return false;
}
// 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
if (calculatedSignature !== sinchSignature) {
request.log.warn(`Signature mismatch. Calculated: ${calculatedSignature}, Received: ${sinchSignature}`);
// Log parts of the stringToSign for debugging if needed, be careful with sensitive data
// request.log.debug(`String signed: ${stringToSign.substring(0, 100)}... Timestamp: ${timestamp}`);
return false;
}
// Optional: Check timestamp validity to prevent replay attacks
// Sinch timestamp is usually ISO 8601 string (e.g., ""2023-10-27T10:00:00.123Z"")
// Or sometimes Unix epoch seconds string. Adapt parsing as needed.
let requestTimestampSeconds: number;
try {
// Attempt parsing as ISO 8601 date string first
const requestDate = new Date(timestamp);
if (isNaN(requestDate.getTime())) {
// If not a valid date string, try parsing as Unix epoch seconds
requestTimestampSeconds = parseInt(timestamp, 10);
if (isNaN(requestTimestampSeconds)) {
throw new Error('Timestamp is not a valid ISO 8601 date string or Unix epoch seconds.');
}
} else {
requestTimestampSeconds = Math.floor(requestDate.getTime() / 1000);
}
} catch (e: any) {
request.log.warn(`Could not parse timestamp '${timestamp}': ${e.message}`);
return false; // Invalid timestamp format
}
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 (epoch sec): ${requestTimestampSeconds}, Current time (epoch sec): ${currentTimestampSeconds}. Difference > 5 minutes.`);
return false; // Or handle as potentially stale but valid signature if needed
}
request.log.info('Sinch webhook signature verified successfully.');
return true;
};
// --- 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');
// Avoid logging raw body in production unless necessary for debugging specific issues,
// as it might contain sensitive information. Log parsed body instead if needed.
fastify.log.info({ headers: request.headers }, 'Request Headers');
// fastify.log.info({ rawBody: (request as any).rawBody }, 'Raw Request Body'); // If rawBody is attached
fastify.log.info({ body: request.body }, 'Parsed Request Body');
// 1. Verify Signature (SECURITY CRITICAL)
// Cast request type assertion to include rawBody if using that approach
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
// Using the defined interface for better type checking.
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');
// Decide if you need to signal an error back to Sinch (usually 200 OK is still preferred)
}
} else {
fastify.log.warn('Received MESSAGE_INBOUND but could not parse sender or content.');
}
// Removed MESSAGE_DELIVERY handling from here - it belongs in /status
// } else if (payload.event === 'MESSAGE_DELIVERY') { ... }
} 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.
// Process the message asynchronously if needed.
reply.status(200).send({ message: 'Webhook received successfully.' });
});
// Endpoint for message STATUS updates (Delivery reports, failures etc.)
// Configure this URL in your Sinch App settings as well.
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 failed
if (messageId && status) {
fastify.log.info(`Received delivery status via /status for message ${messageId}: ${status} ${reason ? `(Reason: ${reason})` : ''}`);
// Update message status in your database here using messageId, status, and reason
// Example: await updateMessageStatus(messageId, status, undefined, reason);
} 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: The
verifySinchSignature
function is critical. It prevents attackers from sending fake requests to your endpoint. It uses theApplication Secret
from your Sinch App settings. Crucially, it now relies on accessing the raw request body (request.rawBody
), which needs to be configured inserver.ts
as noted in the comments there. Verify Sinch's exact signing method (raw body vs. stringified JSON) if issues arise. - Payload Processing: The structure of
request.body
depends on the Sinch event. Always consult the latest Sinch Conversation API documentation. Using TypeScript interfaces (likeSinchWebhookPayload
) is recommended for type safety. - Acknowledgement: Respond with a
200 OK
quickly. Process asynchronously if needed. /inbound
vs/status
: Logic forMESSAGE_DELIVERY
events is now only in the/status
endpoint to avoid redundancy./inbound
focuses onMESSAGE_INBOUND
.
4. Integrating with Sinch (Configuration and Setup)
-
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. - 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. - Screenshot/Navigation Hint: Look for sections labeled ""Access Keys"" and ""Apps"" in the main dashboard navigation. Within an App's settings, find ""Conversation API"" and ""Webhook Settings"".
- 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
).
- 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:
- You need to tell Sinch where to send incoming messages and status updates. This requires a publicly accessible URL. During development, we use
ngrok
. - Start ngrok: Open a new terminal window and run:
# 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 likehttps://<random-string>.ngrok-free.app
. Copy this HTTPS URL. Remember that free tier URLs change frequently. - Update
.env
: Paste the ngrok URL into theNGROK_URL
variable in your.env
file (optional, but helpful for reference).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. EnsureMESSAGE_DELIVERY
(and potentially others likeMESSAGE_SUBMIT_FAILED
,MESSAGE_REJECTED
) are selected for the Delivery Report URL. - Save the webhook configuration.
- Target URL (for Inbound Messages): Enter your ngrok URL followed by the inbound webhook path:
- Screenshot/Navigation Hint: In the Sinch App settings, find the ""Webhook Settings"" or similar section for the Conversation API. There should be fields for Target URL, Delivery Report URL, and event type selection.
- You need to tell Sinch where to send incoming messages and status updates. This requires a publicly accessible URL. During development, we use
-
Review Environment Variables: Double-check your
.env
file ensures all values obtained from Sinch are correctly copied.
5. Implementing Error Handling, Logging, and Retries
-
Error Handling:
- We've set up a global error handler in
server.ts
usingserver.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.
- We've set up a global error handler in
-
Logging:
- Fastify's built-in
pino
logger (logger: true
) is excellent. - 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
):import retry from 'async-retry'; // Inside sendWhatsAppTextMessage, wrap the API call: try { const response = await retry( async (bail, attempt) => { // Added attempt number console.log(`Attempt ${attempt} to send message...`); try { // Ensure sinchClient and necessary parameters are accessible here 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.'); // Decide whether to bail or treat as success without ID // bail(new Error('No message ID returned')); return apiResponse; // Return the response even without ID } 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 // Ensure bail doesn't proceed; throwing here ensures retry loop exits with the error. 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; // Throw error to signal retry } }_ { retries: 3_ // Number of retries factor: 2_ minTimeout: 1000_ maxTimeout: 5000_ onRetry: (error_ attempt) => { console.warn(`Retry attempt ${attempt} failed: ${error.message}`); }, } ); // Process successful response (even if retried) if (response.accepted_message_id) { console.log(`Message successfully sent with ID: ${response.accepted_message_id} after potential retries.`); return response.accepted_message_id; } else { console.warn('Message sending succeeded after retries, but no message ID was returned.'); return undefined; // Or handle as appropriate } } catch (error: any) { console.error('Failed to send message after all retries:', error.message); // Log final failure details if available from error object if (error.response) { console.error('Final failure details:', error.response.data); } // Rethrow the final error after retries throw new Error(`Failed to send WhatsApp message after retries: ${error.message}`); }
- Note: Implement retries carefully. Avoid retrying operations that are not idempotent or errors that indicate a permanent failure (like invalid credentials or recipient). Log retry attempts clearly.
- Sinch API calls might fail transiently. Implement retries with exponential backoff for outgoing messages using libraries like