code examples

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

Node.js & Express Guide: Sending SMS and Handling Delivery Status Callbacks with Sinch

A comprehensive guide to building a Node.js application using Express to send SMS messages via the Sinch API and handle delivery status report (DLR) callbacks.

Node.js & Express Guide: Sending SMS and Handling Delivery Status Callbacks with Sinch

This guide provides a complete walkthrough for building a Node.js application using the Express framework to send SMS messages via the Sinch API and reliably handle delivery status report (DLR) callbacks. Understanding SMS delivery status is crucial for applications requiring confirmation that messages reached the recipient's handset, enabling features like status tracking, analytics, and automated retries.

We will build a simple application that:

  1. Provides an API endpoint to send an SMS message.
  2. Configures Sinch to send delivery status updates to our application via webhooks.
  3. Includes an Express route to receive and process these delivery status callbacks.
  4. Logs the relevant information for monitoring and debugging.

Technologies Used:

  • Node.js: A JavaScript runtime environment for building server-side applications.
  • Express.js: A minimal and flexible Node.js web application framework used here to create API endpoints and handle incoming webhooks.
  • Axios: A promise-based HTTP client for making requests to the Sinch API.
  • dotenv: A module to load environment variables from a .env file, keeping sensitive credentials secure.
  • Sinch SMS API: The third-party service used for sending SMS messages.
  • ngrok (for development): A tool to expose local web servers to the internet, necessary for receiving Sinch webhooks during development.

Prerequisites:

  • A Sinch account with SMS API credentials (Service Plan ID, API Token). (Sign up or log in here)
  • A provisioned Sinch phone number.
  • Node.js and npm (or yarn) installed on your system.
  • Basic familiarity with Node.js, Express, and REST APIs.
  • ngrok installed globally (optional but recommended for local development): npm install -g ngrok

System Architecture:

+-----------------+ 1. Send SMS Request +------------+ 2. Send SMS +-----------+ | Your Node.js | ----------------------------> | Sinch API | -------------------> | Recipient | | Application | (POST /v1/batches) | | | (Phone) | | (Express Server)| | | +-----------+ | | 4. Delivery Status | | ^ | | <---------------------------- | | | 3. Carrier | | (POST /webhooks/dlr) | | | Delivery Status +-----------------+ +------------+ v +-----------+ | Carrier | +-----------+
  1. Your application sends an SMS request to the Sinch API /batches endpoint, specifying the recipient, message body, and requesting a delivery report.
  2. Sinch processes the request and sends the SMS towards the recipient's carrier.
  3. The carrier attempts delivery and sends status updates back to Sinch.
  4. Sinch forwards the final delivery status (e.g., Delivered, Failed) to the callback URL configured in your Sinch account, which points to your application's webhook handler endpoint.

By the end of this guide, you will have a functional Node.js application capable of sending SMS messages and logging their delivery status received via Sinch webhooks.

