Step-by-step guide to building production-ready inbound SMS handling with Sinch SMS API, Node.js, and Fastify. Includes webhook security, HMAC verification, retry logic, and deployment strategies.
Build a production-ready Node.js application using Fastify to receive and process inbound SMS messages via the Sinch SMS API. This guide covers project setup, webhook handling, sending replies, security considerations, deployment, and troubleshooting.
You'll build a functional Fastify application capable of:
Receiving webhook notifications from Sinch for incoming SMS messages
Parsing message content and sender information
Logging incoming messages with structured data
Sending automated replies back via Sinch
Running locally using ngrok for testing
Deploying to production with containerization
Target Audience: Developers familiar with Node.js and basic web concepts who want to integrate Sinch SMS capabilities into their applications. Familiarity with Fastify helps but isn't required.
Technologies Used:
Node.js: The JavaScript runtime environment. Use LTS versions: Node.js 22 (Jod, LTS until April 2027), Node.js 20 (Iron, LTS until April 2026), or Node.js 18 (Hydrogen, LTS until April 2025). Source: Node.js Release Schedule (2024)
Fastify: A high-performance, low-overhead web framework for Node.js. Current version: Fastify v5 (released September 2024). Chosen for its speed, extensibility, and developer experience. Source: Fastify Releases (2024)
Sinch SMS API (REST): For sending reply messages and configuring inbound webhooks
axios: A promise-based HTTP client for making requests to the Sinch API
dotenv: For managing environment variables securely
ngrok (for local testing): Exposes your local server to the internet securely
Prerequisites:
Node.js (LTS version: 18.x, 20.x, or 22.x) and npm (or yarn) installed. Download Node.js
A provisioned phone number within your Sinch account capable of sending and receiving SMS
Your Sinch Service Plan ID and API Token (found in your Sinch Customer Dashboard under SMS → APIs)
ngrok installed globally (npm install -g ngrok) or available in your PATH
Verification: Confirm your setup works before proceeding:
Bash
# Verify Node.js installation
node --version # Should show v18.x, v20.x, or v22.x# Verify npm installation
npm --version
# Verify ngrok installation
ngrok version
System Architecture:
The system follows a simple webhook pattern:
User's Phone: Sends an SMS to your Sinch virtual number
Sinch Platform: Receives the SMS and identifies the configured callback URL (webhook) associated with that number or service plan
Sinch Platform: Sends an HTTP POST request containing the message details (sender, recipient, body, etc.) to your application's webhook endpoint
Fastify Application (Your Server): Listens for incoming POST requests on the designated endpoint (/webhooks/sinch)
Fastify Application: Receives the request, parses the JSON payload, logs the message, and performs other actions (e.g., storing in a database, triggering business logic)
(Optional) Fastify Application: Uses the Sinch REST API (via axios) to send an SMS reply back to the original sender
Sinch Platform: Delivers the reply SMS to the user's phone
Initialize your Node.js project and install the necessary dependencies.
1. Create Project Directory:
Open your terminal and create a new directory for the project, then navigate into it.
Bash
mkdir sinch-fastify-sms
cd sinch-fastify-sms
2. Initialize Node.js Project:
Create a package.json file to manage dependencies and project metadata.
Bash
npm init -y
3. Install Dependencies:
Install Fastify for the web server, axios to make API calls to Sinch for replies, and dotenv to manage environment variables.
Bash
npm install fastify axios dotenv
Recommended Versions:
fastify: ^5.0.0
axios: ^1.6.0
dotenv: ^16.0.0
4. Create Project Structure:
Create the basic files and directories.
Bash
touch index.js .env .gitignore
index.js: The main entry point for your Fastify application
.env: Stores sensitive credentials and configuration (API keys, phone numbers). Never commit this file to version control.
.gitignore: Specifies intentionally untracked files that Git should ignore
5. Configure .gitignore:
Add node_modules and .env to your .gitignore file to prevent committing them.
Text
# .gitignore
node_modules
.env
*.log
6. Set Up Environment Variables:
Open the .env file and add the following variables. Replace the placeholder values with your actual Sinch credentials and number.
.env
# .env
# Sinch Credentials (REST API for sending)
# Found in your Sinch Customer Dashboard → SMS → APIs
SINCH_SERVICE_PLAN_ID="YOUR_SERVICE_PLAN_ID"
SINCH_API_TOKEN="YOUR_API_TOKEN"
# Your Sinch Virtual Number (E.164 format, e.g., +12075551234)
SINCH_NUMBER="YOUR_SINCH_VIRTUAL_NUMBER"
# Server Configuration
PORT=3000
HOST="0.0.0.0" # Listen on all available network interfaces
# Optional: Set Sinch API region if not default (e.g., us, eu, au, ca)
# See Sinch docs for regional endpoints
SINCH_REGION="us"
Find your Service Plan ID listed in the service plan details
Generate or retrieve your API Token from the same section
Go to Numbers to view your provisioned virtual number
E.164 Format: Use the international phone number format with country code, no spaces or special characters. Examples: +12075551234 (US), +447700900123 (UK).
Why this setup?
Using environment variables (dotenv) keeps sensitive credentials out of your codebase, adhering to security best practices. The .gitignore file ensures these secrets and bulky node_modules aren't accidentally committed. Listening on 0.0.0.0 makes the server accessible within Docker containers or cloud environments.
2. Implementing Core Functionality: The Webhook Handler
Build the Fastify server and the endpoint that receives incoming SMS messages from Sinch.
1. Basic Fastify Server:
Open index.js and set up a minimal Fastify server.
JavaScript
// index.js// Load environment variables from .env filerequire('dotenv').config();
// Import Fastifyconst fastify = require('fastify')({
logger: true// Enable Fastify's built-in Pino logger
});
// Basic route for health check or testing
fastify.get('/', async (request, reply) => {
return { status: 'ok', timestamp: newDate().toISOString() };
});
// Function to start the serverconststart = async () => {
try {
const port = process.env.PORT || 3000;
const host = process.env.HOST || '0.0.0.0';
await fastify.listen({ port: parseInt(port, 10), host: host });
fastify.log.info(`Server listening on port ${port}`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
// Handle graceful shutdown
process.on('SIGINT', async () => {
fastify.log.info('Received SIGINT – shutting down gracefully');
await fastify.close();
process.exit(0);
});
process.on('SIGTERM', async () => {
fastify.log.info('Received SIGTERM – shutting down gracefully');
await fastify.close();
process.exit(0);
});
// Placeholder for sendSmsReply function (defined later)// async function sendSmsReply(recipientNumber, messageBody, log) { /* ... implementation ... */ }// --- Sinch Webhook Handler ---// Define the expected schema for the incoming Sinch payload// This helps with validation and provides type safety hints.// Schema based on official Sinch SMS API documentation (2024).// Inbound messages are stored in Sinch's system for 14 days.// Source: https://developers.sinch.com/docs/sms/api-reference/sms/tag/Inbounds/const sinchInboundSmsSchema = {
body: {
type: 'object',
required: ['from', 'to', 'body', 'id', 'type', 'received_at'],
properties: {
id: { type: 'string' }, // Sinch message ID (unique identifier)from: { type: 'string' }, // Sender's phone number (E.164 format)to: { type: 'string' }, // Your Sinch number or short codebody: { type: 'string', maxLength: 2000 }, // Message content (max 2000 chars)type: { type: 'string', const: 'mo_text' }, // Message Originating Textreceived_at: { type: 'string', format: 'date-time' }, // ISO-8601 timestampsent_at: { type: 'string', format: 'date-time' }, // Optional: when message left deviceoperator_id: { type: 'string' }, // Optional: MCC/MNC of sender's carrierclient_reference: { type: 'string', maxLength: 2048 } // Optional: reference from outbound message
}
}
};
// POST route to receive Sinch Inbound SMS webhooks
fastify.post('/webhooks/sinch', { schema: sinchInboundSmsSchema }, async (request, reply) => {
request.log.info('Received Sinch inbound SMS webhook:');
request.log.info(request.body); // Log the entire payload for debuggingtry {
const { from, to, body, id, received_at } = request.body;
const senderNumber = from; // Direct string in actual Sinch payloadconst recipientNumber = to; // Your Sinch number (direct string)const messageContent = body;
const messageId = id;
request.log.info(`Message ID [${messageId}] from [${senderNumber}] to [${recipientNumber}]: "${messageContent}"`);
request.log.info(`Received at: ${received_at}`);
// --- TODO: Add your business logic here ---// - Store the message in a database (Sinch retains messages for 14 days)// - Trigger other services// - Implement conversational logic// - Check for duplicate messages using messageId if needed// Example: Send an automated reply (requires sendSmsReply function defined later)// await sendSmsReply(senderNumber, `Thanks for your message: "${messageContent.substring(0, 50)}…"`, request.log);// Acknowledge receipt to Sinch with 200 OK// IMPORTANT: Respond within 2–3 seconds to avoid triggering Sinch's retry mechanism// If your processing takes longer than 2 seconds, respond immediately with 200 OK// and process the message asynchronously in the background
reply.code(200).send({ status: 'received' });
} catch (error) {
request.log.error('Error processing Sinch webhook:', error);
// Inform Sinch there was an error, but avoid sending detailed errors back
reply.code(500).send({ status: 'error processing message' });
}
});
// Start the serverstart();
Test Your Server:
Start your application and verify it's running correctly:
Bash
node index.js
You should see output like:
Code
{"level":30,"time":1234567890,"msg":"Server listening on port 3000",...}
Visit http://localhost:3000/ in your browser. You should see:
Schema Validation: Fastify automatically validates incoming request bodies against sinchInboundSmsSchema. If validation fails, Fastify sends a 400 Bad Request response, protecting your handler from invalid data.
Logging: Log the entire incoming request.body for easy debugging during development. Also log key extracted information.
Data Extraction: Safely extract relevant fields like from (sender), to (your Sinch number), and body (the message text).
Business Logic Placeholder: A comment marks where you'd integrate your specific application logic.
Acknowledgement: Send a 200 OK response back to Sinch quickly to acknowledge receipt. Failure to respond or sending error codes repeatedly might cause Sinch to disable the webhook.
Response Timing: If your processing takes longer than 2–3 seconds, respond immediately with 200 OK and process the message asynchronously. Otherwise, Sinch will retry the webhook, potentially creating duplicate processing.
Error Handling: A try…catch block handles unexpected errors during processing, logs them, and sends a generic 500 Internal Server Error response.
Graceful Shutdown: The server handles SIGINT and SIGTERM signals to close connections cleanly.
Endpoint Documentation & Testing:
Endpoint:POST /webhooks/sinch
Description: Receives inbound SMS notifications from the Sinch platform
Request Body:application/json
Requires fields: from (string), to (string), body (string, max 2000 chars), id (string), type (string, "mo_text"), received_at (ISO-8601 date-time)
See sinchInboundSmsSchema in the code above for the full expected structure
Example Request Payload (Actual Sinch Format):
JSON
{"id":"01FC66621XXXXX119Z8PMV1QPA","from":"16051234567","to":"13185551234","body":"This is a test message.","type":"mo_text","received_at":"2022-08-24T14:15:22Z","sent_at":"2022-08-24T14:15:22Z","operator_id":"310260","client_reference":"myReference"}
Responses:
200 OK: Successfully received and acknowledged the message. Sinch expects response within the 2xx success range.
JSON
{"status":"received"}
400 Bad Request: The incoming request body did not match the expected schema (handled automatically by Fastify). This counts as a permanent failure and will NOT trigger retries.
JSON
{"statusCode":400,"error":"Bad Request","message":"body should have required property '…' or body should match pattern \"…\""}
429 Too Many Requests: The callback will be retried AND the callback throughput will be lowered by Sinch. Source: Sinch Webhooks Documentation (2024)
500 Internal Server Error: An error occurred while processing the message on the server. A 5xx status code will trigger Sinch's retry mechanism.
JSON
{"status":"error processing message"}
Testing with curl:
Simulate a Sinch webhook call using curl once your server is running. Check your running server's console logs to see the output.
Bash
curl -X POST http://localhost:3000/webhooks/sinch \
-H "Content-Type: application/json" \
-d '{
"id": "TEST01",
"from": "16051234567",
"to": "13185551234",
"body": "This is a test message via curl",
"type": "mo_text",
"received_at": "2025-04-20T11:00:00Z",
"operator_id": "SIMULATED"
}'
Expected Output in Console:
JSON
{"level":30,"time":...,"msg":"Received Sinch inbound SMS webhook:"}{"level":30,"time":...,"msg":{"id":"TEST01","from":"16051234567",...}}{"level":30,"time":...,"msg":"Message ID [TEST01] from [16051234567] to [13185551234]: \"This is a test message via curl\""}
3. Integrating with Sinch (Sending Replies)
Implement the function to send replies using the Sinch REST API and axios.
1. Add axios:
Already installed in the setup phase.
2. Create the Send Function:
Add this function to your index.js file, replacing the placeholder comment.
JavaScript
// index.js (additions)// ... (Fastify setup and webhook handler above)const axios = require('axios');
// --- Sinch API Client for Sending SMS ---// Construct the Sinch API base URL dynamically based on regionconst sinchRegion = process.env.SINCH_REGION || 'us';
constSINCH_API_BASE_URL = `https://${sinchRegion}.sms.api.sinch.com/xms/v1/`;
/**
* Sends an SMS message using the Sinch REST API.
* @param {string} recipientNumber – The destination phone number (E.164 format)
* @param {string} messageBody – The text content of the SMS
* @param {import('fastify').FastifyLoggerInstance} [log=fastify.log] – Optional logger instance
*/asyncfunctionsendSmsReply(recipientNumber, messageBody, log = fastify.log) {
const servicePlanId = process.env.SINCH_SERVICE_PLAN_ID;
const apiToken = process.env.SINCH_API_TOKEN;
const sinchNumber = process.env.SINCH_NUMBER;
if (!servicePlanId || !apiToken || !sinchNumber) {
log.error('Sinch API credentials or number not configured in .env file. Cannot send SMS.');
return; // Avoid crashing if config is missing
}
const endpoint = `${SINCH_API_BASE_URL}${servicePlanId}/batches`;
const payload = {
from: sinchNumber,
to: [recipientNumber], // API expects an array of recipientsbody: messageBody,
// Add other parameters like delivery_report: 'full' if needed
};
const config = {
headers: {
'Authorization': `Bearer ${apiToken}`,
'Content-Type': 'application/json'
},
// Timeout set to 10 seconds to prevent hanging on slow connections// Most Sinch API calls respond within 1-2 seconds under normal conditionstimeout: 10000,
};
try {
log.info(`Sending SMS reply to [${recipientNumber}] via Sinch…`);
const response = await axios.post(endpoint, payload, config);
log.info(`Sinch API response status: ${response.status}`);
log.info(`Sinch API response data: ${JSON.stringify(response.data)}`);
// A successful send usually returns a batch ID, etc.return response.data;
} catch (error) {
log.error(`Error sending SMS via Sinch to [${recipientNumber}]:`);
if (error.response) {
// The request was made and the server responded with a status code// that falls out of the range of 2xx
log.error(`Status: ${error.response.status}`);
log.error(`Headers: ${JSON.stringify(error.response.headers)}`);
log.error(`Data: ${JSON.stringify(error.response.data)}`);
// Handle rate limiting (429)if (error.response.status === 429) {
const retryAfter = error.response.headers['retry-after'] || 60;
log.warn(`Rate limited by Sinch API. Retry after ${retryAfter} seconds.`);
// In production, implement exponential backoff retry logic here
}
} elseif (error.request) {
// The request was made but no response was received
log.error('No response received from Sinch API.');
} else {
// Something happened in setting up the request that triggered an Error
log.error('Error setting up Sinch API request:', error.message);
}
// Depending on the error, you might implement retries here (see Section 4)
}
}
// --- Webhook Handler Modification (Optional) ---// To enable auto-replies, uncomment the line inside the webhook handler's try block://// fastify.post('/webhooks/sinch', { schema: sinchInboundSmsSchema }, async (request, reply) => {// // ... (inside the try block)// await sendSmsReply(senderNumber, `Thanks for your message: "${messageContent.substring(0, 50)}…"`, request.log);// // ...// });// ... (keep existing start function below)// start(); // Ensure start() is called only once at the end of the file
Dynamic Base URL: Construct the API endpoint URL using the SINCH_REGION environment variable for flexibility
Credentials Check: The function first checks if the necessary environment variables are set
API Payload: The payload includes your Sinch number (from), the recipient's number (to – as an array), and the message body
Authorization Header: The Authorization: Bearer YOUR_API_TOKEN header authenticates with the Sinch REST API
Timeout Configuration: Set to 10 seconds to prevent hanging on slow connections. Most Sinch API calls respond within 1–2 seconds under normal conditions.
Rate Limit Handling: Detects 429 responses and logs the retry-after period for implementing backoff logic
Axios Request: Use axios.post to send the request
Detailed Error Logging: The catch block provides detailed logging based on the type of error received from axios, helpful for troubleshooting API integration issues
Logger Passing: Pass the request.log instance (or the global fastify.log) into sendSmsReply so logs related to sending a reply are associated with the original incoming request context
3. Configure ngrok for Local Testing:
Set up ngrok to expose your local server to the internet so Sinch can send webhooks to it.
Start ngrok:
Bash
ngrok http 3000
Expected Output:
Code
Session Status online
Account your_account (Plan: Free)
Version 3.x.x
Region United States (us)
Forwarding https://abc123.ngrok.io -> http://localhost:3000
Important: Copy the HTTPS forwarding URL (e.g., https://abc123.ngrok.io). This is your public webhook URL.
ngrok Subdomain Persistence:
Free Tier: ngrok generates a new random subdomain each time you restart it
Paid Tier: You can reserve a custom subdomain that persists across restarts (e.g., https://yourapp.ngrok.io)
For development, the free tier works fine – just update the Sinch dashboard callback URL each time you restart ngrok
4. Sinch Dashboard Configuration:
Tell Sinch where to send incoming SMS notifications.
Find your Service Plan ID listed there and click on it
Locate the Callback URL section (might be under "Default Settings" or similar)
Click Add Callback URL or Edit
Enter your ngrok HTTPS URL followed by the webhook path: https://abc123.ngrok.io/webhooks/sinch
Important: Ensure the URL starts with https://. Sinch requires secure callbacks.
Save the changes
Environment Variables Summary:
Variable
Description
Example
SINCH_SERVICE_PLAN_ID
Your specific service plan identifier from the Sinch dashboard. Used in the API URL for sending.
abc12345def67890
SINCH_API_TOKEN
Your secret API token from the Sinch dashboard. Used in the Authorization header for sending.
token_xyz789...
SINCH_NUMBER
Your provisioned Sinch virtual phone number in E.164 format. Used as the from number when sending replies.
+12223334444
PORT
The local port your Fastify server listens on.
3000
HOST
The network interface to bind to (0.0.0.0 for broad accessibility, 127.0.0.1 for local only).
0.0.0.0
SINCH_REGION
The geographical region for your Sinch API endpoint (e.g., us, eu). Affects the base URL for sending SMS.
us
4. Error Handling, Logging, and Retries
Error Handling:
Your application handles three categories of errors:
Fastify Validation: Handled automatically via the schema defined for the /webhooks/sinch route. Invalid requests get a 400 response.
Webhook Processing (Recoverable): Network timeouts, database connection issues, temporary service unavailability. Log the error, return 500 to trigger Sinch retry.
Webhook Processing (Non-Recoverable): Invalid phone number format, message violates content policy, authentication failure. Log the error, return 200 to prevent retries.
API Call Errors (sendSmsReply): The catch block in sendSmsReply handles network errors, authentication failures (401), authorization issues (403), bad requests (400), or server errors (5xx) from the Sinch API. It logs detailed information.
Logging:
Fastify Default Logger (Pino): Enabled via logger: true during Fastify initialization. Provides structured JSON logging by default, excellent for production.
Key Events Logged:
Server startup (fastify.log.info)
Incoming webhook requests (request.log.info)
Full webhook payload (request.log.info)
Extracted message details including timestamps (request.log.info)
Errors during processing (request.log.error)
Attempts to send replies (log.info in sendSmsReply)
Sinch API responses/errors (log.info/log.error in sendSmsReply)
Log Management: In production, forward these structured logs to a log management service (e.g., Datadog, Logz.io, ELK stack) for searching, filtering, and alerting based on error messages or specific log properties. Configure log rotation using tools like logrotate on Linux or your platform's built-in log management. Set retention policies based on compliance requirements – typically 30–90 days for application logs.
Retry Mechanisms:
Incoming Webhooks (Sinch Retry Policy): If your endpoint fails to respond with a 2xx status code, Sinch retries the callback using exponential backoff. Official retry schedule: The first retry occurs 5 seconds after the initial attempt, then 10 seconds, 20 seconds, 40 seconds, 80 seconds, doubling on every attempt. The final retry occurs at 81,920 seconds (22 hours 45 minutes) after the initial failed attempt. For 429 responses, the callback will be retried with lowered throughput. Ensure your handler responds quickly (ideally under 2–3 seconds) to avoid triggering retries. Source: Sinch Webhooks Documentation (2024)
Outgoing API Calls with Retry Logic:
For production systems, implement retry logic for transient failures:
Idempotency: To prevent duplicate message sends during retries, generate a unique idempotency key for each message and store it:
JavaScript
const crypto = require('crypto');
// Generate idempotency key based on message content and recipientfunctiongenerateIdempotencyKey(recipientNumber, messageBody, originalMessageId) {
const data = `${recipientNumber}:${messageBody}:${originalMessageId}`;
return crypto.createHash('sha256').update(data).digest('hex');
}
// In your webhook handler, before sending a reply:const idempotencyKey = generateIdempotencyKey(senderNumber, replyMessage, messageId);
// Check if this key exists in your database/cache// If yes, skip sending; if no, send and store the key
5. Database Schema and Data Layer (Conceptual)
Storing message history is often required. Here's a practical approach:
Recommended pool size: 10–20 connections for typical webhook workloads.
6. Security Features
Input Validation: Handled by Fastify's schema validation on the /webhooks/sinch route. Protects against malformed payloads.
Environment Variables: Securely manage API keys and sensitive data using .env and ensuring it's in .gitignore.
HTTPS: Sinch requires HTTPS for callback URLs, ensuring data is encrypted in transit. ngrok provides this locally. In production, use a reverse proxy (like Nginx or Caddy) or a PaaS that terminates SSL.
Rate Limiting: Protect against denial-of-service or abuse by implementing rate limiting on the webhook endpoint. Use @fastify/rate-limit:
Bash
npm install @fastify/rate-limit
JavaScript
// In index.js, register the plugin earlyconst fastify = require('fastify')({ logger: true });
await fastify.register(require('@fastify/rate-limit'), {
max: 100, // Max 100 requests per minute per IPtimeWindow: '1 minute'// Rationale: Typical SMS volumes rarely exceed 100 inbound messages/minute// from a single source. Adjust based on your expected traffic patterns.
});
Webhook Signature Verification (HMAC):
Problem: Anyone who discovers your webhook URL could potentially send fake SMS notifications.
Sinch HMAC Support: Sinch SMS API supports HMAC (Hash-based Message Authentication Code) using SHA-256 for webhook authentication. HMAC provides cryptographic verification of webhook authenticity and payload integrity. Source: Sinch Webhooks Documentation (2024)
Configuration Required: HMAC authentication must be configured by contacting your Sinch account manager. It can be assigned to a specific callback URL or an entire Service Plan. HMAC can also be configured alongside Basic Auth or OAuth 2.0.
Implementation Approaches:
Shared Secret Token (Simpler): Include a hard-to-guess secret token as a query parameter in the Callback URL configured in Sinch (e.g., https://…/webhooks/sinch?token=YOUR_SECRET_TOKEN). Verify this token in your handler. This adds protection but is less robust than HMAC.
JavaScript
// Inside the /webhooks/sinch handlerconst expectedToken = process.env.WEBHOOK_SECRET_TOKEN;
const receivedToken = request.query.token;
if (!expectedToken || receivedToken !== expectedToken) {
request.log.warn('Invalid or missing webhook token');
reply.code(401).send({ status: 'unauthorized' });
return; // Stop processing
}
// ... rest of the handler logic ...
HMAC-SHA256 (Most Secure, Recommended for Production): Once configured by your account manager, Sinch includes signature headers in webhook requests. Your application calculates the signature using the webhook secret and compares it to Sinch's provided signature. Typical headers include:
x-sinch-webhook-signature-nonce: Unique nonce value
const crypto = require('crypto');
// Store used nonces in Redis or memory cache for replay preventionconst usedNonces = newSet();
functionverifyHmacSignature(request, webhookSecret) {
const signature = request.headers['x-sinch-webhook-signature'];
const nonce = request.headers['x-sinch-webhook-signature-nonce'];
const timestamp = request.headers['x-sinch-webhook-signature-timestamp'];
const rawBody = JSON.stringify(request.body); // Use raw request bodyif (!signature || !nonce || !timestamp) {
returnfalse;
}
// Prevent replay attacks: reject old timestamps (> 5 minutes)const requestTime = parseInt(timestamp, 10);
const now = Date.now();
const fiveMinutes = 5 * 60 * 1000;
if (Math.abs(now - requestTime) > fiveMinutes) {
returnfalse; // Request too old or from future
}
// Check if nonce was already used (replay attack)if (usedNonces.has(nonce)) {
returnfalse;
}
usedNonces.add(nonce);
// In production, use Redis with TTL of 10 minutes for nonce storage// Construct the message: nonce + timestamp + rawBodyconst message = nonce + timestamp + rawBody;
// Calculate HMAC-SHA256 signatureconst expectedSignature = crypto
.createHmac('sha256', webhookSecret)
.update(message)
.digest('base64');
// Compare signatures (use timing-safe comparison)return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
// In webhook handler (after HMAC is configured):const webhookSecret = process.env.SINCH_WEBHOOK_SECRET;
if (webhookSecret && !verifyHmacSignature(request, webhookSecret)) {
request.log.warn('Invalid HMAC signature – potential unauthorized request');
reply.code(401).send({ status: 'unauthorized' });
return;
}
Webhook Secret Management:
Store secrets in environment variables, never in code
Use secret management services (AWS Secrets Manager, HashiCorp Vault, Google Secret Manager) in production
Rotate secrets every 90 days
When rotating, support both old and new secrets for 24 hours to prevent downtime
Note: Contact your Sinch account manager to enable and configure HMAC authentication for your webhooks.
Least Privilege: Ensure the API Token used only has permissions necessary for sending SMS (and potentially managing numbers/callbacks if needed), not broader account access.
7. Handling Special Cases
Character Encoding: Sinch and modern platforms handle UTF-8 correctly, supporting various characters and emojis. Ensure your database (if used) is also configured for UTF-8.
Long Messages (Concatenation): Sinch handles splitting long messages into multiple parts and delivering them correctly. The body received by your webhook contains the fully reassembled message. When sending, Sinch also handles splitting if the body exceeds standard SMS limits.
Non-Text Messages (MMS/Binary): This guide focuses on text (mo_text). Handling MMS (images, etc.) or binary SMS requires different webhook types (mo_binary) and payload structures, often involving links to media content. This requires extending the schema and handler logic.
Invalid Numbers: The sendSmsReply function might fail if the senderNumber is invalid or cannot receive SMS. The error handling block catches this (often as a 4xx error from Sinch).
Duplicate Messages: Network issues could potentially cause Sinch to retry sending a webhook. Use the unique Sinch message ID (request.body.id) to deduplicate messages. Inbound messages are stored in Sinch's system for 14 days and can be retrieved via the Inbounds API if needed. Source: Sinch Inbounds API (2024)
Deduplication Implementation:
JavaScript
// In webhook handler, before processing:const messageId = request.body.id;
// Check if message already processed (using database or cache)const existingMessage = await prisma.message.findUnique({
where: { sinchMsgId: messageId }
});
if (existingMessage) {
request.log.info(`Duplicate message ${messageId} detected – skipping processing`);
reply.code(200).send({ status: 'already processed' });
return; // Early return
}
// Continue with normal processing...
Character Encoding Edge Cases: SMS messages use either GSM-7 (supports 160 chars) or Unicode/UCS-2 (supports 70 chars). Sinch automatically detects and uses the appropriate encoding. Characters like emojis force Unicode encoding, reducing the character limit per message segment.
Payload Size: Be mindful of very large webhook payloads if they occur, though typical SMS webhooks are small
Database Optimization: Proper indexing (as mentioned in Section 5) is crucial if storing messages. Prevent N+1 queries by using Prisma's include and select options strategically.
Load Testing: Use tools like k6, autocannon, or wrk to simulate high volumes of webhook traffic and identify bottlenecks in your processing logic or downstream dependencies:
Bash
# Example using autocannon (install with npm i -g autocannon)
autocannon -m POST -H "Content-Type=application/json" -b '{ "id": "LT01", "from": "16051234567", "to": "13185551234", "body": "Load test", "type": "mo_text", "received_at": "2025-01-01T00:00:00Z" }' http://localhost:3000/webhooks/sinch
Expected Throughput: A properly configured Fastify application on modern hardware should handle 5,000–10,000 requests/second for simple webhook processing. With database writes, expect 500–2,000 requests/second depending on database performance.
9. Monitoring, Observability, and Analytics
Health Checks: The GET / route serves as a basic health check endpoint for load balancers or monitoring systems
Logging: Centralized logging (Section 4) is key for observability
Performance Metrics (APM): Integrate Application Performance Monitoring tools like Sentry (which has Fastify integration), Datadog APM, or Dynatrace. These automatically instrument your Fastify app to track request latency, error rates, throughput, and trace requests across services.
// index.js – Place this BEFORE requiring fastifyconstSentry = require('@sentry/node');
const { ProfilingIntegration } = require('@sentry/profiling-node');
Sentry.init({
dsn: process.env.SENTRY_DSN, // Add SENTRY_DSN to your .envintegrations: [
newSentry.Integrations.Http({ tracing: true }),
newProfilingIntegration(),
],
tracesSampleRate: 1.0, // Capture 100% of transactions – adjust to 0.1 (10%) in productionprofilesSampleRate: 1.0,
});
// Import Fastify *after* Sentry.initconst fastify = require('fastify')({ logger: true });
// Sentry Handlers
fastify.addHook('onRequest', Sentry.Handlers.requestHandler());
fastify.addHook('onRequest', Sentry.Handlers.tracingHandler());
// Custom error handler to ensure Sentry captures errors
fastify.setErrorHandler(async (error, request, reply) => {
Sentry.captureException(error);
reply.send(error);
});
// ... rest of your Fastify setup ...
Business Metrics: Track SMS-specific KPIs:
Inbound message volume (messages/hour)
Outbound message volume (messages/hour)
Delivery rate (delivered / sent)
Response time (time between inbound and outbound)
Failed sends by error type
Unique active users (contacts sending messages)
Alerting: Set up alerts in your monitoring or logging system for:
High error rates (5xx responses on webhook, API call failures)
Increased latency (> 500ms p95)
Low throughput (if expecting consistent traffic)
Specific error messages (e.g., "Invalid Sinch credentials")
Delivery rate drops below 95%
10. Deployment Considerations
Containerization (Docker): Package the application into a Docker image for consistent deployments.
Dockerfile Example:
Dockerfile
# Dockerfile
FROM node:22-alpine AS base
WORKDIR /app
# Install dependencies only when package files change
COPY package*.json ./
RUN npm ci --only=production
# Copy application code
COPY . .
# Expose the port the app runs on
EXPOSE 3000
# Set environment variables (can be overridden at runtime)
ENV NODE_ENV=production
ENV PORT=3000
ENV HOST=0.0.0.0
# Ensure SINCH_* variables are injected securely at runtime (e.g., via secrets management)
# Run the application
CMD [ "node", "index.js" ]
PaaS (e.g., Heroku, Render, Railway): Simplest option, handles infrastructure, scaling, HTTPS. Ensure the platform supports long-running Node.js servers (not just serverless functions for this webhook pattern)
Container Orchestration (e.g., Kubernetes, AWS ECS, Google Cloud Run): More control and scalability, requires more setup. Ideal for larger applications
Virtual Machine (e.g., AWS EC2, Google Compute Engine): Full control, requires manual setup of Node.js, process manager (like pm2), reverse proxy (Nginx/Caddy for HTTPS), and firewall
Kubernetes: Store in Secrets and mount as env vars
AWS ECS: Use AWS Secrets Manager or Parameter Store
Never hardcode secrets in Docker images or code
Process Management: In non-PaaS environments, use a process manager like pm2 to keep the Node.js application running:
Bash
npm install pm2 -g
pm2 start index.js --name sinch-sms
pm2 startup # Generate startup script
pm2 save # Save process list
HTTPS Termination: Ensure HTTPS is enforced. PaaS and orchestrators often handle this. If using VMs, configure a reverse proxy like Nginx or Caddy to handle SSL certificates (e.g., via Let's Encrypt) and proxy requests to your Node.js app running on localhost:3000.
Scaling:
Vertical: Increase CPU/RAM of the server/container instance
Horizontal: Run multiple instances of the application behind a load balancer. Ensure your application is stateless or manages state externally (e.g., using a database or Redis) if scaling horizontally. The webhook handler itself is generally stateless.
Zero-Downtime Deployments: Use blue-green or rolling deployment strategies:
Blue-Green: Deploy new version to separate environment, test, then switch traffic
Rolling: Gradually replace old instances with new ones (Kubernetes does this automatically)
Always test new deployments with health checks before directing traffic
11. Troubleshooting
Common Issues and Solutions:
Issue
Solution
Webhooks not arriving
1. Verify ngrok is running and HTTPS URL is correct 2. Check Sinch dashboard callback URL configuration 3. Check server logs for incoming requests 4. Verify firewall allows inbound traffic
400 Bad Request errors
1. Check webhook payload matches schema 2. Verify Fastify schema validation isn't too strict 3. Log full request body to inspect structure
500 Internal Server errors
1. Check server logs for error stack traces 2. Verify environment variables are set correctly 3. Test database connectivity if using persistence
SMS not sending
1. Verify SINCH_API_TOKEN is correct 2. Check SINCH_SERVICE_PLAN_ID matches dashboard 3. Verify SINCH_NUMBER is in E.164 format 4. Check Sinch API error response details
Duplicate messages
1. Implement deduplication using sinch_msg_id 2. Respond faster (< 2 seconds) to prevent retries 3. Check Sinch dashboard for retry settings
Diagnostic Steps:
Check Server Logs: The first step for any issue. Look for errors reported by Fastify, axios, or your custom logic
Verify ngrok Tunnel: If testing locally, ensure ngrok is running and the HTTPS URL is correctly configured in the Sinch dashboard. Check the ngrok web interface (http://localhost:4040 by default) to see incoming requests
Test with curl: Use the curl command (provided in Section 2) to simulate requests directly to your running application, bypassing Sinch/ngrok to isolate issues
Check Sinch Dashboard:
API Logs: Look for logs related to callback attempts or SMS sending failures in the Sinch dashboard (if available)
Callback URL: Double-check the configured callback URL is correct, uses HTTPS, and includes the /webhooks/sinch path
Number Configuration: Ensure the Sinch number is correctly provisioned and assigned to the Service Plan ID used
Environment Variables: Ensure all required .env variables are set correctly and accessible by the application (especially in deployment environments). Log them on startup (excluding secrets) for verification:
Firewall Issues: In deployed environments, ensure firewalls allow incoming traffic on the application's port (e.g., 3000 or 443 if using a reverse proxy) from Sinch's IP ranges (check Sinch documentation for these)
Sinch API Errors: Examine the detailed error logs from the sendSmsReply function's catch block (Status, Headers, Data) to understand failures when sending replies.
Common Sinch API Error Codes:
Status Code
Meaning
Action
400
Bad Request – Invalid parameters
Check phone number format, message body, API payload structure
401
Unauthorized – Invalid API token
Verify SINCH_API_TOKEN is correct and active
403
Forbidden – Insufficient permissions
Check API token has SMS sending permissions
404
Not Found – Service Plan ID doesn't exist
Verify SINCH_SERVICE_PLAN_ID is correct
429
Rate Limit Exceeded
Implement exponential backoff, check rate limits in dashboard
500/502/503
Sinch Server Error
Implement retry logic with exponential backoff
This guide provides a solid foundation for receiving Sinch SMS messages with Fastify. Adapt the business logic, error handling, security measures, and deployment strategy to your specific production requirements.
Frequently asked questions
Set up a Fastify server with a POST route '/webhooks/sinch' to handle incoming webhooks from Sinch. Configure your Sinch account to send notifications to this endpoint. The provided code example includes a schema to validate incoming requests and detailed logging for debugging.
The schema defines the expected structure of the JSON payload sent by Sinch, including 'from', 'to', 'body', 'id', and 'type'. This ensures type safety and allows Fastify to validate incoming requests, preventing errors from malformed data. Refer to the article's code example for the detailed schema and always cross-check with official Sinch documentation.
HTTPS encrypts data in transit, protecting sensitive information like message content. When testing locally with ngrok, it provides the necessary HTTPS tunnel. In production, use a reverse proxy or platform with SSL termination.
Use ngrok during local development to expose your server publicly so Sinch can send webhooks to it. For production, deploy your application to a server or platform with a public HTTPS URL and configure that in your Sinch dashboard.
Yes, the provided code includes the 'sendSmsReply' function using axios and the Sinch REST API. You'll need to configure your Sinch Service Plan ID, API Token, and Sinch Number in the '.env' file. Remember to uncomment the relevant line in the webhook handler to enable automatic replies after receiving a message.
Wrap your webhook handler logic in a try...catch block to handle potential errors. Log errors using request.log.error for debugging and inform Sinch there was an issue by sending a 500 Internal Server Error response to prevent retry exhaustion.
Fastify is a high-performance Node.js web framework known for its speed and extensibility. It's an excellent choice for handling Sinch SMS webhooks due to its efficiency in processing requests.
The article provides a comprehensive guide using Node.js, Fastify, and the Sinch SMS API. You'll need to install dependencies, configure environment variables, implement a webhook handler, and set up the callback URL in your Sinch dashboard.
Use ngrok to create a public HTTPS URL for your local server. Configure this URL as the callback in Sinch. Then, you can use curl to simulate Sinch webhook requests and test your application's response.
Storing sensitive information like API keys in environment variables (via .env) prevents them from being exposed in your codebase, improving security and adhering to best practices. Ensure '.env' is in your '.gitignore' file.
The Sinch Service Plan ID is a unique identifier for your Sinch account and SMS service configuration. It's required when making API calls to Sinch, including sending replies and setting up webhooks.
Sinch automatically handles long message concatenation. Your webhook will receive the complete message body, even if it was sent as multiple parts. Similarly, Sinch manages splitting long messages when sending.
Use HTTPS, validate webhook requests with a schema or token, manage credentials securely with environment variables, implement rate limiting, and consider HMAC signature verification if available from Sinch (or use a shared secret as an alternative).
Build a production-ready Node.js application using Fastify to receive and process inbound SMS messages via the Sinch SMS API. This guide covers project setup, webhook handling, sending replies, security considerations, deployment, and troubleshooting.
You'll build a functional Fastify application capable of:
Target Audience: Developers familiar with Node.js and basic web concepts who want to integrate Sinch SMS capabilities into their applications. Familiarity with Fastify helps but isn't required.
Technologies Used:
Prerequisites:
npm install -g ngrok) or available in your PATHVerification: Confirm your setup works before proceeding:
# Verify Node.js installation node --version # Should show v18.x, v20.x, or v22.x # Verify npm installation npm --version # Verify ngrok installation ngrok versionSystem Architecture:
The system follows a simple webhook pattern:
/webhooks/sinch)1. Setting Up the Project
Initialize your Node.js project and install the necessary dependencies.
1. Create Project Directory:
Open your terminal and create a new directory for the project, then navigate into it.
mkdir sinch-fastify-sms cd sinch-fastify-sms2. Initialize Node.js Project:
Create a
package.jsonfile to manage dependencies and project metadata.3. Install Dependencies:
Install Fastify for the web server, axios to make API calls to Sinch for replies, and dotenv to manage environment variables.
Recommended Versions:
fastify: ^5.0.0axios: ^1.6.0dotenv: ^16.0.04. Create Project Structure:
Create the basic files and directories.
touch index.js .env .gitignoreindex.js: The main entry point for your Fastify application.env: Stores sensitive credentials and configuration (API keys, phone numbers). Never commit this file to version control..gitignore: Specifies intentionally untracked files that Git should ignore5. Configure
.gitignore:Add
node_modulesand.envto your.gitignorefile to prevent committing them.6. Set Up Environment Variables:
Open the
.envfile and add the following variables. Replace the placeholder values with your actual Sinch credentials and number.Finding Your Credentials:
E.164 Format: Use the international phone number format with country code, no spaces or special characters. Examples:
+12075551234(US),+447700900123(UK).Why this setup?
Using environment variables (dotenv) keeps sensitive credentials out of your codebase, adhering to security best practices. The
.gitignorefile ensures these secrets and bulkynode_modulesaren't accidentally committed. Listening on0.0.0.0makes the server accessible within Docker containers or cloud environments.2. Implementing Core Functionality: The Webhook Handler
Build the Fastify server and the endpoint that receives incoming SMS messages from Sinch.
1. Basic Fastify Server:
Open
index.jsand set up a minimal Fastify server.// index.js // Load environment variables from .env file require('dotenv').config(); // Import Fastify const fastify = require('fastify')({ logger: true // Enable Fastify's built-in Pino logger }); // Basic route for health check or testing fastify.get('/', async (request, reply) => { return { status: 'ok', timestamp: new Date().toISOString() }; }); // Function to start the server const start = async () => { try { const port = process.env.PORT || 3000; const host = process.env.HOST || '0.0.0.0'; await fastify.listen({ port: parseInt(port, 10), host: host }); fastify.log.info(`Server listening on port ${port}`); } catch (err) { fastify.log.error(err); process.exit(1); } }; // Handle graceful shutdown process.on('SIGINT', async () => { fastify.log.info('Received SIGINT – shutting down gracefully'); await fastify.close(); process.exit(0); }); process.on('SIGTERM', async () => { fastify.log.info('Received SIGTERM – shutting down gracefully'); await fastify.close(); process.exit(0); }); // Placeholder for sendSmsReply function (defined later) // async function sendSmsReply(recipientNumber, messageBody, log) { /* ... implementation ... */ } // --- Sinch Webhook Handler --- // Define the expected schema for the incoming Sinch payload // This helps with validation and provides type safety hints. // Schema based on official Sinch SMS API documentation (2024). // Inbound messages are stored in Sinch's system for 14 days. // Source: https://developers.sinch.com/docs/sms/api-reference/sms/tag/Inbounds/ const sinchInboundSmsSchema = { body: { type: 'object', required: ['from', 'to', 'body', 'id', 'type', 'received_at'], properties: { id: { type: 'string' }, // Sinch message ID (unique identifier) from: { type: 'string' }, // Sender's phone number (E.164 format) to: { type: 'string' }, // Your Sinch number or short code body: { type: 'string', maxLength: 2000 }, // Message content (max 2000 chars) type: { type: 'string', const: 'mo_text' }, // Message Originating Text received_at: { type: 'string', format: 'date-time' }, // ISO-8601 timestamp sent_at: { type: 'string', format: 'date-time' }, // Optional: when message left device operator_id: { type: 'string' }, // Optional: MCC/MNC of sender's carrier client_reference: { type: 'string', maxLength: 2048 } // Optional: reference from outbound message } } }; // POST route to receive Sinch Inbound SMS webhooks fastify.post('/webhooks/sinch', { schema: sinchInboundSmsSchema }, async (request, reply) => { request.log.info('Received Sinch inbound SMS webhook:'); request.log.info(request.body); // Log the entire payload for debugging try { const { from, to, body, id, received_at } = request.body; const senderNumber = from; // Direct string in actual Sinch payload const recipientNumber = to; // Your Sinch number (direct string) const messageContent = body; const messageId = id; request.log.info(`Message ID [${messageId}] from [${senderNumber}] to [${recipientNumber}]: "${messageContent}"`); request.log.info(`Received at: ${received_at}`); // --- TODO: Add your business logic here --- // - Store the message in a database (Sinch retains messages for 14 days) // - Trigger other services // - Implement conversational logic // - Check for duplicate messages using messageId if needed // Example: Send an automated reply (requires sendSmsReply function defined later) // await sendSmsReply(senderNumber, `Thanks for your message: "${messageContent.substring(0, 50)}…"`, request.log); // Acknowledge receipt to Sinch with 200 OK // IMPORTANT: Respond within 2–3 seconds to avoid triggering Sinch's retry mechanism // If your processing takes longer than 2 seconds, respond immediately with 200 OK // and process the message asynchronously in the background reply.code(200).send({ status: 'received' }); } catch (error) { request.log.error('Error processing Sinch webhook:', error); // Inform Sinch there was an error, but avoid sending detailed errors back reply.code(500).send({ status: 'error processing message' }); } }); // Start the server start();Test Your Server:
Start your application and verify it's running correctly:
You should see output like:
Visit
http://localhost:3000/in your browser. You should see:{"status":"ok","timestamp":"2025-01-15T10:30:00.000Z"}sinchInboundSmsSchema. If validation fails, Fastify sends a 400 Bad Request response, protecting your handler from invalid data.request.bodyfor easy debugging during development. Also log key extracted information.from(sender),to(your Sinch number), andbody(the message text).200 OKresponse back to Sinch quickly to acknowledge receipt. Failure to respond or sending error codes repeatedly might cause Sinch to disable the webhook.200 OKand process the message asynchronously. Otherwise, Sinch will retry the webhook, potentially creating duplicate processing.try…catchblock handles unexpected errors during processing, logs them, and sends a generic500 Internal Server Errorresponse.Endpoint Documentation & Testing:
Endpoint:
POST /webhooks/sinchDescription: Receives inbound SMS notifications from the Sinch platform
Request Body:
application/jsonfrom(string),to(string),body(string, max 2000 chars),id(string),type(string, "mo_text"),received_at(ISO-8601 date-time)sinchInboundSmsSchemain the code above for the full expected structure{ "id": "01FC66621XXXXX119Z8PMV1QPA", "from": "16051234567", "to": "13185551234", "body": "This is a test message.", "type": "mo_text", "received_at": "2022-08-24T14:15:22Z", "sent_at": "2022-08-24T14:15:22Z", "operator_id": "310260", "client_reference": "myReference" }Responses:
200 OK: Successfully received and acknowledged the message. Sinch expects response within the2xxsuccess range.{ "status": "received" }400 Bad Request: The incoming request body did not match the expected schema (handled automatically by Fastify). This counts as a permanent failure and will NOT trigger retries.{ "statusCode": 400, "error": "Bad Request", "message": "body should have required property '…' or body should match pattern \"…\"" }429 Too Many Requests: The callback will be retried AND the callback throughput will be lowered by Sinch. Source: Sinch Webhooks Documentation (2024)500 Internal Server Error: An error occurred while processing the message on the server. A5xxstatus code will trigger Sinch's retry mechanism.{ "status": "error processing message" }Testing with curl:
Simulate a Sinch webhook call using curl once your server is running. Check your running server's console logs to see the output.
curl -X POST http://localhost:3000/webhooks/sinch \ -H "Content-Type: application/json" \ -d '{ "id": "TEST01", "from": "16051234567", "to": "13185551234", "body": "This is a test message via curl", "type": "mo_text", "received_at": "2025-04-20T11:00:00Z", "operator_id": "SIMULATED" }'Expected Output in Console:
{"level":30,"time":...,"msg":"Received Sinch inbound SMS webhook:"} {"level":30,"time":...,"msg":{"id":"TEST01","from":"16051234567",...}} {"level":30,"time":...,"msg":"Message ID [TEST01] from [16051234567] to [13185551234]: \"This is a test message via curl\""}3. Integrating with Sinch (Sending Replies)
Implement the function to send replies using the Sinch REST API and axios.
1. Add axios:
Already installed in the setup phase.
2. Create the Send Function:
Add this function to your
index.jsfile, replacing the placeholder comment.// index.js (additions) // ... (Fastify setup and webhook handler above) const axios = require('axios'); // --- Sinch API Client for Sending SMS --- // Construct the Sinch API base URL dynamically based on region const sinchRegion = process.env.SINCH_REGION || 'us'; const SINCH_API_BASE_URL = `https://${sinchRegion}.sms.api.sinch.com/xms/v1/`; /** * Sends an SMS message using the Sinch REST API. * @param {string} recipientNumber – The destination phone number (E.164 format) * @param {string} messageBody – The text content of the SMS * @param {import('fastify').FastifyLoggerInstance} [log=fastify.log] – Optional logger instance */ async function sendSmsReply(recipientNumber, messageBody, log = fastify.log) { const servicePlanId = process.env.SINCH_SERVICE_PLAN_ID; const apiToken = process.env.SINCH_API_TOKEN; const sinchNumber = process.env.SINCH_NUMBER; if (!servicePlanId || !apiToken || !sinchNumber) { log.error('Sinch API credentials or number not configured in .env file. Cannot send SMS.'); return; // Avoid crashing if config is missing } const endpoint = `${SINCH_API_BASE_URL}${servicePlanId}/batches`; const payload = { from: sinchNumber, to: [recipientNumber], // API expects an array of recipients body: messageBody, // Add other parameters like delivery_report: 'full' if needed }; const config = { headers: { 'Authorization': `Bearer ${apiToken}`, 'Content-Type': 'application/json' }, // Timeout set to 10 seconds to prevent hanging on slow connections // Most Sinch API calls respond within 1-2 seconds under normal conditions timeout: 10000, }; try { log.info(`Sending SMS reply to [${recipientNumber}] via Sinch…`); const response = await axios.post(endpoint, payload, config); log.info(`Sinch API response status: ${response.status}`); log.info(`Sinch API response data: ${JSON.stringify(response.data)}`); // A successful send usually returns a batch ID, etc. return response.data; } catch (error) { log.error(`Error sending SMS via Sinch to [${recipientNumber}]:`); if (error.response) { // The request was made and the server responded with a status code // that falls out of the range of 2xx log.error(`Status: ${error.response.status}`); log.error(`Headers: ${JSON.stringify(error.response.headers)}`); log.error(`Data: ${JSON.stringify(error.response.data)}`); // Handle rate limiting (429) if (error.response.status === 429) { const retryAfter = error.response.headers['retry-after'] || 60; log.warn(`Rate limited by Sinch API. Retry after ${retryAfter} seconds.`); // In production, implement exponential backoff retry logic here } } else if (error.request) { // The request was made but no response was received log.error('No response received from Sinch API.'); } else { // Something happened in setting up the request that triggered an Error log.error('Error setting up Sinch API request:', error.message); } // Depending on the error, you might implement retries here (see Section 4) } } // --- Webhook Handler Modification (Optional) --- // To enable auto-replies, uncomment the line inside the webhook handler's try block: // // fastify.post('/webhooks/sinch', { schema: sinchInboundSmsSchema }, async (request, reply) => { // // ... (inside the try block) // await sendSmsReply(senderNumber, `Thanks for your message: "${messageContent.substring(0, 50)}…"`, request.log); // // ... // }); // ... (keep existing start function below) // start(); // Ensure start() is called only once at the end of the fileSINCH_REGIONenvironment variable for flexibilityfrom), the recipient's number (to– as an array), and the messagebodyAuthorization: Bearer YOUR_API_TOKENheader authenticates with the Sinch REST APIaxios.postto send the requestcatchblock provides detailed logging based on the type of error received from axios, helpful for troubleshooting API integration issuesrequest.loginstance (or the globalfastify.log) intosendSmsReplyso logs related to sending a reply are associated with the original incoming request context3. Configure ngrok for Local Testing:
Set up ngrok to expose your local server to the internet so Sinch can send webhooks to it.
Start ngrok:
Expected Output:
Important: Copy the HTTPS forwarding URL (e.g.,
https://abc123.ngrok.io). This is your public webhook URL.ngrok Subdomain Persistence:
https://yourapp.ngrok.io)4. Sinch Dashboard Configuration:
Tell Sinch where to send incoming SMS notifications.
https://abc123.ngrok.io/webhooks/sinchhttps://. Sinch requires secure callbacks.Environment Variables Summary:
SINCH_SERVICE_PLAN_IDabc12345def67890SINCH_API_TOKENAuthorizationheader for sending.token_xyz789...SINCH_NUMBERfromnumber when sending replies.+12223334444PORT3000HOST0.0.0.0for broad accessibility,127.0.0.1for local only).0.0.0.0SINCH_REGIONus,eu). Affects the base URL for sending SMS.us4. Error Handling, Logging, and Retries
Error Handling:
Your application handles three categories of errors:
/webhooks/sinchroute. Invalid requests get a 400 response.sendSmsReply): Thecatchblock insendSmsReplyhandles network errors, authentication failures (401), authorization issues (403), bad requests (400), or server errors (5xx) from the Sinch API. It logs detailed information.Logging:
Fastify Default Logger (Pino): Enabled via
logger: trueduring Fastify initialization. Provides structured JSON logging by default, excellent for production.Key Events Logged:
fastify.log.info)request.log.info)request.log.info)request.log.info)request.log.error)log.infoinsendSmsReply)log.info/log.errorinsendSmsReply)Log Management: In production, forward these structured logs to a log management service (e.g., Datadog, Logz.io, ELK stack) for searching, filtering, and alerting based on error messages or specific log properties. Configure log rotation using tools like
logrotateon Linux or your platform's built-in log management. Set retention policies based on compliance requirements – typically 30–90 days for application logs.Retry Mechanisms:
2xxstatus code, Sinch retries the callback using exponential backoff. Official retry schedule: The first retry occurs 5 seconds after the initial attempt, then 10 seconds, 20 seconds, 40 seconds, 80 seconds, doubling on every attempt. The final retry occurs at 81,920 seconds (22 hours 45 minutes) after the initial failed attempt. For429responses, the callback will be retried with lowered throughput. Ensure your handler responds quickly (ideally under 2–3 seconds) to avoid triggering retries. Source: Sinch Webhooks Documentation (2024)For production systems, implement retry logic for transient failures:
const asyncRetry = require('async-retry'); async function sendSmsReplyWithRetry(recipientNumber, messageBody, log = fastify.log) { // Implementation with async-retry return await asyncRetry( async (bail) => { try { return await sendSmsReply(recipientNumber, messageBody, log); } catch (error) { // Don't retry on 4xx client errors (except 429) if (error.response && error.response.status >= 400 && error.response.status < 500) { if (error.response.status !== 429) { bail(error); // Stop retrying permanently return; } } throw error; // Retry on 5xx or network errors } }, { retries: 3, factor: 2, minTimeout: 1000, maxTimeout: 5000, onRetry: (error, attempt) => { log.warn(`Retry attempt ${attempt} for sending SMS to ${recipientNumber}`); } } ); }Install async-retry:
Idempotency: To prevent duplicate message sends during retries, generate a unique idempotency key for each message and store it:
const crypto = require('crypto'); // Generate idempotency key based on message content and recipient function generateIdempotencyKey(recipientNumber, messageBody, originalMessageId) { const data = `${recipientNumber}:${messageBody}:${originalMessageId}`; return crypto.createHash('sha256').update(data).digest('hex'); } // In your webhook handler, before sending a reply: const idempotencyKey = generateIdempotencyKey(senderNumber, replyMessage, messageId); // Check if this key exists in your database/cache // If yes, skip sending; if no, send and store the key5. Database Schema and Data Layer (Conceptual)
Storing message history is often required. Here's a practical approach:
Entity Relationship Diagram (ERD):
Prisma Schema Example:
Implementation Steps (using Prisma + PostgreSQL):
Install ORM and Database Driver:
Initialize ORM:
Add DATABASE_URL to .env:
Update Schema: Copy the Prisma schema above into
prisma/schema.prismaCreate Migrations:
Generate Client:
Integrate into Webhook Handler:
const { PrismaClient } = require('@prisma/client'); const prisma = new PrismaClient(); // In webhook handler after extracting message data: try { // Find or create contact const contact = await prisma.contact.upsert({ where: { phoneNumber: senderNumber }, update: {}, create: { phoneNumber: senderNumber } }); // Store inbound message await prisma.message.create({ data: { sinchMsgId: messageId, contactId: contact.id, direction: 'inbound', body: messageContent, status: 'received', receivedAt: new Date(received_at) } }); // Continue with business logic... } catch (dbError) { request.log.error('Database error:', dbError); // Still respond 200 to Sinch to prevent retries }Connection Pooling: Configure Prisma's connection pool in
schema.prisma:Recommended pool size: 10–20 connections for typical webhook workloads.
6. Security Features
/webhooks/sinchroute. Protects against malformed payloads..envand ensuring it's in.gitignore.@fastify/rate-limit:// In index.js, register the plugin early const fastify = require('fastify')({ logger: true }); await fastify.register(require('@fastify/rate-limit'), { max: 100, // Max 100 requests per minute per IP timeWindow: '1 minute' // Rationale: Typical SMS volumes rarely exceed 100 inbound messages/minute // from a single source. Adjust based on your expected traffic patterns. });Webhook Signature Verification (HMAC):
Shared Secret Token (Simpler): Include a hard-to-guess secret token as a query parameter in the Callback URL configured in Sinch (e.g.,
https://…/webhooks/sinch?token=YOUR_SECRET_TOKEN). Verify this token in your handler. This adds protection but is less robust than HMAC.// Inside the /webhooks/sinch handler const expectedToken = process.env.WEBHOOK_SECRET_TOKEN; const receivedToken = request.query.token; if (!expectedToken || receivedToken !== expectedToken) { request.log.warn('Invalid or missing webhook token'); reply.code(401).send({ status: 'unauthorized' }); return; // Stop processing } // ... rest of the handler logic ...HMAC-SHA256 (Most Secure, Recommended for Production): Once configured by your account manager, Sinch includes signature headers in webhook requests. Your application calculates the signature using the webhook secret and compares it to Sinch's provided signature. Typical headers include:
x-sinch-webhook-signature-nonce: Unique nonce valuex-sinch-webhook-signature-timestamp: Request timestampx-sinch-webhook-signature: HMAC-SHA256 signature (base64-encoded)HMAC Verification with Replay Attack Prevention:
const crypto = require('crypto'); // Store used nonces in Redis or memory cache for replay prevention const usedNonces = new Set(); function verifyHmacSignature(request, webhookSecret) { const signature = request.headers['x-sinch-webhook-signature']; const nonce = request.headers['x-sinch-webhook-signature-nonce']; const timestamp = request.headers['x-sinch-webhook-signature-timestamp']; const rawBody = JSON.stringify(request.body); // Use raw request body if (!signature || !nonce || !timestamp) { return false; } // Prevent replay attacks: reject old timestamps (> 5 minutes) const requestTime = parseInt(timestamp, 10); const now = Date.now(); const fiveMinutes = 5 * 60 * 1000; if (Math.abs(now - requestTime) > fiveMinutes) { return false; // Request too old or from future } // Check if nonce was already used (replay attack) if (usedNonces.has(nonce)) { return false; } usedNonces.add(nonce); // In production, use Redis with TTL of 10 minutes for nonce storage // Construct the message: nonce + timestamp + rawBody const message = nonce + timestamp + rawBody; // Calculate HMAC-SHA256 signature const expectedSignature = crypto .createHmac('sha256', webhookSecret) .update(message) .digest('base64'); // Compare signatures (use timing-safe comparison) return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expectedSignature) ); } // In webhook handler (after HMAC is configured): const webhookSecret = process.env.SINCH_WEBHOOK_SECRET; if (webhookSecret && !verifyHmacSignature(request, webhookSecret)) { request.log.warn('Invalid HMAC signature – potential unauthorized request'); reply.code(401).send({ status: 'unauthorized' }); return; }Webhook Secret Management:
Note: Contact your Sinch account manager to enable and configure HMAC authentication for your webhooks.
Least Privilege: Ensure the API Token used only has permissions necessary for sending SMS (and potentially managing numbers/callbacks if needed), not broader account access.
7. Handling Special Cases
Character Encoding: Sinch and modern platforms handle UTF-8 correctly, supporting various characters and emojis. Ensure your database (if used) is also configured for UTF-8.
Long Messages (Concatenation): Sinch handles splitting long messages into multiple parts and delivering them correctly. The
bodyreceived by your webhook contains the fully reassembled message. When sending, Sinch also handles splitting if thebodyexceeds standard SMS limits.Non-Text Messages (MMS/Binary): This guide focuses on text (
mo_text). Handling MMS (images, etc.) or binary SMS requires different webhook types (mo_binary) and payload structures, often involving links to media content. This requires extending the schema and handler logic.Invalid Numbers: The
sendSmsReplyfunction might fail if thesenderNumberis invalid or cannot receive SMS. The error handling block catches this (often as a 4xx error from Sinch).Duplicate Messages: Network issues could potentially cause Sinch to retry sending a webhook. Use the unique Sinch message ID (
request.body.id) to deduplicate messages. Inbound messages are stored in Sinch's system for 14 days and can be retrieved via the Inbounds API if needed. Source: Sinch Inbounds API (2024)Deduplication Implementation:
// In webhook handler, before processing: const messageId = request.body.id; // Check if message already processed (using database or cache) const existingMessage = await prisma.message.findUnique({ where: { sinchMsgId: messageId } }); if (existingMessage) { request.log.info(`Duplicate message ${messageId} detected – skipping processing`); reply.code(200).send({ status: 'already processed' }); return; // Early return } // Continue with normal processing...Character Encoding Edge Cases: SMS messages use either GSM-7 (supports 160 chars) or Unicode/UCS-2 (supports 70 chars). Sinch automatically detects and uses the appropriate encoding. Characters like emojis force Unicode encoding, reducing the character limit per message segment.
Opt-Out Keywords: Implement automatic handling for opt-out keywords (STOP, UNSUBSCRIBE, CANCEL):
// In webhook handler: const normalizedBody = messageContent.trim().toUpperCase(); if (['STOP', 'UNSUBSCRIBE', 'CANCEL', 'END', 'QUIT'].includes(normalizedBody)) { request.log.info(`Opt-out request from ${senderNumber}`); // Mark contact as opted out in database await prisma.contact.update({ where: { phoneNumber: senderNumber }, data: { optedOut: true, optedOutAt: new Date() } }); // Send confirmation await sendSmsReply( senderNumber, 'You have been unsubscribed. Reply START to opt back in.', request.log ); reply.code(200).send({ status: 'received' }); return; }8. Performance Optimizations
async/await, preventing the Node.js event loop from being blockedinfoorwarninstead ofdebugto reduce logging overhead:const fastify = require('fastify')({ logger: { level: process.env.LOG_LEVEL || 'info' } });includeandselectoptions strategically.k6,autocannon, orwrkto simulate high volumes of webhook traffic and identify bottlenecks in your processing logic or downstream dependencies:# Example using autocannon (install with npm i -g autocannon) autocannon -m POST -H "Content-Type=application/json" -b '{ "id": "LT01", "from": "16051234567", "to": "13185551234", "body": "Load test", "type": "mo_text", "received_at": "2025-01-01T00:00:00Z" }' http://localhost:3000/webhooks/sinchExpected Throughput: A properly configured Fastify application on modern hardware should handle 5,000–10,000 requests/second for simple webhook processing. With database writes, expect 500–2,000 requests/second depending on database performance.
9. Monitoring, Observability, and Analytics
Health Checks: The
GET /route serves as a basic health check endpoint for load balancers or monitoring systemsLogging: Centralized logging (Section 4) is key for observability
Performance Metrics (APM): Integrate Application Performance Monitoring tools like Sentry (which has Fastify integration), Datadog APM, or Dynatrace. These automatically instrument your Fastify app to track request latency, error rates, throughput, and trace requests across services.
Sentry Example:
// index.js – Place this BEFORE requiring fastify const Sentry = require('@sentry/node'); const { ProfilingIntegration } = require('@sentry/profiling-node'); Sentry.init({ dsn: process.env.SENTRY_DSN, // Add SENTRY_DSN to your .env integrations: [ new Sentry.Integrations.Http({ tracing: true }), new ProfilingIntegration(), ], tracesSampleRate: 1.0, // Capture 100% of transactions – adjust to 0.1 (10%) in production profilesSampleRate: 1.0, }); // Import Fastify *after* Sentry.init const fastify = require('fastify')({ logger: true }); // Sentry Handlers fastify.addHook('onRequest', Sentry.Handlers.requestHandler()); fastify.addHook('onRequest', Sentry.Handlers.tracingHandler()); // Custom error handler to ensure Sentry captures errors fastify.setErrorHandler(async (error, request, reply) => { Sentry.captureException(error); reply.send(error); }); // ... rest of your Fastify setup ...Business Metrics: Track SMS-specific KPIs:
Alerting: Set up alerts in your monitoring or logging system for:
10. Deployment Considerations
Containerization (Docker): Package the application into a Docker image for consistent deployments.
Dockerfile Example:
.dockerignore: Similar to.gitignore, include:Platform Choice:
Environment Variables: Inject sensitive environment variables (
SINCH_API_TOKEN, etc.) securely using platform secrets management:heroku config:set SINCH_API_TOKEN=xyzProcess Management: In non-PaaS environments, use a process manager like pm2 to keep the Node.js application running:
npm install pm2 -g pm2 start index.js --name sinch-sms pm2 startup # Generate startup script pm2 save # Save process listHTTPS Termination: Ensure HTTPS is enforced. PaaS and orchestrators often handle this. If using VMs, configure a reverse proxy like Nginx or Caddy to handle SSL certificates (e.g., via Let's Encrypt) and proxy requests to your Node.js app running on
localhost:3000.Scaling:
Zero-Downtime Deployments: Use blue-green or rolling deployment strategies:
11. Troubleshooting
Common Issues and Solutions:
2. Check Sinch dashboard callback URL configuration
3. Check server logs for incoming requests
4. Verify firewall allows inbound traffic
2. Verify Fastify schema validation isn't too strict
3. Log full request body to inspect structure
2. Verify environment variables are set correctly
3. Test database connectivity if using persistence
SINCH_API_TOKENis correct2. Check
SINCH_SERVICE_PLAN_IDmatches dashboard3. Verify
SINCH_NUMBERis in E.164 format4. Check Sinch API error response details
sinch_msg_id2. Respond faster (< 2 seconds) to prevent retries
3. Check Sinch dashboard for retry settings
Diagnostic Steps:
Check Server Logs: The first step for any issue. Look for errors reported by Fastify, axios, or your custom logic
Verify ngrok Tunnel: If testing locally, ensure ngrok is running and the HTTPS URL is correctly configured in the Sinch dashboard. Check the ngrok web interface (
http://localhost:4040by default) to see incoming requestsTest with curl: Use the curl command (provided in Section 2) to simulate requests directly to your running application, bypassing Sinch/ngrok to isolate issues
Check Sinch Dashboard:
/webhooks/sinchpathEnvironment Variables: Ensure all required
.envvariables are set correctly and accessible by the application (especially in deployment environments). Log them on startup (excluding secrets) for verification:// Add at startup (after dotenv.config()) fastify.log.info({ port: process.env.PORT, host: process.env.HOST, region: process.env.SINCH_REGION, hasServicePlanId: !!process.env.SINCH_SERVICE_PLAN_ID, hasApiToken: !!process.env.SINCH_API_TOKEN, hasNumber: !!process.env.SINCH_NUMBER });Firewall Issues: In deployed environments, ensure firewalls allow incoming traffic on the application's port (e.g., 3000 or 443 if using a reverse proxy) from Sinch's IP ranges (check Sinch documentation for these)
Sinch API Errors: Examine the detailed error logs from the
sendSmsReplyfunction'scatchblock (Status, Headers, Data) to understand failures when sending replies.Common Sinch API Error Codes:
SINCH_API_TOKENis correct and activeSINCH_SERVICE_PLAN_IDis correctThis guide provides a solid foundation for receiving Sinch SMS messages with Fastify. Adapt the business logic, error handling, security measures, and deployment strategy to your specific production requirements.