Production-Ready WhatsApp Integration with Node.js, Express, and Sinch Conversation API
This guide provides a comprehensive walkthrough for integrating WhatsApp messaging capabilities into your Node.js application using Express and the Sinch Conversation API. We'll cover everything from initial setup to deployment, security, and monitoring, enabling you to build a robust, production-grade solution.
Note: This guide uses the current Sinch Conversation API. Some Sinch documentation might still reference older, standalone WhatsApp APIs which are deprecated or scheduled for deprecation. Always refer to the Conversation API documentation for the latest methods.
Project Overview and Goals
What We're Building:
We will build a Node.js Express application that can:
- Send WhatsApp messages: Initiate conversations using approved templates or send free-form messages within the 24-hour customer service window via the Sinch Conversation API.
- Receive WhatsApp messages: Process incoming messages and events (like delivery receipts) sent from WhatsApp users to our designated number via Sinch webhooks.
- Provide a Basic API: Expose a simple REST endpoint to trigger outgoing messages.
Problem Solved:
This integration enables businesses to leverage the ubiquity of WhatsApp for customer communication, support, notifications, and engagement directly from their backend systems, managed through a unified API (Sinch Conversation API).
Technologies Used:
- Node.js: A JavaScript runtime environment for building scalable server-side applications.
- Express: A minimal and flexible Node.js web application framework for building APIs and web applications.
- Sinch Conversation API: A unified API platform for managing conversations across multiple channels, including WhatsApp. It handles the complexities of interacting with the WhatsApp Business Platform.
- dotenv: Module to load environment variables from a
.env
file. - axios: Promise-based HTTP client for making requests to the Sinch API.
- Express built-in JSON and Raw Body Parsers: Middleware to parse incoming request bodies.
express.raw()
is specifically needed beforeexpress.json()
for the webhook route to correctly verify Sinch signatures which rely on the unmodified raw body. - crypto: Node.js built-in module for cryptographic functions (needed for webhook signature verification).
- (Optional) Prisma: A modern database toolkit for Node.js & TypeScript (for storing conversation data).
- (Optional) ngrok: A tool to expose local servers to the internet for webhook testing during development.
Prerequisites:
- Node.js and npm (or yarn): Installed on your development machine. Download Node.js
- Sinch Account: A registered Sinch account with postpay billing enabled. This is generally required for using the WhatsApp channel via the Conversation API. Confirm specific requirements with Sinch support or your account manager.
- Sinch Conversation API Access: Ensure the Conversation API is enabled for your account.
- Sinch Project and App: A project created within the Sinch Customer Dashboard, and within that project, an ""App"" configured for the Conversation API.
- Sinch Access Keys: An
Access Key ID
andAccess Secret
generated for your Conversation API App. Store the secret securely; it's only shown once. - WhatsApp Sender ID: A WhatsApp Business number provisioned and approved through Sinch. You'll need the
WhatsApp Sender ID
provided by Sinch after setup (often referred to asSender ID
,WhatsApp Bot
, orclaimed_identity
in the dashboard). This is used as theapp_id
in API calls. - (Development) ngrok: For testing webhooks locally. Download ngrok
- Basic understanding of Node.js, Express, REST APIs, and asynchronous JavaScript.
1. Setting Up the Project
Let's initialize our Node.js project and install necessary dependencies.
1.1. Create Project Directory:
mkdir sinch-whatsapp-integration
cd sinch-whatsapp-integration
1.2. Initialize npm Project:
npm init -y
This creates a package.json
file.
1.3. Install Dependencies:
npm install express dotenv axios crypto
# Or using yarn:
# yarn add express dotenv axios crypto
express
: Web framework.dotenv
: Loads environment variables from.env
.axios
: HTTP client to call the Sinch API.crypto
: Built-in Node module for webhook signature verification.- Note: We will use Express's built-in
express.json()
andexpress.raw()
middleware.body-parser
is not strictly needed unless you require features not covered by the built-ins. Crucially,express.raw()
must be configured for the webhook route beforeexpress.json()
to accessreq.rawBody
for signature verification.
1.4. Project Structure:
Create the following directory structure for better organization:
sinch-whatsapp-integration/
├── src/
│ ├── controllers/
│ │ ├── messageController.js
│ │ └── webhookController.js
│ ├── routes/
│ │ ├── messageRoutes.js
│ │ └── webhookRoutes.js
│ ├── services/
│ │ └── sinchService.js
│ ├── middleware/
│ │ └── verifySinchWebhook.js
│ └── app.js # Express app setup
├── .env # Environment variables (DO NOT COMMIT)
├── .gitignore # Git ignore file
└── package.json
1.5. Create .gitignore
:
Create a .gitignore
file in the root directory to prevent committing sensitive files and unnecessary directories:
# .gitignore
node_modules/
.env
*.log
prisma/migrations/*.sql # If using Prisma
1.6. Create .env
File:
Create a .env
file in the root directory. We will populate this with credentials obtained from Sinch later.
# .env - Fill these values later
PORT=3000
NODE_ENV=development # Set to 'production' in deployment
# Sinch Credentials
SINCH_PROJECT_ID=YOUR_SINCH_PROJECT_ID
SINCH_ACCESS_KEY_ID=YOUR_SINCH_ACCESS_KEY_ID
SINCH_ACCESS_SECRET=YOUR_SINCH_ACCESS_SECRET
WHATSAPP_SENDER_ID=YOUR_WHATSAPP_SENDER_ID_FROM_SINCH # The ID for your WhatsApp number via Sinch
# Webhook Secret (generate a strong random string)
SINCH_WEBHOOK_SECRET=YOUR_STRONG_RANDOM_SECRET_FOR_WEBHOOK_VERIFICATION
# Sinch API Region (us or eu - depends on your app config)
SINCH_REGION=us # or eu
# Optional: Database URL (if using Prisma)
# DATABASE_URL=""postgresql://user:password@host:port/database?schema=public""
# Optional: Logging Level
# LOG_LEVEL=info
Explanation:
- We use
dotenv
to manage sensitive credentials and configuration, keeping them out of source code. - The structured directories (
controllers
,routes
,services
) promote modularity and maintainability, following common Express patterns. axios
is chosen for its simplicity and widespread use for making HTTP requests.crypto
is essential for securing our webhook endpoint.
2. Implementing Core Functionality
Now, let's implement the core logic for sending and receiving messages.
2.1. Sending WhatsApp Messages via Sinch (src/services/sinchService.js
)
This service will encapsulate the logic for interacting with the Sinch Conversation API.
// src/services/sinchService.js
const axios = require('axios');
const crypto = require('crypto');
require('dotenv').config(); // Ensure environment variables are loaded
const {
SINCH_PROJECT_ID,
SINCH_ACCESS_KEY_ID,
SINCH_ACCESS_SECRET,
WHATSAPP_SENDER_ID, // Your provisioned WhatsApp Sender ID
SINCH_REGION // 'us' or 'eu'
} = process.env;
// Input validation
if (!SINCH_PROJECT_ID || !SINCH_ACCESS_KEY_ID || !SINCH_ACCESS_SECRET || !WHATSAPP_SENDER_ID || !SINCH_REGION) {
console.error(""FATAL ERROR: Missing required Sinch environment variables."");
process.exit(1); // Exit if essential config is missing
}
const CONVERSATION_API_URL_BASE = `https://${SINCH_REGION}.conversation.api.sinch.com/v1/projects/${SINCH_PROJECT_ID}`;
// --- Authentication Helper ---
/**
* Generates the Sinch Authentication Header value.
* IMPORTANT: This format MUST precisely match the current Sinch Conversation API documentation.
* Verify the stringToSign components and signature calculation against:
* https://developers.sinch.com/docs/conversation/api-reference/#authentication/application-signed-requests
*
* @param {string} method - HTTP Method (POST, GET, etc.)
* @param {string} path - Request path (e.g., /messages:send)
* @param {string} [requestBody=''] - Stringified JSON request body or empty string for GET.
* @returns {string} - The Authorization header value.
*/
const generateSinchAuthHeader = (method, path, requestBody = '') => {
const httpVerb = method.toUpperCase();
const contentMd5 = crypto.createHash('md5').update(requestBody, 'utf-8').digest('base64');
const contentType = requestBody ? 'application/json; charset=utf-8' : '';
const timestamp = new Date().toISOString();
const canonicalizedHeaders = `x-timestamp:${timestamp}`;
const canonicalizedResource = path;
const stringToSign = `${httpVerb}\n${contentMd5}\n${contentType}\n${canonicalizedHeaders}\n${canonicalizedResource}`;
// IMPORTANT: Ensure SINCH_ACCESS_SECRET is the Base64 decoded secret if the documentation requires it.
// The provided secret from the dashboard is usually already Base64 encoded.
// Double-check if Buffer.from(SECRET, 'base64') is correct based on current docs.
const signature = crypto.createHmac('sha256', Buffer.from(SINCH_ACCESS_SECRET, 'base64'))
.update(stringToSign, 'utf-8')
.digest('base64');
return `Application ${SINCH_ACCESS_KEY_ID}:${signature}`;
};
// --- Sending Logic ---
/**
* Sends a message via the Sinch Conversation API.
* @param {string} recipientPhoneNumber - E.164 formatted phone number.
* @param {object} messagePayload - The Sinch message object (e.g., { text_message: { text: 'Hello!' } }).
* @returns {Promise<object>} - The response data from Sinch API.
* @throws {Error} - If the API call fails.
*/
const sendMessage = async (recipientPhoneNumber, messagePayload) => {
const path = '/messages:send';
const url = `${CONVERSATION_API_URL_BASE}${path}`;
const method = 'POST';
// Note on Recipient Identification:
// Using `contact_id: recipientPhoneNumber` is common for WhatsApp.
// Alternatively, Sinch supports `identified_by: { channel: 'WHATSAPP', identity: recipientPhoneNumber }`.
// Consult Sinch documentation for specific use cases or if `contact_id` doesn't work as expected.
const body = {
app_id: WHATSAPP_SENDER_ID, // Use the WhatsApp Sender ID here
recipient: {
contact_id: recipientPhoneNumber
},
message: messagePayload,
channel_priority_order: [""WHATSAPP""] // Prioritize WhatsApp
};
const requestBodyString = JSON.stringify(body);
const authorization = generateSinchAuthHeader(method, path, requestBodyString);
const timestamp = new Date().toISOString(); // Use the same timestamp for header and signature
try {
console.log(`Sending message to ${recipientPhoneNumber} via Sinch...`);
const response = await axios({
method: method,
url: url,
data: requestBodyString,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'x-timestamp': timestamp,
'Authorization': authorization
}
});
console.log('Sinch API Response:', response.data);
return response.data;
} catch (error) {
console.error('Error sending message via Sinch:', error.response ? JSON.stringify(error.response.data, null, 2) : error.message);
if (error.response) {
// Attach status code for better handling upstream
error.statusCode = error.response.status;
}
throw error; // Rethrow enriched error
}
};
/**
* Sends a simple text message.
* @param {string} recipientPhoneNumber
* @param {string} text
*/
const sendTextMessage = async (recipientPhoneNumber, text) => {
const messagePayload = {
text_message: { text: text }
};
return sendMessage(recipientPhoneNumber, messagePayload);
};
/**
* Sends a pre-approved template message.
* Required when initiating a conversation or outside the 24hr window.
* Note: Parameter names (e.g., 'param1') depend on your specific template definition in Sinch/WhatsApp. Verify them there.
* @param {string} recipientPhoneNumber
* @param {string} templateId - The ID of the approved template.
* @param {string[]} [params] - Optional parameters for the template placeholders.
*/
const sendTemplateMessage = async (recipientPhoneNumber, templateId, params = []) => {
// Note: Check your template definition in the Sinch dashboard for the correct parameter names.
// They might not always be 'param1', 'param2', etc.
const parameters = params.reduce((acc, value, index) => {
// Common default, but verify against your actual template config
acc[`param${index + 1}`] = value;
return acc;
}, {});
const messagePayload = {
template_message: {
omni_template: { // Use omni_template for Conversation API
template_id: templateId,
version: ""latest"", // Or specify a version
language_code: ""en_US"", // Adjust as needed
parameters: parameters
}
}
};
return sendMessage(recipientPhoneNumber, messagePayload);
};
module.exports = {
sendMessage,
sendTextMessage,
sendTemplateMessage,
generateSinchAuthHeader // Exporting for potential reuse (e.g., testing)
};
Explanation:
- Authentication (
generateSinchAuthHeader
): Added explicit notes emphasizing the need to verify thestringToSign
format and secret handling against current Sinch documentation. sendMessage
: Added note clarifying recipient identification options (contact_id
vsidentified_by
). Corrected quotes aroundWHATSAPP
. Enriched error re-throwing. Added basic check for missing env vars.sendTemplateMessage
: Added note about verifying template parameter names. Corrected quotes aroundlatest
anden_US
.- Region: The
SINCH_REGION
variable ensures we hit the correct API endpoint (us
oreu
).
2.2. Receiving WhatsApp Messages (Webhook)
We need an endpoint to receive callbacks from Sinch when users send messages to our number or when message events occur.
2.2.1. Webhook Verification Middleware (src/middleware/verifySinchWebhook.js
)
Security is paramount. We must verify that incoming webhook requests genuinely originate from Sinch using the signature provided in the headers.
// src/middleware/verifySinchWebhook.js
const crypto = require('crypto');
require('dotenv').config();
const { SINCH_WEBHOOK_SECRET } = process.env;
/**
* Express middleware to verify Sinch webhook signatures.
* IMPORTANT: This format MUST precisely match the current Sinch Conversation API documentation
* for webhook verification. Verify the stringToSign format (`${req.rawBody}|${timestampHeader}`)
* and signature calculation against the docs.
* https://developers.sinch.com/docs/conversation/webhook-verification/
*/
const verifySinchWebhook = (req, res, next) => {
if (!SINCH_WEBHOOK_SECRET) {
console.error('CRITICAL: SINCH_WEBHOOK_SECRET is not set. Webhook verification cannot proceed. Rejecting request.');
return res.status(500).send('Webhook secret not configured');
}
const signatureHeader = req.headers['x-sinch-signature'];
const timestampHeader = req.headers['x-sinch-timestamp'];
if (!signatureHeader || !timestampHeader) {
console.error('Webhook Error: Missing Sinch signature headers (x-sinch-signature or x-sinch-timestamp)');
return res.status(400).send('Missing signature headers');
}
// Optional but Recommended: Check timestamp validity (e.g., within 5 minutes)
const requestTimestamp = parseInt(timestampHeader, 10); // Timestamp is typically seconds since epoch
const currentTimestamp = Math.floor(Date.now() / 1000);
const timestampTolerance = 300; // 5 minutes in seconds
if (isNaN(requestTimestamp) || Math.abs(currentTimestamp - requestTimestamp) > timestampTolerance) {
console.error(`Webhook Error: Timestamp validation failed. Request: ${requestTimestamp}, Current: ${currentTimestamp}, Tolerance: ${timestampTolerance}s`);
return res.status(400).send('Timestamp validation failed');
}
// CRITICAL: req.rawBody is required. Ensure the raw body parser middleware
// (e.g., express.raw({ type: 'application/json' })) is configured in app.js
// *before* the standard JSON parser (express.json()) for the webhook route.
if (!req.rawBody || req.rawBody.length === 0) {
console.error('Webhook Error: Missing req.rawBody. Ensure raw body parser is configured correctly BEFORE JSON parser for this route.');
return res.status(500).send('Internal server error: Raw body not available for verification.');
}
// IMPORTANT: Verify this format with Sinch documentation.
const stringToSign = `${req.rawBody}|${timestampHeader}`; // Raw Body + Pipe + Timestamp Header Value
try {
const expectedSignature = crypto.createHmac('sha256', SINCH_WEBHOOK_SECRET)
.update(stringToSign)
.digest('base64');
// Handle potential multiple signatures (e.g., if passed through proxies)
// Sinch documentation should specify the expected behavior. Taking the first is common.
const signatures = signatureHeader.split(',');
const providedSignature = signatures[0].trim();
if (signatures.length > 1) {
console.warn(`Multiple signatures received in x-sinch-signature header. Verifying the first one: ${providedSignature}`);
// Add logic here if specific handling for multiple signatures is documented/required.
}
const signaturesMatch = crypto.timingSafeEqual(Buffer.from(providedSignature), Buffer.from(expectedSignature));
if (!signaturesMatch) {
console.error('Webhook Error: Invalid Sinch webhook signature.');
// Log details for debugging (avoid logging secrets or full body in prod if sensitive)
// console.error(`Provided: ${providedSignature}, Expected: ${expectedSignature}, Body Length: ${req.rawBody.length}, Timestamp: ${timestampHeader}`);
return res.status(403).send('Invalid signature');
}
console.log('Sinch webhook signature verified successfully.');
next(); // Signature is valid, proceed to the controller
} catch (error) {
console.error('Webhook Error: Exception during signature verification.', error);
return res.status(500).send('Internal server error during signature verification');
}
};
module.exports = verifySinchWebhook;
Explanation:
- Secret Check: Now logs an error and returns 500 if the secret is missing, improving security.
- Timestamp Check: Made more robust, checking for
NaN
and logging details on failure. req.rawBody
: Added more emphasis on the critical need for this and the correct middleware setup order. Returns 500 if missing.- Signature Calculation: Added note emphasizing the need to verify the
stringToSign
format against Sinch docs. - Multiple Signatures: Added handling for
split(',')
and a warning, explaining why the first is taken, but advising to check Sinch docs. - Error Handling: Added
try...catch
around crypto operations. - Logging: Improved error logging messages.
2.2.2. Webhook Controller (src/controllers/webhookController.js
)
This controller handles the logic after the webhook signature has been verified.
// src/controllers/webhookController.js
const sinchService = require('../services/sinchService');
// Optional: Import Prisma client if using database
// const prisma = require('../db');
const handleIncomingEvent = async (req, res) => {
// The JSON body is parsed by express.json() which runs *after*
// express.raw() and verifySinchWebhook middleware for this route.
const event = req.body;
console.log(`Received Verified Sinch Webhook Event: ${event.event_id || 'Unknown ID'}`);
// console.debug('Full Event Payload:', JSON.stringify(event, null, 2)); // Use debug level for full payload
// --- Process Different Event Types ---
// See Sinch Docs for full list & payload structure:
// https://developers.sinch.com/docs/conversation/api-reference/conversation/webhook-events/
try {
if (event.message_inbound) {
await handleInboundMessage(event.message_inbound);
} else if (event.message_delivery) {
await handleDeliveryReceipt(event.message_delivery);
} else if (event.contact_create) {
console.log(`Contact created event: ${event.contact_create.contact_id}`);
// Optional: Handle contact creation logic
} else if (event.contact_merge) {
console.log(`Contacts merged event: ${event.contact_merge.merged_contact_ids} into ${event.contact_merge.contact_id}`);
// Optional: Handle contact merge logic
} else if (event.conversation_start) {
console.log(`Conversation started event: ${event.conversation_start.conversation_id}`);
// Optional: Handle conversation start logic
}
// Add handlers for other event types as needed (e.g., event_inbound, message_submission)
res.status(200).send('Webhook received and processed successfully');
} catch (error) {
console.error(`Error processing webhook event ${event.event_id || 'Unknown ID'}:`, error);
// Respond with 500 but avoid sending detailed errors back to Sinch
res.status(500).send('Error processing webhook event');
}
};
// --- Specific Event Handlers ---
const handleInboundMessage = async (inboundEvent) => {
console.log('Handling Inbound Message:', inboundEvent.message_id);
const contactId = inboundEvent.contact_message?.contact_id; // Sender's ID (usually phone number)
const appId = inboundEvent.app_id; // Your WhatsApp Sender ID it was sent TO
const message = inboundEvent.contact_message?.message; // The actual message payload
const messageId = inboundEvent.message_id; // Sinch ID for this inbound message
const acceptedTime = inboundEvent.accepted_time;
if (!contactId || !message || !messageId) {
console.error(""Inbound message missing critical fields (contactId, message, messageId). Skipping."", inboundEvent);
return; // Cannot process without essential info
}
// Optional: Store message in DB (See Section 6)
// await storeInboundMessage(inboundEvent);
// Example: Simple Echo Bot (with basic 24hr window awareness - needs DB for real tracking)
if (message.text_message) {
const receivedText = message.text_message.text;
console.log(`Received text from ${contactId} (App: ${appId}, Msg ID: ${messageId}): ""${receivedText}""`);
// --- 24-Hour Window Logic Placeholder ---
// IMPORTANT: Production apps MUST track the last interaction time per contact
// in a database to correctly determine if the 24-hour window is open.
// This example assumes the window is open for simplicity.
const isWithinWindow = true; // Replace with actual check: e.g., checkDbForRecentInteraction(contactId);
if (isWithinWindow) {
// Simple check for STOP keyword
if (receivedText.trim().toUpperCase() === 'STOP') {
console.log(`Opt-out request received from ${contactId}.`);
// Add logic here to mark user as opted-out in your database
await sinchService.sendTextMessage(contactId, ""You have been opted out of messages."");
} else {
// Basic echo reply
try {
// Add a small delay to prevent instant loops if testing echo
// await new Promise(resolve => setTimeout(resolve, 500));
const replyText = `You said: ""${receivedText}""`;
await sinchService.sendTextMessage(contactId, replyText);
console.log(`Sent reply to ${contactId}`);
} catch (error) {
console.error(`Failed to send reply to ${contactId} (Msg ID: ${messageId}):`, error);
// Handle failure (e.g., log, maybe try template if outside window error occurs)
}
}
} else {
console.log(`Received message from ${contactId} outside 24hr window. Cannot send free-form reply.`);
// Logic to potentially send an approved template message if appropriate,
// or simply log and take no action.
// Example: await sinchService.sendTemplateMessage(contactId, 'customer_support_reopen_template', [contactName]);
}
} else if (message.media_message) {
console.log(`Received media from ${contactId} (Msg ID: ${messageId}): ${message.media_message.url}`);
// Handle media messages (download, process, store URL, etc.)
// Optional: Send confirmation (check 24hr window first)
// await sinchService.sendTextMessage(contactId, ""Thanks for the media!"");
} else if (message.choice_response_message) {
console.log(`Received button/list reply from ${contactId} (Msg ID: ${messageId}): ${message.choice_response_message.choice_id}`);
// Handle interactive message replies
// await sinchService.sendTextMessage(contactId, `Thanks for selecting ${message.choice_response_message.choice_id}!`);
} else {
const messageType = Object.keys(message)[0];
console.log(`Received other message type '${messageType}' from ${contactId} (Msg ID: ${messageId})`);
// Handle other types (location, contact cards, etc.) based on your needs
// Optional: Send generic ack (check 24hr window first)
// await sinchService.sendTextMessage(contactId, ""Received your message."");
}
};
const handleDeliveryReceipt = async (deliveryEvent) => {
console.log('Handling Delivery Receipt:', deliveryEvent.message_id);
const messageId = deliveryEvent.message_id; // Correlates to the message you sent
const contactId = deliveryEvent.contact_id;
const status = deliveryEvent.status; // e.g., DELIVERED, FAILED, READ, PENDING
const timestamp = deliveryEvent.processed_time;
const reason = deliveryEvent.reason; // Present if status is FAILED
console.log(`Message ${messageId} to ${contactId} status: ${status} at ${timestamp}`);
// Optional: Update message status in your database (See Section 6)
// await updateMessageStatus(messageId, status, reason, timestamp);
if (status === 'FAILED') {
console.error(`Message ${messageId} to ${contactId} FAILED: ${reason?.code || 'N/A'} - ${reason?.description || 'No description'}`);
// Trigger alerts or specific business logic for failed messages
} else if (status === 'READ') {
console.log(`Message ${messageId} to ${contactId} was READ at ${timestamp}`);
// Optional: Trigger analytics or follow-up logic
}
};
module.exports = {
handleIncomingEvent
};
Explanation:
- Logging: Added more context to logs (event ID, message ID). Using
console.debug
for full payload. - Error Handling: Added check for missing critical fields in
handleInboundMessage
. Improved error logging in the main handler. - 24-Hour Window: Added comments emphasizing the need for proper database tracking for the 24-hour rule. Added basic 'STOP' keyword handling example.
- Message Types: Added examples for handling
media_message
andchoice_response_message
(button/list replies). - Delivery Receipts: Made processing async. Added logging for
READ
status. EnhancedFAILED
logging.
2.2.3. Webhook Route (src/routes/webhookRoutes.js
)
This defines the /webhook
endpoint and applies the verification middleware.
// src/routes/webhookRoutes.js
const express = require('express');
const webhookController = require('../controllers/webhookController');
const verifySinchWebhook = require('../middleware/verifySinchWebhook');
const router = express.Router();
// IMPORTANT: The raw body parser middleware (express.raw) MUST be configured
// in app.js specifically for this route *before* the standard JSON parser (express.json)
// and *before* this verifySinchWebhook middleware runs.
// Apply signature verification middleware ONLY to the webhook route
// Then, allow express.json() to parse the verified body for the controller
router.post(
'/',
// Raw body parser applied in app.js for this route path
verifySinchWebhook, // Verifies using req.rawBody
express.json(), // Parses the verified body into req.body
webhookController.handleIncomingEvent // Controller receives req.body
);
module.exports = router;
Explanation:
- Added comments reinforcing the middleware order requirement in
app.js
. - Explicitly added
express.json()
afterverifySinchWebhook
in this route definition to ensure the controller getsreq.body
. The raw parser needs to be applied even earlier inapp.js
.
3. Building a Complete API Layer
Let's create a simple API endpoint to trigger sending messages.
3.1. Message Controller (src/controllers/messageController.js
)
// src/controllers/messageController.js
const sinchService = require('../services/sinchService');
// Optional: Import retry logic wrapper if implemented
// const { sendMessageWithRetry } = require('../services/retryService'); // Example path
const sendMessageApi = async (req, res, next) => { // Added next for error handling middleware
// Basic Input Validation
const { to, type, text, templateId, params } = req.body;
if (!to || !type) {
return res.status(400).json({ error: 'Missing required fields: ""to"" and ""type""' });
}
// Basic E.164 format check (can be more robust)
if (!/^\+?[1-9]\d{1,14}$/.test(to)) {
return res.status(400).json({ error: 'Invalid phone number format. Use E.164 (e.g., +14155552671)' });
}
try {
let result;
let messagePayload; // Define payload for potential DB storage
if (type === 'text') {
if (!text) {
return res.status(400).json({ error: 'Missing ""text"" field for type ""text""' });
}
messagePayload = { text_message: { text: text } };
console.log(`API request to send text to ${to}`);
// Example using retry logic (if implemented)
// result = await sendMessageWithRetry(to, messagePayload);
// Example using direct service call
result = await sinchService.sendTextMessage(to, text);
} else if (type === 'template') {
if (!templateId) {
return res.status(400).json({ error: 'Missing ""templateId"" field for type ""template""' });
}
// Basic validation for params if provided
if (params && !Array.isArray(params)) {
return res.status(400).json({ error: '""params"" must be an array of strings.' });
}
const templateParams = params || [];
// Construct payload here to potentially store it
messagePayload = {
template_message: { /* ... structure from sendTemplateMessage ... */ }
}; // Reconstruct or get from service if needed
console.log(`API request to send template ${templateId} to ${to} with params: ${templateParams.join(', ')}`);
result = await sinchService.sendTemplateMessage(to, templateId, templateParams);
} else {