1. Setting Up the Project

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

  1. Create Project Directory: Open your terminal and create a new directory for the project, then navigate into it.

    bash
    mkdir sinch-sms-dlr-app
    cd sinch-sms-dlr-app
  2. Initialize Node.js Project: Run npm init to create a package.json file. You can accept the defaults by pressing Enter repeatedly or customize as needed.

    bash
    npm init -y

    (The -y flag automatically accepts the defaults).

  3. Install Dependencies: We need express for the web server, axios to make HTTP requests to Sinch, and dotenv to manage environment variables.

    bash
    npm install express axios dotenv
  4. Create Project Structure: Set up a basic file structure:

    sinch-sms-dlr-app/ ├── .env # Stores environment variables (API keys, etc.) ├── .gitignore # Specifies intentionally untracked files that Git should ignore ├── index.js # Main application file ├── package.json └── node_modules/ # Created by npm install
  5. Create .gitignore: Create a .gitignore file in the root directory to prevent committing sensitive information and unnecessary files (like node_modules and .env).

    plaintext
    # .gitignore
    node_modules/
    .env
    npm-debug.log*
    yarn-debug.log*
    yarn-error.log*
  6. Set Up Environment Variables (.env): Create a .env file in the root directory. This file will hold your Sinch credentials and configuration. Never commit this file to version control.

    plaintext
    # .env
    
    # Sinch API Credentials
    SINCH_SERVICE_PLAN_ID=YOUR_SERVICE_PLAN_ID
    SINCH_API_TOKEN=YOUR_API_TOKEN
    SINCH_NUMBER=YOUR_SINCH_VIRTUAL_NUMBER # e.g., +12025550181
    SINCH_API_REGION=us # Or eu, ca, au, br, etc. based on your account region
    
    # Application Configuration
    PORT=3000

    How to Obtain Sinch Credentials:

    • SINCH_SERVICE_PLAN_ID and SINCH_API_TOKEN:
      1. Log in to your Sinch Customer Dashboard (https://dashboard.sinch.com/login).
      2. Navigate to SMS > APIs in the left-hand menu.
      3. Your Service plan ID is listed here.
      4. Under API Credentials, find your API token. Click Show to reveal it.
      5. Copy both values into your .env file.
    • SINCH_NUMBER:
      1. In the Sinch Dashboard, navigate to Numbers > Your virtual numbers.
      2. Copy one of your active Sinch numbers capable of sending SMS (ensure it's in E.164 format, e.g., +12025550181).
    • SINCH_API_REGION:
      1. Check the region associated with your Service Plan ID in the SMS > APIs section of the dashboard. Common values are us, eu, ca, au, br. Use the correct subdomain prefix for the API base URL (e.g., us.sms.api.sinch.com).

2. Implementing Core Functionality

Now, let's write the code in index.js to set up the Express server, send SMS messages, and handle incoming delivery report webhooks.

javascript
// index.js
require('dotenv').config(); // Load environment variables from .env file
const express = require('express');
const axios = require('axios');

const app = express();

// Middleware to parse JSON request bodies
// Sinch sends webhooks as JSON
app.use(express.json());

// --- Configuration ---
const PORT = process.env.PORT || 3000;
const SINCH_SERVICE_PLAN_ID = process.env.SINCH_SERVICE_PLAN_ID;
const SINCH_API_TOKEN = process.env.SINCH_API_TOKEN;
const SINCH_NUMBER = process.env.SINCH_NUMBER;
const SINCH_API_REGION = process.env.SINCH_API_REGION || 'us'; // Default to 'us' if not set
const SINCH_API_BASE_URL = `https://${SINCH_API_REGION}.sms.api.sinch.com/xms/v1/`;

// Basic validation to ensure credentials are loaded
if (!SINCH_SERVICE_PLAN_ID || !SINCH_API_TOKEN || !SINCH_NUMBER) {
    console.error(""Error: Missing Sinch credentials in .env file. Please check your configuration."");
    process.exit(1); // Exit if critical configuration is missing
}

// --- Sinch API Client Setup ---
const sinchClient = axios.create({
    baseURL: SINCH_API_BASE_URL + SINCH_SERVICE_PLAN_ID,
    headers: {
        'Authorization': `Bearer ${SINCH_API_TOKEN}`,
        'Content-Type': 'application/json'
    }
});

// --- Function to Send SMS ---
async function sendSms(recipientNumber, messageBody) {
    console.log(`Attempting to send SMS to ${recipientNumber}`);
    try {
        const payload = {
            from: SINCH_NUMBER,
            to: [recipientNumber], // Must be an array
            body: messageBody,
            // Request a detailed delivery report for each recipient status change
            // Options: 'none', 'summary', 'full', 'per_recipient'
            // 'full' or 'per_recipient' are best for detailed tracking.
            // 'full' sends one callback per batch status change.
            // 'per_recipient' sends a callback for *each* recipient's status change (can be noisy).
            // We use 'full' here for simplicity, but often 'per_recipient' is needed.
            delivery_report: 'full',
            // Optional: You can override the global callback URL per message
            // callback_url: 'YOUR_SPECIFIC_CALLBACK_URL_HERE'
            // Optional: Client reference for correlating DLRs
            // client_reference: `my_internal_id_${Date.now()}`
        };

        const response = await sinchClient.post('/batches', payload);

        console.log('Sinch API Send Request Successful:');
        console.log(`Batch ID: ${response.data.id}`);
        console.log(`Sent to: ${response.data.to.join(', ')}`);
        // Return the batch ID for potential future tracking
        return { success: true, batchId: response.data.id };

    } catch (error) {
        console.error('Error sending SMS via Sinch API:');
        if (error.response) {
            // The request was made and the server responded with a status code
            // that falls out of the range of 2xx
            console.error('Status:', error.response.status);
            console.error('Headers:', error.response.headers);
            console.error('Data:', error.response.data);
        } else if (error.request) {
            // The request was made but no response was received
            console.error('Request Error:', error.request);
        } else {
            // Something happened in setting up the request that triggered an Error
            console.error('Error Message:', error.message);
        }
        return { success: false, error: error.message };
    }
}

// --- API Endpoint to Trigger SMS Sending ---
// Example: POST /send-sms with JSON body: { ""to"": ""+15551234567"", ""message"": ""Hello from Sinch!"" }
app.post('/send-sms', async (req, res) => {
    const { to, message } = req.body;

    // Basic Input Validation
    if (!to || !message) {
        return res.status(400).json({ error: 'Missing ""to"" or ""message"" in request body' });
    }
    // Add more robust validation (e.g., phone number format) in production
    if (!/^\+[1-9]\d{1,14}$/.test(to)) {
         return res.status(400).json({ error: 'Invalid ""to"" phone number format. Use E.164 format (e.g., +15551234567).' });
    }

    const result = await sendSms(to, message);

    if (result.success) {
        res.status(202).json({ message: 'SMS send request accepted by Sinch.', batchId: result.batchId });
    } else {
        res.status(500).json({ error: 'Failed to send SMS.', details: result.error });
    }
});

// --- Webhook Endpoint for Delivery Reports (DLRs) ---
// Sinch will POST delivery status updates to this endpoint
// The exact path '/webhooks/dlr' should match the callback URL configured in Sinch
app.post('/webhooks/dlr', (req, res) => {
    console.log('--- Received Sinch Delivery Report ---');
    console.log('Timestamp:', new Date().toISOString());
    console.log('Request Body:', JSON.stringify(req.body, null, 2)); // Pretty print the JSON

    // Structure of the DLR payload can vary based on 'delivery_report' setting and event type.
    // Check Sinch documentation for the exact format based on your 'delivery_report' setting.
    // Example for 'full' or 'per_recipient':
    const { batch_id, status, recipient, client_reference, applied_originator } = req.body;

    // Process the DLR - In a real application, you would:
    // 1. Validate the request (e.g., check a signature if provided by Sinch).
    // 2. Look up the message/batch in your database using batch_id or client_reference.
    // 3. Update the message status in your database (e.g., 'DELIVERED', 'FAILED', etc.).
    // 4. Trigger any follow-up actions based on the status.

    console.log(`Status for Batch ID [${batch_id}] to Recipient [${recipient || 'N/A'}]: ${status}`);
    if (client_reference) {
        console.log(`Client Reference: ${client_reference}`);
    }

    // Always respond to Sinch with a 200 OK quickly to acknowledge receipt.
    // Failure to respond promptly may cause Sinch to retry the webhook.
    res.status(200).send('OK');
});

// --- Basic Health Check Endpoint ---
app.get('/health', (req, res) => {
    res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() });
});

