Implementing Sinch SMS Delivery Reports with Fastify and Node.js
A guide to building a webhook endpoint using Fastify, Node.js, and Prisma to receive, process, and store Sinch SMS delivery report (DLR) callbacks.
Code examples19 min read
Frequently asked questions
Set up Sinch SMS delivery reports by creating a webhook endpoint in your application. This endpoint receives real-time status updates from Sinch about your sent messages. The setup involves configuring your Sinch account, setting up a web server to handle incoming requests, and implementing logic to process the delivery reports. Use a tool like ngrok to expose your local development server for testing callbacks.
A Sinch SMS Delivery Report (DLR) is a real-time notification from Sinch that provides the status of your sent SMS messages. These reports are delivered to a webhook URL you specify, allowing your application to track message delivery status like "Delivered", "Failed", or "Pending". DLRs are crucial for applications needing reliable communication and status monitoring.
Fastify is a high-performance web framework for Node.js, chosen for its speed, extensibility, and developer-friendly features. Its efficiency makes it ideal for handling real-time callbacks like Sinch DLRs, ensuring quick responses and minimal overhead. Fastify's plugin system also simplifies adding features like authentication and rate limiting.
Test Sinch DLR webhooks locally using ngrok, which creates a public URL that tunnels requests to your local server. After starting ngrok, update your .env file's CALLBACK_BASE_URL with your ngrok HTTPS URL. Then, start your Fastify server and send a test SMS using the provided /send-test-sms endpoint. Observe your server logs and the ngrok interface to verify successful callback processing.
Prisma is a next-generation ORM (Object-Relational Mapper) used for database schema management and interactions. In this Sinch DLR setup, Prisma simplifies database operations by defining a schema for storing message statuses, generating migrations, and providing a clean API for querying and updating data. It streamlines database interaction in your Node.js application.
The provided code includes a /send-test-sms route in server.js. Access it via a GET request with query parameters for 'to', 'from' (optional), and 'body' (optional). It uses your Sinch API credentials and ngrok URL to send an SMS configured to trigger delivery report callbacks to your webhook endpoint. Remember to update your environment variables before running.
Use 'per_recipient' delivery reports when you require real-time status updates for each individual recipient of your SMS messages. This setting, configured in the Sinch API request payload, ensures that your webhook receives a callback for every status change for each recipient. This is ideal for applications requiring granular tracking of delivery outcomes.
The database schema, defined in prisma/schema.prisma, includes a MessageStatus model to store the status updates. It stores batchId, recipient, status, statusCode, timestamps, and an optional clientReference. A unique constraint is defined on batchId and recipient to enable efficient upsert operations. This constraint is important to ensure that each message recipient has only one record in the database.
Yes, you can use SQLite for the Sinch DLR project, especially for simplified local development. During Prisma initialization, choose SQLite as the datasource provider and update the DATABASE_URL in your .env file accordingly. The code adapts to either PostgreSQL (used in the example) or SQLite based on this environment variable.
Secure your Sinch DLR webhook ideally with signature verification, if available, by checking a Sinch-generated signature against your shared secret. If not available, use Basic Authentication by configuring credentials in your Sinch Dashboard and adding authentication checks in your webhook endpoint. Always use HTTPS and consider rate limiting to prevent abuse.
Sinch requires an immediate 200 OK response to acknowledge receipt of the delivery report callback. This confirms to Sinch that your endpoint received the data. Processing the DLR payload should happen asynchronously after sending the 200 OK to prevent Sinch from retrying the callback unnecessarily if processing takes time.
Handle Sinch DLR errors by implementing robust error handling and logging within your processDeliveryReport function. Log errors during validation, database interactions, or timestamp parsing. For critical failures, consider a dead-letter queue (DLQ) to store failed payloads for later inspection or retry. Monitor your logs and endpoint health.
ngrok creates a secure tunnel from a public URL to your local development server. It's essential for testing Sinch webhooks locally, as it allows Sinch to send callbacks to your server even though it's not publicly accessible. Update your .env file with the ngrok HTTPS URL and restart your server after starting ngrok.
Install the required dependencies for the Sinch DLR project using npm or yarn. The main dependencies include fastify, dotenv, axios, and @prisma/client. For Prisma setup, install the Prisma CLI as a development dependency using npm install -D prisma. Ensure your .env file is configured with your database and Sinch credentials before running.
One API. Every channel.
Contact Sales
This guide provides a step-by-step walkthrough for building a robust system to handle Sinch SMS delivery report (DLR) callbacks using Fastify, Node.js, and Prisma. You'll learn how to receive, process, and store real-time updates on the status of messages you send via the Sinch SMS API.
By the end of this tutorial, you will have a functional webhook endpoint capable of receiving DLRs from Sinch, processing them, and persisting the status updates to a database. This enables real-time tracking of message delivery, crucial for applications requiring reliable communication and status monitoring.
Project Goals:
Technologies Used:
.envfile.System Architecture:
Prerequisites:
ngrokinstalled for local testing (ngrok Installation Guide).1. Setting up the Project
Let's initialize the Node.js project and install the necessary dependencies.
Create Project Directory: Open your terminal and create a new directory for the project.
mkdir sinch-fastify-dlr cd sinch-fastify-dlrInitialize Node.js Project: Initialize the project using npm (or yarn).
This creates a
package.jsonfile.Install Dependencies: Install Fastify, dotenv (for environment variables), axios (for sending SMS), and Prisma (for database interaction).
Install Development Dependencies: Install Prisma CLI as a development dependency.
Initialize Prisma: Set up Prisma in your project. We'll use PostgreSQL as an example, but you can change the provider if needed (e.g.,
sqlite).This creates:
prismadirectory with aschema.prismafile for defining your database schema..envfile (if it doesn't exist) for environment variables, including theDATABASE_URL.Configure
.gitignore: Create a.gitignorefile in the root of your project to avoid committing sensitive information and unnecessary files.Project Structure: Your basic project structure should look like this:
2. Environment Configuration
Securely manage your API keys and other configuration settings using environment variables.
Edit
.envFile: Open the.envfile created byprisma init(or create it if it doesn't exist) and add your Sinch credentials and other configurations. You MUST replace the placeholder values below with your actual credentials and URLs.DATABASE_URL: Crucial. Replace the example string with your actual database connection string. If using SQLite locally, ensure the path is correct (e.g.,file:./prisma/dev.db). Make sure the database exists if using PostgreSQL.SINCH_SERVICE_PLAN_ID: Required. Find this on your Sinch Dashboard under API Credentials.SINCH_API_TOKEN: Required. Also found on your Sinch Dashboard. Keep this secret!PORT: The port your Fastify server will listen on (default is 3000).CALLBACK_BASE_URL: Required. This is the publicly accessible base URL where Sinch will send callbacks. When testing locally with ngrok, you'll replace the placeholder with your ngrok forwarding URL (thehttps://...one). In production, this will be your server's public domain/IP.WEBHOOK_USER/WEBHOOK_PASSWORD: (Optional) Only needed if you configure Basic Authentication for your callback URL in the Sinch Dashboard and implement the check in Section 7.Load Environment Variables: We'll load these variables into our application using
dotenvin theserver.jsfile. Ensurerequire('dotenv').config();is called early in your application startup.3. Creating the Database Schema
Define the database schema using Prisma to store message status information.
Define the Schema (
prisma/schema.prisma): Openprisma/schema.prismaand define a model to store the delivery status updates. We add a unique constraint onbatchIdandrecipientto facilitate theupsertoperation correctly.id, automatically generated by Prisma.@@unique([batchId, recipient])constraint is key. It ensures that for any given message batch, each recipient can only have one status record. This constraint is used by theupsertoperation in the code.receivedAtdefaults to the time the record is created in our database.updatedAtautomatically tracks the last modification time.Apply Migrations: Generate the SQL migration files based on your schema and apply them to your database.
# Create the migration files based on schema changes npx prisma migrate dev --name init-message-status # (Optional but recommended) Generate Prisma Client types based on the schema npx prisma generateThis command will:
DATABASE_URL.schema.prisma(including creating theMessageStatustable and the unique constraint).4. Building the Callback Endpoint
Now, let's create the Fastify server and the endpoint to receive callbacks.
Create
server.js: Create a file namedserver.jsin the root of your project.Implement the Server and Endpoint: Add the following code to
server.js:// server.js 'use strict'; // Load environment variables FIRST require('dotenv').config(); // Import dependencies const Fastify = require('fastify'); const { PrismaClient } = require('@prisma/client'); const axios = require('axios'); // Import axios here if using the test route // Initialize Prisma Client const prisma = new PrismaClient(); // Initialize Fastify const fastify = Fastify({ logger: true // Enable built-in Pino logger (outputs to console) }); // --- Function to Process Delivery Report Payload --- async function processDeliveryReport(payload, log) { // Basic payload structure validation if (!payload || typeof payload !== 'object') { log.warn({ payload }, 'Received invalid or non-object DLR payload. Skipping.'); return; } // Check for expected webhook type (adapt if using different report types) const expectedType = 'recipient_delivery_report_sms'; // Common type for per-recipient reports if (payload.type !== expectedType) { log.warn({ payloadType: payload.type, expectedType }, 'Received DLR payload with unexpected type. Skipping.'); // You might want different handlers for different types if needed. return; } // Validate essential fields for the expected type const { batch_id: batchId, recipient, status, code: statusCode, at: sinchTimestampStr, // ISO 8601 format string client_reference: clientReference // Optional } = payload; if (!batchId || !recipient || !status || typeof statusCode === 'undefined' || !sinchTimestampStr) { log.warn({ payload }, 'Received incomplete DLR payload (missing required fields). Skipping.'); return; // Stop processing if essential data is missing } // Convert timestamp string to Date object, handle potential errors const sinchTimestamp = new Date(sinchTimestampStr); if (isNaN(sinchTimestamp.getTime())) { log.warn({ payload }, 'Invalid timestamp format in DLR payload. Skipping.'); return; } log.info(`Processing status: ${status} (Code: ${statusCode}) for ${recipient} in batch ${batchId}`); try { // Use Prisma's upsert based on the @@unique([batchId, recipient]) constraint // This will CREATE a record if none exists for this batchId/recipient combo, // or UPDATE the existing record if one is found. const messageStatus = await prisma.messageStatus.upsert({ where: { // Use the unique constraint defined in schema.prisma batchId_recipient: { batchId: batchId, recipient: recipient, } }, update: { // Data to update if record exists status: status, statusCode: statusCode, sinchTimestamp: sinchTimestamp, clientReference: clientReference, // Update client ref if provided // `updatedAt` is handled automatically by Prisma's @updatedAt }, create: { // Data to insert if record doesn't exist // `id` is automatically generated (CUID) batchId: batchId, recipient: recipient, status: status, statusCode: statusCode, sinchTimestamp: sinchTimestamp, clientReference: clientReference, // `receivedAt` is handled by @default(now()) // `updatedAt` is set on creation as well }, }); log.info({ recordId: messageStatus.id, batchId, recipient }, `Successfully saved/updated status for recipient`); } catch (dbError) { log.error({ err: dbError, batchId, recipient }, 'Database error while saving DLR status'); // Re-throw the error to be caught by the caller (the webhook route handler) // This allows the main handler to log it and potentially implement retry/DLQ logic. throw dbError; } } // --- Webhook Endpoint for Sinch Delivery Reports --- // Path: /webhooks/sinch/dlr (matches common webhook patterns) // Define the handler function separately for clarity const webhookHandler = async (request, reply) => { const webhookPayload = request.body; // 1. Log the incoming payload for debugging request.log.info({ payload: webhookPayload }, 'Received Sinch DLR callback'); // 2. Immediately acknowledge receipt to Sinch (essential!) // Sinch expects a 2xx response quickly. Process the data asynchronously. reply.code(200).send({ status: 'OK', message: 'Callback received' }); // 3. Asynchronous Processing (does not delay the 200 OK response) // Use setImmediate or process.nextTick for truly non-blocking behavior if needed, // but awaiting the async function here is generally fine for typical loads. try { await processDeliveryReport(webhookPayload, request.log); } catch (error) { // Log processing errors, but the 200 response has already been sent. request.log.error({ err: error, payload: webhookPayload }, 'Error processing DLR payload after acknowledging'); // Consider adding failed payloads to a dead-letter queue here for retry/inspection. } }; // Register the webhook route (will be potentially modified by Basic Auth logic later) fastify.post('/webhooks/sinch/dlr', webhookHandler); // --- Health Check Endpoint (Good Practice) --- fastify.get('/health', async (request, reply) => { try { // Optional: Check database connection is alive await prisma.$queryRaw`SELECT 1`; reply.code(200).send({ status: 'OK', timestamp: new Date().toISOString() }); } catch (error) { request.log.error({ err: error }, 'Health check failed (database connection issue?)'); reply.code(503).send({ status: 'Service Unavailable' }); } }); // --- Test SMS Sending Route (Add this before start()) --- fastify.get('/send-test-sms', async (request, reply) => { // Validate 'to' number query parameter const to = request.query.to; if (!to || !/^\+?[1-9]\d{1,14}$/.test(to)) { // Basic E.164 format check return reply.code(400).send({ error: 'Missing or invalid required query parameter: to (must be E.164 format, e.g., +15551234567)' }); } const recipientNumber = to.startsWith('+') ? to : `+${to}`; // Ensure '+' prefix // Get other optional parameters const from = request.query.from || 'SinchTest'; // Optional Sender ID const body = request.query.body || `Test SMS from Fastify @ ${new Date().toLocaleTimeString()}`; const servicePlanId = process.env.SINCH_SERVICE_PLAN_ID; const apiToken = process.env.SINCH_API_TOKEN; const callbackBaseUrl = process.env.CALLBACK_BASE_URL; if (!servicePlanId || !apiToken || !callbackBaseUrl) { request.log.error('Missing SINCH_SERVICE_PLAN_ID, SINCH_API_TOKEN, or CALLBACK_BASE_URL in .env'); return reply.code(500).send({ error: 'Server configuration error: Missing required environment variables.' }); } // Construct the full callback URL for Sinch const callbackUrl = `${callbackBaseUrl}/webhooks/sinch/dlr`; // Determine Sinch API region URL (adjust 'us' if your account is in another region like 'eu') const sinchRegion = 'us'; // Or 'eu', 'ca', 'au', etc. const sinchSmsUrl = `https://${sinchRegion}.sms.api.sinch.com/xms/v1/${servicePlanId}/batches`; const payload = { from: from, to: [recipientNumber], // Must be an array body: body, // ** Important: Request per-recipient DLRs directed to your webhook ** delivery_report: 'per_recipient', // Options: 'summary', 'full', 'per_recipient', 'per_recipient_final' callback_url: callbackUrl, client_reference: `testRef_${Date.now()}` // Optional: Your custom reference ID }; request.log.info({ url: sinchSmsUrl, recipient: recipientNumber, callback: callbackUrl }, `Attempting to send SMS via Sinch`); try { const response = await axios.post(sinchSmsUrl, payload, { headers: { 'Authorization': `Bearer ${apiToken}`, 'Content-Type': 'application/json', 'Accept': 'application/json' }, timeout: 10000 // Add a timeout (e.g., 10 seconds) }); request.log.info({ batchId: response.data?.id, status: response.status }, 'SMS batch submitted successfully to Sinch.'); reply.send({ message: 'SMS batch submission successful!', sinchBatchId: response.data?.id, callbackUrl: callbackUrl, recipient: recipientNumber, clientReference: payload.client_reference }); } catch (error) { const status = error.response?.status; const errorData = error.response?.data; const errorMessage = error.message; request.log.error({ err: errorMessage, status, errorData, url: sinchSmsUrl }, 'Error sending SMS via Sinch API'); reply.code(status || 500).send({ error: 'Failed to send SMS via Sinch', details: errorData || errorMessage, status: status }); } }); // --- Start the Server --- const start = async () => { try { const port = parseInt(process.env.PORT || '3000', 10); const host = '0.0.0.0'; // Listen on all available network interfaces await fastify.listen({ port: port, host: host }); // Log after server is actually listening fastify.log.info(`Server listening on http://${host}:${port}`); fastify.log.info(`Webhook endpoint ready at /webhooks/sinch/dlr`); if (process.env.CALLBACK_BASE_URL) { fastify.log.info(`Ensure Sinch callback URL is configured to: ${process.env.CALLBACK_BASE_URL}/webhooks/sinch/dlr`); } else { fastify.log.warn('CALLBACK_BASE_URL environment variable is not set. Sinch callbacks will likely fail.'); } } catch (err) { fastify.log.error(err); await prisma.$disconnect(); // Disconnect Prisma on startup failure process.exit(1); } }; // Graceful Shutdown Handling const signals = ['SIGINT', 'SIGTERM']; signals.forEach(signal => { process.on(signal, async () => { fastify.log.info(`Received ${signal}, shutting down gracefully...`); try { await fastify.close(); // Stops accepting new connections and waits for pending requests await prisma.$disconnect(); // Closes the database connection pool fastify.log.info('Server and database connection closed successfully.'); process.exit(0); } catch (err) { fastify.log.error({ err }, 'Error during graceful shutdown'); process.exit(1); } }); }); // Call start after all routes and plugins are defined start();Explanation:
dotenvloads variables from.env. Crucially, it's called first./webhooks/sinch/dlrRoute:webhookHandler.200 OKimmediately. This is vital for Sinch.processDeliveryReportasynchronously after responding. Errors during processing are logged but don't cause Sinch retries (unless you implement a Dead Letter Queue).processDeliveryReportFunction:type.batch_id,recipient,status,code,at).prisma.messageStatus.upsertwith thebatchId_recipientunique constraint defined in the schema. This correctly finds an existing record to update or creates a new one.idfield is not set in thecreateblock; Prisma handles the CUID generation./healthRoute: Standard health check./send-test-smsRoute: (Included from Section 5 for completeness) Allows triggering an SMS for testing.PORTand0.0.0.0. Logs the expected callback URL.SIGINT(Ctrl+C) andSIGTERMto close the server and database connection cleanly.5. Sending an SMS with Callback Configuration
To test the webhook, you need to send an SMS using the Sinch API and tell Sinch where to send the delivery report callback. The code for the
/send-test-smsroute is included in theserver.jsexample in Section 4.Explanation of the Test Route:
to(required, E.164 format),from(optional), andbody(optional) as query parameters.SINCH_SERVICE_PLAN_ID,SINCH_API_TOKEN,CALLBACK_BASE_URL).usis correct for your account).to: Array containing the recipient number in E.164 format.delivery_report: 'per_recipient': Tells Sinch to send a callback for each recipient's status changes. Study Sinch docs if you needsummaryorfinalreports, as the payload structure differs.callback_url: The full, public URL of your webhook endpoint (/webhooks/sinch/dlrappended toCALLBACK_BASE_URL).axiosto make the POST request with the Bearer token.batch_id.6. Running and Testing Locally with ngrok
To allow Sinch's servers (on the public internet) to reach your local Fastify application during development, use
ngrok.Start Your Database: Ensure your PostgreSQL server is running or your SQLite file path is correct.
Start ngrok: Open a new, separate terminal window and run ngrok, telling it to forward HTTP traffic to the port your Fastify server runs on (default 3000).
Get ngrok Forwarding URL: ngrok will display output like this:
Copy the HTTPS forwarding URL (e.g.,
https://xxxxxxxx.ngrok-free.app). This URL is now publicly accessible as long as ngrok is running. Note: Free ngrok URLs are temporary and change each time you restart ngrok.Update
.env: Open your.envfile and replace the placeholderCALLBACK_BASE_URLwith the actual HTTPS ngrok URL you just copied.IMPORTANT: You must restart your Fastify server (
node server.js) after changing the.envfile for the changes to take effect.Start Fastify Server: In your original terminal window (in the project directory), start the server.
Check the logs. It should confirm it's listening and state the full callback URL it expects Sinch to use (based on your
.envsetting). IfCALLBACK_BASE_URLwas missing or incorrect, you'll see a warning.Trigger Test SMS: Open your web browser or use a tool like
curlto hit your server's test endpoint. Provide a valid phone number you can check (use your own mobile, in E.164 format like+15551234567).# Using curl (replace with your number): curl ""http://localhost:3000/send-test-sms?to=+15551234567"" # Or open in your browser (replace with your number): # http://localhost:3000/send-test-sms?to=+15551234567Observe Logs and Callbacks:
/send-test-sms./webhooks/sinch/dlras Sinch sends status updates (e.g.,Dispatched,Delivered,Failed).processDeliveryReportshowing status processing and database interaction (Successfully saved/updated status...).http://127.0.0.1:4040in your browser. This is ngrok's local inspection interface. It shows all requests coming through the tunnel, which is very useful for debugging if your Fastify endpoint isn't receiving callbacks as expected (you can inspect headers, body, timing, etc.).psql, pgAdmin,sqlite3, or Prisma Studio:npx prisma studio) and check theMessageStatustable. You should see records being created or updated corresponding to the DLRs received for your test message.7. Security Considerations
Securing your webhook endpoint is crucial in production.
Webhook Signature Verification (Ideal but Check Availability): The most secure method is verifying a signature sent by Sinch (usually HMAC), calculated using a shared secret. Currently, standard HMAC signature verification does not seem to be offered by Sinch for SMS DLR callbacks. Always consult the latest official Sinch documentation for confirmation and potential updates on security features. If signature verification becomes available, prioritize implementing it.
Basic Authentication (Alternative): If signature verification isn't available, you can use Basic Authentication as a layer of security. Configure a username and password in the Sinch Dashboard for your callback URL.
@fastify/basic-authplugin.preHandlerhook inserver.jsto check credentials before your webhook logic runs. Add this code before thefastify.post('/webhooks/sinch/dlr', ...)line and before thestart()call.// Add near the top of server.js, after Fastify initialization const basicAuth = require('@fastify/basic-auth'); // Register basic auth plugin ONLY if WEBHOOK_USER and WEBHOOK_PASSWORD are set in .env if (process.env.WEBHOOK_USER && process.env.WEBHOOK_PASSWORD) { fastify.register(basicAuth, { validate: async function (username, password, request, reply) { // Compare provided credentials with environment variables if (username !== process.env.WEBHOOK_USER || password !== process.env.WEBHOOK_PASSWORD) { request.log.warn('Webhook basic authentication failed: Invalid credentials'); // Return an error to trigger Fastify's default 401 Unauthorized response return new Error('Authentication required.'); } // No error means authentication succeeded }, authenticate: { realm: 'Sinch Webhook' } // Send WWW-Authenticate header on failure }); // Apply the basicAuth hook specifically to the webhook route // Define the route options object including the preHandler hook const webhookRouteOptions = { preHandler: fastify.basicAuth, // Apply authentication check handler: webhookHandler // Use the existing handler function }; // Re-register the route using the options object, effectively replacing the previous registration // Note: Fastify route registration order matters. Ensure this happens before start(). // It's generally safer to define all routes *then* call start(). // If the route was already defined, this might override it depending on Fastify version/behavior. // A cleaner approach is to conditionally define the route *once* based on env vars. // (See alternative structure below if needed) // Overwrite the previous route registration with the secured one: fastify.route({ method: 'POST', url: '/webhooks/sinch/dlr', ...webhookRouteOptions // Spread the options including preHandler and handler }); fastify.log.info('Webhook endpoint /webhooks/sinch/dlr configured with Basic Authentication.'); } else { // If no basic auth credentials in .env, the original route registration // fastify.post('/webhooks/sinch/dlr', webhookHandler); // remains active (or ensure it's registered here if structuring differently). fastify.log.info('Webhook endpoint /webhooks/sinch/dlr configured WITHOUT Basic Authentication.'); } // --- Ensure the rest of your routes (like /health, /send-test-sms) are defined here --- // --- Start the Server (should be called after all routes/plugins are set up) --- // start(); // The start() call should be at the very end of the script execution flowfastify.post('/webhooks/sinch/dlr', webhookHandler);line defined earlier is either removed or placed within theelseblock here, so the route isn't registered twice. The code above assumes thefastify.route({...})call replaces any previous definition for the same method/URL. Check Fastify documentation for specifics if needed. The key is that the route is registered either with thepreHandler(if auth is enabled) or without it (if auth is disabled).HTTPS: Always use HTTPS for your callback URL (
CALLBACK_BASE_URL).ngrokprovides this automatically for local testing. In production, ensure your server is behind a reverse proxy (like Nginx or Caddy) configured for SSL/TLS termination.Rate Limiting: Implement rate limiting (e.g., using
@fastify/rate-limit) on the webhook endpoint to prevent abuse or accidental overload.Input Validation: The current code performs basic validation. Enhance it based on the exact Sinch DLR payload specification to be more robust against unexpected or malformed data.
Error Handling & Monitoring: Implement robust error handling. Consider a dead-letter queue (DLQ) mechanism for payloads that fail processing, allowing for later inspection and retry. Monitor logs and endpoint health.