code examples
code examples
Build Inbound Two-Way SMS with Sinch, Node.js & Fastify: Complete Guide
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 Inbound Two-Way SMS with Sinch, Node.js & Fastify
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 Sinch account with access to the SMS API. Create a Sinch account if you don't have one. Check Sinch pricing before starting.
- 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:
# 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:
- 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
[User's Phone] <-- SMS --> [Sinch Platform] --- HTTP POST --> [Your Fastify App]
^ |
|----------------------- SMS Reply ------------------------| (via Sinch API)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.json file to manage dependencies and project metadata.
npm init -y3. Install Dependencies:
Install Fastify for the web server, axios to make API calls to Sinch for replies, and dotenv to manage environment variables.
npm install fastify axios dotenvRecommended Versions:
fastify: ^5.0.0axios: ^1.6.0dotenv: ^16.0.0
4. 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 ignore
5. Configure .gitignore:
Add node_modules and .env to your .gitignore file to prevent committing them.
# .gitignore
node_modules
.env
*.log6. 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
# 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"Finding Your Credentials:
- Log in to your Sinch Customer Dashboard
- Navigate to SMS → APIs
- 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.
// 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:
node index.jsYou should see output like:
{"level":30,"time":1234567890,"msg":"Server listening on port 3000",...}
Visit http://localhost:3000/ in your browser. You should see:
{"status":"ok","timestamp":"2025-01-15T10:30:00.000Z"}- 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.bodyfor easy debugging during development. Also log key extracted information. - Data Extraction: Safely extract relevant fields like
from(sender),to(your Sinch number), andbody(the message text). - Business Logic Placeholder: A comment marks where you'd integrate your specific application logic.
- Acknowledgement: Send a
200 OKresponse 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 OKand process the message asynchronously. Otherwise, Sinch will retry the webhook, potentially creating duplicate processing. - Error Handling: A
try…catchblock handles unexpected errors during processing, logs them, and sends a generic500 Internal Server Errorresponse. - 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
sinchInboundSmsSchemain 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" }
- Requires fields:
-
Responses:
200 OK: Successfully received and acknowledged the message. Sinch expects response within the2xxsuccess 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. A5xxstatus 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.
bashcurl -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.
// 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 file- Dynamic Base URL: Construct the API endpoint URL using the
SINCH_REGIONenvironment 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 messagebody - Authorization Header: The
Authorization: Bearer YOUR_API_TOKENheader 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.postto send the request - Detailed Error Logging: The
catchblock provides detailed logging based on the type of error received from axios, helpful for troubleshooting API integration issues - Logger Passing: Pass the
request.loginstance (or the globalfastify.log) intosendSmsReplyso 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:
ngrok http 3000Expected Output:
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.
- Log in to your Sinch Customer Dashboard
- Navigate to SMS → APIs
- 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/sinchroute. 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): 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:
- 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.infoinsendSmsReply) - Sinch API responses/errors (
log.info/log.errorinsendSmsReply)
- Server startup (
-
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:
- Incoming Webhooks (Sinch Retry Policy): If your endpoint fails to respond with a
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) - Outgoing API Calls with Retry Logic:
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:
npm install async-retryIdempotency: 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):
+-----------------+ +-----------------+
| Contact | | Message |
+-----------------+ +-----------------+
| contact_id (PK) | | message_id (PK) |
| phone_number(UQ)|------| sinch_msg_id(UQ)|
| created_at | | contact_id (FK) |
| updated_at | | direction | // 'inbound' or 'outbound'
+-----------------+ | body |
| status | // 'received', 'sent', 'delivered', 'failed'
| error_message |
| retry_count |
| metadata | // JSON field for extensibility
| received_at |
| sent_at |
+-----------------+Prisma Schema Example:
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Contact {
id String @id @default(cuid())
phoneNumber String @unique @map("phone_number")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
messages Message[]
@@map("contacts")
}
model Message {
id String @id @default(cuid())
sinchMsgId String @unique @map("sinch_msg_id")
contactId String @map("contact_id")
direction String // 'inbound' or 'outbound'
body String
status String // 'received', 'sent', 'delivered', 'failed'
errorMessage String? @map("error_message")
retryCount Int @default(0) @map("retry_count")
metadata Json?
receivedAt DateTime? @map("received_at")
sentAt DateTime? @map("sent_at")
contact Contact @relation(fields: [contactId], references: [id])
@@index([contactId])
@@index([sinchMsgId])
@@map("messages")
}Implementation Steps (using Prisma + PostgreSQL):
-
Install ORM and Database Driver:
bashnpm install @prisma/client pg npm install -D prisma -
Initialize ORM:
bashnpx prisma init -
Add DATABASE_URL to .env:
dotenvDATABASE_URL="postgresql://user:password@localhost:5432/sinch_sms?schema=public" -
Update Schema: Copy the Prisma schema above into
prisma/schema.prisma -
Create Migrations:
bashnpx prisma migrate dev --name initial_setup -
Generate Client:
bashnpx prisma generate -
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:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
// Connection pooling configuration
// Format: postgresql://user:password@localhost:5432/dbname?connection_limit=10&pool_timeout=20
}Recommended pool size: 10–20 connections for typical webhook workloads.
6. Security Features
- Input Validation: Handled by Fastify's schema validation on the
/webhooks/sinchroute. Protects against malformed payloads. - Environment Variables: Securely manage API keys and sensitive data using
.envand 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:
npm install @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):
- 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 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:
javascriptconst 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:
- 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
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
- Fastify: Chosen for its high performance out-of-the-box
- Asynchronous Operations: All I/O (logging, API calls, potential DB access) uses
async/await, preventing the Node.js event loop from being blocked - Logging Level: In production, set the log level to
infoorwarninstead ofdebugto reduce logging overhead:
const fastify = require('fastify')({
logger: {
level: process.env.LOG_LEVEL || 'info'
}
});- 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
includeandselectoptions strategically. - Load Testing: Use tools like
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 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.
Sentry Example:
bashnpm install --save @sentry/node @sentry/profiling-nodejavascript// 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:
- 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" ].dockerignore: Similar to.gitignore, include:node_modules .env Dockerfile .dockerignore *.log .git -
Platform Choice:
- 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
-
Environment Variables: Inject sensitive environment variables (
SINCH_API_TOKEN, etc.) securely using platform secrets management:- Heroku:
heroku config:set SINCH_API_TOKEN=xyz - 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
- Heroku:
-
Process 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 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<br>2. Check Sinch dashboard callback URL configuration<br>3. Check server logs for incoming requests<br>4. Verify firewall allows inbound traffic |
| 400 Bad Request errors | 1. Check webhook payload matches schema<br>2. Verify Fastify schema validation isn't too strict<br>3. Log full request body to inspect structure |
| 500 Internal Server errors | 1. Check server logs for error stack traces<br>2. Verify environment variables are set correctly<br>3. Test database connectivity if using persistence |
| SMS not sending | 1. Verify SINCH_API_TOKEN is correct<br>2. Check SINCH_SERVICE_PLAN_ID matches dashboard<br>3. Verify SINCH_NUMBER is in E.164 format<br>4. Check Sinch API error response details |
| Duplicate messages | 1. Implement deduplication using sinch_msg_id<br>2. Respond faster (< 2 seconds) to prevent retries<br>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 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/sinchpath - Number Configuration: Ensure the Sinch number is correctly provisioned and assigned to the Service Plan ID used
-
Environment 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:
| 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
How to receive SMS messages with Fastify and Sinch?
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.
What is the Sinch inbound SMS webhook schema?
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.
Why does Sinch require HTTPS for callback URLs?
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.
When should I use ngrok for Sinch SMS?
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.
Can I send SMS replies with this setup?
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.
How to handle Sinch webhook errors in Fastify?
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.
What is Fastify and why use it with Sinch?
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.
How to set up Sinch inbound SMS with Node.js?
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.
How to test Sinch SMS integration locally?
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.
Why use environment variables for Sinch credentials?
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.
What is the purpose of a Sinch Service Plan ID?
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.
How to handle long SMS messages with Sinch and Fastify?
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.
What are security best practices for Sinch SMS integration?
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).