// --- Start the Server ---
app.listen(PORT, () => {
    console.log(`Server running on http://localhost:${PORT}`);
    console.log(`Sinch API endpoint: ${SINCH_API_BASE_URL}${SINCH_SERVICE_PLAN_ID}`);
    console.log(`Configured Sinch Number: ${SINCH_NUMBER}`);
    console.log('Waiting for incoming requests and webhooks...');
    console.log('\n--- IMPORTANT ---');
    console.log('For local development, ensure ngrok is running and');
    console.log('the ngrok HTTPS URL is configured as the Callback URL');
    console.log('in your Sinch dashboard for the Service Plan ID.');
    console.log('Example ngrok command: ngrok http 3000');
    console.log('Example Callback URL: https://your-unique-ngrok-id.ngrok.io/webhooks/dlr');
    console.log('--------------- \n');
});

Code Explanation:

  1. Dependencies & Config: Loads dotenv, express, axios. Sets up constants for port and Sinch credentials loaded from .env. Includes basic validation to ensure credentials exist. Constructs the base URL for the Sinch API based on the region.
  2. Axios Instance (sinchClient): Creates a pre-configured axios instance with the correct baseURL (including the Service Plan ID) and authentication headers (Authorization: Bearer YOUR_API_TOKEN). This simplifies making API calls.
  3. sendSms Function:
    • Takes the recipientNumber and messageBody as arguments.
    • Constructs the payload for the Sinch /batches endpoint.
      • from: Your Sinch virtual number.
      • to: An array containing the recipient's phone number (E.164 format).
      • body: The text message content.
      • delivery_report: 'full': Crucially, this tells Sinch to send a comprehensive delivery status report back via webhook. You could also use 'per_recipient' for even more granular (but potentially noisier) updates. 'none' or 'summary' won't provide the detailed status needed.
      • client_reference (Optional but Recommended): Include a unique identifier from your system. This ID will be included in the DLR webhook, making it easier to correlate the status update back to the original message in your database.
    • Uses sinchClient.post to send the request.
    • Includes robust try...catch error handling, logging details if the API call fails.
    • Returns the batchId on success, which is Sinch's identifier for this message send operation.
  4. /send-sms Endpoint (POST):
    • Defines a route to trigger sending an SMS. It expects a JSON body like { ""to"": ""+1..."", ""message"": ""..."" }.
    • Performs basic validation on the input. In production, add more robust validation (e.g., using a library like joi or express-validator) and sanitization.
    • Calls the sendSms function.
    • Responds with 202 Accepted if the request to Sinch was successful (SMS sending is asynchronous) or 500 Internal Server Error if it failed.
  5. /webhooks/dlr Endpoint (POST):
    • This is the endpoint Sinch will call with delivery status updates. The path /webhooks/dlr must exactly match the Callback URL you configure in the Sinch dashboard.
    • Uses express.json() middleware to automatically parse the incoming JSON payload from Sinch.
    • Logs the received DLR payload. In a real application, this is where you'd parse the status, batch_id, recipient, etc., and update your application's database accordingly.
    • Sends a 200 OK response back to Sinch immediately to acknowledge receipt. Processing the DLR should happen asynchronously if it's time-consuming, to avoid timeouts.
  6. /health Endpoint (GET): A simple endpoint for monitoring systems to check if the application is running.
  7. Server Start: Starts the Express server, listening on the configured PORT. Logs helpful information, including the reminder about ngrok for local development.

