code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Article

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.

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:

  • Set up a Node.js project using the Fastify framework.
  • Create a secure webhook endpoint to receive HTTP POST requests from Sinch.
  • Parse and validate incoming delivery report payloads.
  • Store message status updates in a database (using Prisma and PostgreSQL/SQLite).
  • Implement basic security, logging, and error handling.
  • Demonstrate how to send an SMS via Sinch API configured to trigger these callbacks.
  • Provide instructions for local testing using ngrok.

Technologies Used:

  • Node.js: JavaScript runtime environment.
  • Fastify: High-performance, low-overhead web framework for Node.js. Chosen for its speed, extensibility, and developer-friendly features.
  • Sinch SMS API: Used for sending SMS messages and providing delivery status updates via webhooks.
  • Prisma: Next-generation ORM for Node.js and TypeScript. Used for database schema management and interaction.
  • dotenv: Module to load environment variables from a .env file.
  • axios: Promise-based HTTP client for making requests to the Sinch API.
  • ngrok: Utility to expose local development servers to the internet for webhook testing.

System Architecture:

mermaid
graph TD
    subgraph Your Application
        A[Fastify Server] --> B{Webhook Endpoint (/webhooks/sinch/dlr)};
        B --> C[DLR Processing Logic];
        C --> D[(Database / Prisma)];
        E[Send SMS Logic] --> F{Sinch SMS API};
    end

    subgraph External Services
        G[User/Trigger] --> E;
        F -- Sends SMS --> H[End User Device];
        F -- Sends DLR Callback --> B;
    end

    style F fill:#f9f,stroke:#333,stroke-width:2px
    style B fill:#ccf,stroke:#333,stroke-width:2px

Prerequisites:

  • Node.js (LTS version recommended, e.g., v18 or v20) and npm/yarn.
  • A Sinch account with API credentials (Service Plan ID and API Token). You can find these on your Sinch Customer Dashboard.
  • ngrok installed for local testing (ngrok Installation Guide).
  • Basic understanding of Node.js, APIs, and webhooks.
  • Access to a database (PostgreSQL is used here, but SQLite can be substituted for simpler local development).

1. Setting up the Project