3. Configuring Sinch Callback URL

For Sinch to send delivery reports back to your running application, you need to configure a callback URL in your Sinch dashboard. During local development, this requires exposing your local server to the internet.

  1. Start Your Node.js Application:

    bash
    node index.js

    You should see the server start message and the prompt about ngrok.

  2. Start ngrok: Open another terminal window and run ngrok, pointing it to the port your application is running on (default is 3000).

    bash
    ngrok http 3000

    ngrok will display output similar to this:

    Session Status online Account Your Name (Plan: Free) Version x.x.x Region United States (us) Web Interface http://127.0.0.1:4040 Forwarding http://xxxxxxxx.ngrok.io -> http://localhost:3000 Forwarding https://xxxxxxxx.ngrok.io -> http://localhost:3000 # <-- COPY THIS HTTPS URL Connections ttl opn rt1 rt5 p50 p90 0 0 0.00 0.00 0.00 0.00

    Copy the https:// forwarding URL. This is the public URL for your local server.

  3. Configure Sinch Dashboard:

    • Go to your Sinch Customer Dashboard.
    • Navigate to SMS > APIs.
    • Click on your Service Plan ID.
    • Scroll down to the Callback URLs section.
    • Find the Default callback URL for delivery reports field (or similar wording for delivery report callbacks).
    • Paste the https:// ngrok URL you copied, appending the webhook path /webhooks/dlr. The final URL should look like: https://xxxxxxxx.ngrok.io/webhooks/dlr
    • Click Save.
    • (In a production environment, you would use the stable public URL of your deployed application instead of an ngrok URL.)

Now, when Sinch has a delivery status update for an SMS sent using this Service Plan ID (and where delivery_report was requested), it will send a POST request to your ngrok URL, which will forward it to your local application's /webhooks/dlr endpoint.

4. Verification and Testing

Let's test the entire flow:

  1. Ensure Both Processes Are Running:

    • Your Node.js application (node index.js).
    • ngrok forwarding to your application's port.
  2. Send an SMS via the API: Use a tool like curl or Postman to send a POST request to your application's /send-sms endpoint. Replace +1xxxxxxxxxx with a real phone number you can check.

    bash
    curl -X POST http://localhost:3000/send-sms \
         -H ""Content-Type: application/json"" \
         -d '{
               ""to"": ""+1xxxxxxxxxx"",
               ""message"": ""Hello from Node/Sinch! Testing DLR. [Timestamp: '""$(date)""']""
             }'
  3. Check Application Logs (Send Request): In the terminal running node index.js, you should see logs indicating the attempt to send the SMS and the response from the Sinch API, including the batchId.

    Attempting to send SMS to +1xxxxxxxxxx Sinch API Send Request Successful: Batch ID: 01HXXXXXXXXXXXXXXEXAMPLEID Sent to: +1xxxxxxxxxx
  4. Check Mobile Phone: The recipient phone number should receive the SMS message shortly.

  5. Check ngrok Console: The terminal running ngrok might show incoming POST requests to /webhooks/dlr as Sinch sends status updates.

  6. Check Application Logs (DLR Callback): Wait a few seconds or minutes (delivery time varies). In the terminal running node index.js, you should see logs indicating a received delivery report webhook:

    --- Received Sinch Delivery Report --- Timestamp: 2023-04-20T10:30:00.123Z Request Body: { ""batch_id"": ""01HXXXXXXXXXXXXXXEXAMPLEID"", ""status"": ""Delivered"", ""recipient"": ""+1xxxxxxxxxx"", ""operator_status_at"": ""2023-04-20T10:30:00.000Z"", ""type"": ""delivery_report_sms"", ""code"": 0, ""client_reference"": null // Or your reference if you sent one // ... other potential fields } Status for Batch ID [01HXXXXXXXXXXXXXXEXAMPLEID] to Recipient [+1xxxxxxxxxx]: Delivered

    The status field will show the final delivery state (e.g., Delivered, Failed, Expired). If you used delivery_report: 'per_recipient', you might receive intermediate statuses like Dispatched or Queued as well.

  7. Test Failure Scenario (Optional): Try sending to an invalid or deactivated number to observe a Failed status in the DLR callback.

5. Error Handling, Logging, and Retries

  • Error Handling: The sendSms function includes basic try...catch blocks logging detailed errors from axios (status codes, response data). The webhook handler logs the incoming payload. Production applications need more sophisticated error tracking (e.g., Sentry, Datadog).
  • Logging: We use console.log. For production, use a structured logger like pino or winston to output JSON logs, making them easier to parse and analyze. Log key events: SMS request received, API call attempt, API response (success/failure), DLR received, DLR processing result. Include correlation IDs (batch_id, client_reference) in logs.
  • Retry Mechanisms (API Calls): If the initial POST /batches call fails due to network issues or temporary Sinch problems (5xx errors), implement a retry strategy with exponential backoff using libraries like axios-retry or async-retry.
  • Retry Mechanisms (Webhooks): Sinch will automatically retry sending webhooks if your endpoint doesn't respond with a 2xx status code within a certain timeout. Ensure your /webhooks/dlr endpoint responds quickly (200 OK) and handles processing potentially asynchronously (e.g., pushing the DLR payload to a queue like Redis or RabbitMQ for later processing) to avoid causing unnecessary retries from Sinch. Make your webhook handler idempotent – processing the same DLR multiple times should not cause issues (e.g., check if the status for that batch_id/recipient has already been updated).

6. Database Schema and Data Layer (Conceptual)

While this guide doesn't implement a database, a real-world application would need one to store message information and track status.

Conceptual Schema (e.g., PostgreSQL):

sql
CREATE TABLE sms_messages (
    message_id SERIAL PRIMARY KEY,           -- Internal unique ID
    sinch_batch_id VARCHAR(255) UNIQUE,      -- Batch ID from Sinch API response
    client_reference VARCHAR(255) UNIQUE,    -- Optional: Your internal reference sent to Sinch
    recipient_number VARCHAR(20) NOT NULL,
    sender_number VARCHAR(20) NOT NULL,
    message_body TEXT,
    status VARCHAR(50) DEFAULT 'Pending',   -- e.g., Pending, Sent, Delivered, Failed, Expired
    send_requested_at TIMESTAMPTZ DEFAULT NOW(),
    last_status_update_at TIMESTAMPTZ,
    sinch_status_code INT,                   -- Status code from DLR
    error_message TEXT,                      -- Store failure reasons
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Index for efficient lookup by Sinch identifiers
CREATE INDEX idx_sms_sinch_batch_id ON sms_messages(sinch_batch_id);
CREATE INDEX idx_sms_client_reference ON sms_messages(client_reference);
CREATE INDEX idx_sms_status ON sms_messages(status);

Data Layer Logic:

  1. Before Sending: Create a record in sms_messages with status='Pending', potentially generating and storing a client_reference.
  2. After Sending (API Success): Update the record with the sinch_batch_id received from the API response and set status='Sent'.
  3. On DLR Webhook:
    • Look up the message using sinch_batch_id or client_reference from the webhook payload.
    • Update the status (e.g., 'Delivered', 'Failed'), sinch_status_code, last_status_update_at, and error_message (if applicable).
    • Update updated_at.

Use an ORM like Prisma or Sequelize to manage migrations and interact with the database safely.

7. Security Features

  • Webhook Security:
    • HTTPS: Always use HTTPS for your callback URL. ngrok provides this automatically. Production deployments must have valid SSL/TLS certificates.
    • Secret/Signature Verification (Recommended): Check if Sinch supports sending a signature (e.g., HMAC-SHA256) in the webhook request header based on a shared secret. If so, configure a secret in the dashboard and verify the signature in your /webhooks/dlr handler before processing. This ensures the request genuinely came from Sinch. If Sinch doesn't offer this for SMS DLRs, consider adding a unique, hard-to-guess path or a secret query parameter to your callback URL (less secure but better than nothing).
    • IP Whitelisting: If Sinch publishes the IP addresses used for sending webhooks, configure your firewall or infrastructure to only allow requests to your webhook endpoint from those IPs.
  • API Key Security: Store SINCH_API_TOKEN and other secrets securely in environment variables (.env locally, managed secrets in deployment). Never hardcode them in your source code. Use .gitignore to prevent committing .env.
  • Input Validation: Rigorously validate all incoming data, especially in the /send-sms endpoint (phone number format, message length) and the /webhooks/dlr endpoint (expected fields, data types). Use libraries like joi or express-validator.
  • Rate Limiting: Implement rate limiting on your API endpoints (/send-sms) and especially the webhook endpoint (/webhooks/dlr) to prevent abuse or accidental overload. Use libraries like express-rate-limit.
  • Dependencies: Keep dependencies updated (npm audit, npm update) to patch known vulnerabilities.

8. Handling Special Cases

  • Time Zones: Timestamps from Sinch (e.g., operator_status_at) are typically in UTC (ISO 8601 format). Store timestamps in your database using TIMESTAMPTZ (Timestamp with Time Zone) in PostgreSQL or equivalent, which usually stores in UTC and handles conversions. Be mindful of time zone conversions when displaying times to users.
  • Character Encoding/Limits: Standard SMS messages have character limits (160 for GSM-7, 70 for UCS-2). Longer messages are split into multiple parts (concatenated SMS). Sinch handles this splitting, but it affects billing. Be aware of how special characters might force UCS-2 encoding, reducing the characters per part. The batches API has parameters like truncate_concat and max_number_of_message_parts if needed.
  • Invalid Numbers: DLRs for invalid numbers will typically result in a Failed status with a specific error code. Log these codes for analysis.
  • Opt-Outs/STOP Keywords: Sinch often handles standard STOP keyword replies automatically if configured. Ensure you respect opt-outs and don't send further messages to users who have replied STOP. DLRs might indicate failure if sent to an opted-out number.
  • DLR Delays: Delivery reports are not always instantaneous. They depend on carrier reporting speed. Your application should handle potential delays gracefully. Status might remain 'Sent' for some time before updating.
  • DLR Format Variations: The exact fields in the DLR payload can sometimes vary slightly depending on the carrier, region, or the specific delivery_report type requested (full vs. per_recipient). Log the full payload during development to understand the structure you receive.

9. Performance Optimizations

  • Asynchronous Processing: Process DLR webhooks asynchronously. The main handler should quickly validate (if possible), acknowledge (200 OK), and then pass the payload to a background job queue (e.g., BullMQ with Redis, RabbitMQ) for database updates and other logic. This prevents blocking the event loop and avoids webhook timeouts/retries from Sinch.
  • Database Indexing: Ensure proper database indexes (as shown in section 6) on fields used for lookups (sinch_batch_id, client_reference, status).
  • Connection Pooling: Use database connection pooling (typically handled by ORMs like Prisma/Sequelize) to reuse database connections efficiently.
  • Load Testing: Use tools like k6, artillery, or autocannon to test how your /send-sms endpoint and /webhooks/dlr handler perform under load. Identify and address bottlenecks.
  • Caching: If you frequently query the status of recently sent messages, consider caching status information (e.g., in Redis) for a short duration to reduce database load, but be mindful of cache invalidation when a new DLR arrives.

10. Monitoring, Observability, and Analytics

  • Health Checks: The /health endpoint provides a basic check. Production systems need more comprehensive health checks that verify database connectivity and potentially Sinch API reachability.
  • Metrics: Instrument your application to collect key metrics using libraries like prom-client (for Prometheus):
    • Rate of outgoing SMS requests (/send-sms calls).
    • Rate of incoming DLR webhooks (/webhooks/dlr calls).
    • Latency of Sinch API calls (/batches).
    • Histogram of DLR processing times.
    • Counts of successful vs. failed SMS sends (based on DLRs).
    • Counts of different DLR statuses (Delivered, Failed, Expired).
    • Application error rates.
  • Logging: Centralized, structured logging (ELK stack, Grafana Loki, Datadog Logs) is essential for debugging and analysis. Include correlation IDs.
  • Error Tracking: Integrate services like Sentry or Datadog APM to automatically capture and aggregate application errors with stack traces and context.
  • Dashboards: Create dashboards (e.g., in Grafana, Datadog) visualizing the metrics collected above to monitor system health and SMS delivery performance in real-time.
  • Alerting: Configure alerts based on metrics (e.g., high API error rate, high SMS failure rate, DLR webhook processing latency exceeding threshold, health check failures) using Prometheus Alertmanager, Grafana alerting, or your monitoring platform's tools.

11. Troubleshooting and Caveats

  • DLRs Not Received:
    • Check Callback URL: Ensure the URL in the Sinch dashboard is correct, uses HTTPS, includes the /webhooks/dlr path, and points to your publicly accessible server (or ngrok tunnel).
    • Check delivery_report Parameter: Verify you are sending delivery_report: 'full' or delivery_report: 'per_recipient' in your /batches request payload. 'none' or 'summary' won't provide detailed status callbacks.
    • Check Server/Firewall: Ensure your server is running and accessible from the internet. Check firewall rules aren't blocking incoming POST requests from Sinch IPs (if known/used).
    • Check Application Logs: Look for errors in your webhook handler that might cause it to crash or return non-200 status codes.
    • Check Sinch Logs: Investigate logs within the Sinch dashboard (if available) for errors related to callback attempts.
    • Check ngrok: Ensure ngrok is running and hasn't timed out (free plans have limits). Check the ngrok web interface (http://127.0.0.1:4040 by default) for request logs and errors.
  • Incorrect DLR Status:
    • Carrier Limitations: Some carriers provide limited or delayed delivery information. A Delivered status usually means delivered to the handset, but edge cases exist. Failed might have specific error codes indicating the reason (invalid number, blocked, etc.).
    • Check delivery_report Type: Ensure you're using the appropriate type (full or per_recipient) for the level of detail you need.
  • Webhook Handler Issues:
    • Timeouts: If your handler takes too long to process, Sinch might retry, leading to duplicate processing. Acknowledge quickly (200 OK) and process asynchronously.
    • Idempotency: Design your handler to safely process the same DLR multiple times without side effects.
    • Parsing Errors: Ensure your code correctly parses the JSON payload from Sinch. Log the raw body on error.
  • API Call Failures:
    • Credentials: Double-check SINCH_SERVICE_PLAN_ID, SINCH_API_TOKEN, SINCH_NUMBER, and SINCH_API_REGION in your .env file.
    • Rate Limits: Check if you are exceeding Sinch API rate limits.
    • Network Issues: Ensure your server has outbound connectivity to the Sinch API endpoint.
    • Invalid Payload: Validate the structure and content of the payload sent to /batches against the Sinch API documentation. Check recipient number format (E.164).

Frequently Asked Questions

Why am I not receiving Sinch DLR callbacks?

Possible reasons include incorrect callback URL configuration in the Sinch dashboard, forgetting to set 'delivery_report' in the send request, server or firewall issues, or errors in the webhook handler code. Check logs in your application, ngrok, and the Sinch dashboard for clues.

How to send SMS messages with Sinch and Node.js?

Use the Sinch SMS API with a Node.js library like Axios to send messages. Create a request payload including the recipient number, message body, your Sinch number, and set 'delivery_report' to 'full' or 'per_recipient' to receive delivery status updates via webhooks. Send the POST request to the /batches endpoint of the Sinch API using your Service Plan ID and API token for authentication.

What is a delivery status report (DLR) callback with Sinch?

A DLR callback is a notification Sinch sends to your application's webhook URL when the status of a sent SMS message changes (e.g., delivered, failed). This provides real-time feedback on message delivery outcomes.

Why use ngrok with Sinch SMS API during development?

ngrok creates a public, secure tunnel to your locally running application, allowing Sinch to send DLR webhooks to your local development environment. It's essential for testing the callback functionality without deploying your application.

When should I use 'full' vs. 'per_recipient' for Sinch delivery reports?

Use 'full' for a summary of delivery status changes per message batch, suitable for basic tracking. 'per_recipient' sends individual callbacks for each recipient's status change (more detailed but potentially noisier), which is often preferred for granular tracking and analysis.

Can I handle Sinch DLR webhooks in Express.js?

Yes, create a POST route in your Express app that matches the callback URL configured in your Sinch account. The route handler should parse the JSON payload, log the status, and update your internal systems based on the reported message status (e.g., delivered, failed). Ensure the endpoint responds quickly with 200 OK.

How to set up Sinch API credentials in a Node.js project?

Store your Sinch Service Plan ID, API Token, Sinch virtual number, and region in a .env file. Load these environment variables into your Node.js application at runtime using the 'dotenv' package. Never hardcode API credentials directly in your source code, as it poses security risks.

What is the purpose of the 'client_reference' field in Sinch API requests?

The optional 'client_reference' field lets you send a custom identifier (e.g., your internal message ID) along with the SMS request. Sinch includes this reference in the DLR callback, allowing you to easily link the status update to the original message in your database.

How to configure Sinch callback URL for delivery reports?

Log into your Sinch Dashboard, navigate to SMS > APIs > Your Service Plan ID. Under 'Callback URLs', enter the HTTPS URL of your application's webhook endpoint, which is usually your server address plus '/webhooks/dlr', e.g., https://your-server.com/webhooks/dlr or https://your-ngrok-id.ngrok.io/webhooks/dlr.

What does 'status: Delivered' mean in a Sinch DLR callback?

The 'Delivered' status typically indicates successful delivery of the SMS message to the recipient's handset. However, there can be carrier-specific nuances, and it's not always a 100% guarantee of being read by the recipient.

How to handle DLR callbacks asynchronously in Node.js?

Use a queue system like Redis, RabbitMQ, or BullMQ. Your webhook handler should quickly acknowledge receipt of the DLR and then place the DLR data onto the queue for background processing. This improves responsiveness and prevents blocking the main thread.

What are best practices for Sinch webhook security?

Always use HTTPS. If Sinch supports it, implement signature verification using a shared secret to authenticate webhooks. Consider IP whitelisting if Sinch provides its outgoing IP addresses. In any case, rate-limit your webhook endpoint to mitigate abuse.

How to handle SMS message failures reported by Sinch DLR?

Log the failure reason and status code from the DLR. Update your application's message status accordingly. Implement retry mechanisms with exponential backoff, but respect potential opt-outs or blocks to avoid excessive sending attempts. Notify administrators or users about critical failures.

How should I design my database schema for storing SMS messages and DLR status?

Create a table with columns for a unique message ID, Sinch batch ID, client reference, recipient, sender, message body, status, timestamps, error messages, and any other relevant metadata. Index the Sinch batch ID and client reference for fast lookups.

What are some performance tips for handling large volumes of SMS messages and DLRs?

Process DLRs asynchronously. Use database indexing for efficient lookups. Implement connection pooling for database interactions. Load test your application to identify bottlenecks. Consider caching status information (with appropriate invalidation) for frequently accessed data.