Let's initialize the Node.js project and install the necessary dependencies.

  1. Create Project Directory: Open your terminal and create a new directory for the project.

    bash
    mkdir sinch-fastify-dlr
    cd sinch-fastify-dlr
  2. Initialize Node.js Project: Initialize the project using npm (or yarn).

    bash
    npm init -y

    This creates a package.json file.

  3. Install Dependencies: Install Fastify, dotenv (for environment variables), axios (for sending SMS), and Prisma (for database interaction).

    bash
    npm install fastify dotenv axios @prisma/client
  4. Install Development Dependencies: Install Prisma CLI as a development dependency.

    bash
    npm install -D prisma
  5. 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).

    bash
    npx prisma init --datasource-provider postgresql

    This creates:

    • A prisma directory with a schema.prisma file for defining your database schema.
    • A .env file (if it doesn't exist) for environment variables, including the DATABASE_URL.
  6. Configure .gitignore: Create a .gitignore file in the root of your project to avoid committing sensitive information and unnecessary files.

    text
    # .gitignore
    
    # Node
    node_modules/
    npm-debug.log*
    yarn-debug.log*
    yarn-error.log*
    pids/
    *.pid
    *.seed
    *.log
    *.csv
    *.dat
    *.out
    
    # Environment variables
    .env
    .env*.local
    .env*.development
    .env*.production
    
    # Prisma
    prisma/migrations/*/*.sql
    # If using SQLite, ignore the database file:
    prisma/dev.db*
  7. Project Structure: Your basic project structure should look like this:

    plaintext
    sinch-fastify-dlr/
    ├── prisma/
    │   └── schema.prisma
    ├── node_modules/
    ├── .env
    ├── .gitignore
    ├── package.json
    ├── package-lock.json
    └── server.js  (We will create this next)

2. Environment Configuration

Securely manage your API keys and other configuration settings using environment variables.

  1. Edit .env File: Open the .env file created by prisma 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.

    dotenv
    # .env
    
    # Database Connection (Prisma uses this)
    # Replace with your actual database connection string.
    # Example for PostgreSQL: DATABASE_URL=""postgresql://user:yourpassword@host:port/database?schema=public""
    # Example for SQLite: DATABASE_URL=""file:./prisma/dev.db""
    DATABASE_URL=""postgresql://postgres:yourpassword@localhost:5432/sinch_dlr?schema=public"" # <-- IMPORTANT: Replace with your DB connection string
    
    # Sinch API Credentials
    # Replace with your credentials from the Sinch Dashboard. Keep these secret!
    SINCH_SERVICE_PLAN_ID=""YOUR_SERVICE_PLAN_ID"" # <-- IMPORTANT: Replace with your Service Plan ID
    SINCH_API_TOKEN=""YOUR_API_TOKEN""             # <-- IMPORTANT: Replace with your API Token
    
    # Application Configuration
    PORT=3000
    # Base URL for callbacks (use ngrok URL for local testing, public domain in production)
    # Replace with your actual ngrok forwarding URL or public server URL.
    CALLBACK_BASE_URL=""https://your-ngrok-subdomain.ngrok.io"" # <-- IMPORTANT: Replace with your ngrok or public URL
    
    # Optional: Basic Auth for Webhook (if configured in Sinch)
    # Uncomment and replace if using Basic Auth security (see Section 7)
    # WEBHOOK_USER=""your_webhook_user""
    # WEBHOOK_PASSWORD=""your_webhook_password""
    • 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 (the https://... 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.
  2. Load Environment Variables: We'll load these variables into our application using dotenv in the server.js file. Ensure require('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.

  1. Define the Schema (prisma/schema.prisma): Open prisma/schema.prisma and define a model to store the delivery status updates. We add a unique constraint on batchId and recipient to facilitate the upsert operation correctly.

    prisma
    // prisma/schema.prisma
    
    generator client {
      provider = "prisma-client-js"
    }
    
    datasource db {
      provider = "postgresql" // Or "sqlite", etc. match your .env
      url      = env("DATABASE_URL")
    }
    
    model MessageStatus {
      id             String   @id @default(cuid()) // Unique record ID (auto-generated CUID)
      batchId        String   // Sinch's batch ID
      recipient      String   // Phone number (MSISDN) of the recipient
      status         String   // Sinch delivery status (e.g., Delivered, Failed)
      statusCode     Int      // Sinch status code (e.g., 0, 401)
      clientReference String?  // Optional client reference sent with the batch
      sinchTimestamp DateTime // Timestamp from the Sinch DLR payload ('at' field)
      receivedAt     DateTime @default(now()) // Timestamp when the callback was received by our server
      updatedAt      DateTime @updatedAt // Timestamp when the record was last updated
    
      // Unique constraint combining batchId and recipient.
      // This allows upsert to find the correct record to update based on these two fields.
      @@unique([batchId, recipient])
    
      // Index for efficient querying (optional, as @@unique often creates an index)
      @@index([batchId, recipient])
    }
    • We use a CUID for the primary id, automatically generated by Prisma.
    • The @@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 the upsert operation in the code.
    • receivedAt defaults to the time the record is created in our database.
    • updatedAt automatically tracks the last modification time.
  2. Apply Migrations: Generate the SQL migration files based on your schema and apply them to your database.

    bash
    # 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 generate

    This command will:

    • Connect to the database specified in DATABASE_URL.
    • Create the necessary SQL commands based on schema.prisma (including creating the MessageStatus table and the unique constraint).
    • Apply these commands to your database.
    • Ensure your Prisma Client is up-to-date with the schema.

4. Building the Callback Endpoint

Now, let's create the Fastify server and the endpoint to receive callbacks.

  1. Create server.js: Create a file named server.js in the root of your project.

  2. Implement the Server and Endpoint: Add the following code to server.js:

    javascript
    // 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:

    • Environment Variables: dotenv loads variables from .env. Crucially, it's called first.
    • Prisma Client: An instance is created.
    • Fastify Instance: Created with logging enabled.
    • /webhooks/sinch/dlr Route:
      • Listens for POST requests using the webhookHandler.
      • Logs the incoming payload.
      • Sends 200 OK immediately. This is vital for Sinch.
      • Calls processDeliveryReport asynchronously after responding. Errors during processing are logged but don't cause Sinch retries (unless you implement a Dead Letter Queue).
    • processDeliveryReport Function:
      • Performs validation on the payload structure and type.
      • Validates required fields (batch_id, recipient, status, code, at).
      • Parses the Sinch timestamp string.
      • Uses prisma.messageStatus.upsert with the batchId_recipient unique constraint defined in the schema. This correctly finds an existing record to update or creates a new one.
      • The id field is not set in the create block; Prisma handles the CUID generation.
      • Logs success or database errors. Errors are re-thrown to be caught by the route handler.
    • /health Route: Standard health check.
    • /send-test-sms Route: (Included from Section 5 for completeness) Allows triggering an SMS for testing.
    • Server Start: Listens on the configured PORT and 0.0.0.0. Logs the expected callback URL.
    • Graceful Shutdown: Handles SIGINT (Ctrl+C) and SIGTERM to 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-sms route is included in the server.js example in Section 4.

Explanation of the Test Route:

  • Takes to (required, E.164 format), from (optional), and body (optional) as query parameters.
  • Validates required environment variables (SINCH_SERVICE_PLAN_ID, SINCH_API_TOKEN, CALLBACK_BASE_URL).
  • Constructs the Sinch API endpoint URL (ensure the region like us is correct for your account).
  • Creates the request payload, critically including:
    • 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 need summary or final reports, as the payload structure differs.
    • callback_url: The full, public URL of your webhook endpoint (/webhooks/sinch/dlr appended to CALLBACK_BASE_URL).
  • Uses axios to make the POST request with the Bearer token.
  • Logs success/failure and returns info including the Sinch 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.

  1. Start Your Database: Ensure your PostgreSQL server is running or your SQLite file path is correct.

  2. 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).

    bash
    ngrok http 3000
  3. Get ngrok Forwarding URL: ngrok will display output like this:

    Session Status online Account Your Name (Plan: Free) ... Forwarding http://xxxxxxxx.ngrok-free.app -> http://localhost:3000 Forwarding https://xxxxxxxx.ngrok-free.app -> http://localhost:3000 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use this HTTPS URL

    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.

  4. Update .env: Open your .env file and replace the placeholder CALLBACK_BASE_URL with the actual HTTPS ngrok URL you just copied.

    dotenv
    # .env
    # ... other variables
    CALLBACK_BASE_URL=""https://xxxxxxxx.ngrok-free.app"" # <-- Paste your ACTUAL ngrok HTTPS URL here

    IMPORTANT: You must restart your Fastify server (node server.js) after changing the .env file for the changes to take effect.

  5. Start Fastify Server: In your original terminal window (in the project directory), start the server.

    bash
    node server.js

    Check the logs. It should confirm it's listening and state the full callback URL it expects Sinch to use (based on your .env setting). If CALLBACK_BASE_URL was missing or incorrect, you'll see a warning.

  6. Trigger Test SMS: Open your web browser or use a tool like curl to hit your server's test endpoint. Provide a valid phone number you can check (use your own mobile, in E.164 format like +15551234567).

    bash
    # 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=+15551234567
  7. Observe Logs and Callbacks:

    • Fastify Terminal: Watch the logs. You should see:
      • The GET request to /send-test-sms.
      • The log indicating successful submission to Sinch (or an error if submission failed).
      • Shortly after, one or more POST requests hitting /webhooks/sinch/dlr as Sinch sends status updates (e.g., Dispatched, Delivered, Failed).
      • Logs from processDeliveryReport showing status processing and database interaction (Successfully saved/updated status...).
    • ngrok Web Interface (Optional): Open http://127.0.0.1:4040 in 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.).
    • Database: Connect to your database (using psql, pgAdmin, sqlite3, or Prisma Studio: npx prisma studio) and check the MessageStatus table. 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.

  1. 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.

  2. 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.

    • Installation (if using Basic Auth): You need the @fastify/basic-auth plugin.
      bash
      npm install @fastify/basic-auth
    • Implementation: Register the plugin and add a preHandler hook in server.js to check credentials before your webhook logic runs. Add this code before the fastify.post('/webhooks/sinch/dlr', ...) line and before the start() call.
    javascript
    // 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 flow
    • Important Structure Note: Ensure the fastify.post('/webhooks/sinch/dlr', webhookHandler); line defined earlier is either removed or placed within the else block here, so the route isn't registered twice. The code above assumes the fastify.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 the preHandler (if auth is enabled) or without it (if auth is disabled).
  3. HTTPS: Always use HTTPS for your callback URL (CALLBACK_BASE_URL). ngrok provides this automatically for local testing. In production, ensure your server is behind a reverse proxy (like Nginx or Caddy) configured for SSL/TLS termination.

  4. Rate Limiting: Implement rate limiting (e.g., using @fastify/rate-limit) on the webhook endpoint to prevent abuse or accidental overload.

  5. 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.

  6. 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.

Frequently Asked Questions

how to set up sinch sms delivery reports

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.

what is sinch sms delivery report (DLR)

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.

why use fastify for sinch dlr

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.

how to test sinch dlr webhook locally

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.

what is prisma used for in sinch dlr

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.

how to send test sms sinch api

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.

when to use per_recipient delivery reports

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.

what is the database schema for sinch dlr

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.

can I use sqlite for sinch dlr project

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.

how to secure sinch dlr webhook

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.

why does sinch dlr need 200 ok response

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.

how to handle sinch dlr errors

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.

what is ngrok and why use with sinch

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.

how to install necessary dependencies sinch dlr

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.