Delivery Report (DLR) Webhooks

Receive real-time notifications when your SMS messages are delivered, failed, or change status.

Overview

Delivery Report webhooks notify you of message delivery status changes. The platform will call your configured endpoint with HMAC-SHA256 signed JSON payloads.

How Delivery Reports Work

  1. Message Submission: When you send an SMS with delivery_report=true in sms_outgoing, the platform requests a delivery report from the SMSC
  2. SMSC Processing: The SMSC attempts delivery and generates a delivery report
  3. Report Reception: Platform receives the delivery report via SMPP and stores it in sms_delivery_reports
  4. Automatic Matching: System matches the delivery report to the original outgoing message by:
    • SMSC message ID (axid)
    • Channel + Mobile + Message text
  5. Database Update: Original message in sms_outgoing is updated with delivery status
  6. Webhook Trigger: If a DLR webhook URL is configured for the client/channel, the webhook is sent (fire-and-forget, non-blocking)
Important Disclaimers:
  • SMSC Dependency: Delivery reports are generated and sent by the mobile network operator's SMSC (Short Message Service Center), not by this platform. The platform can only receive and process DLRs if the SMSC sends them.
  • No Guarantee of Delivery Reports: Not all mobile networks support delivery reports. Some networks may not send DLRs at all, even when requested. This is entirely dependent on the network operator's SMSC configuration and capabilities.
  • Delayed Reports: Delivery reports may arrive seconds, minutes, hours, or even days after message submission, depending on network conditions, roaming status, and SMSC processing delays.
  • Missing Reports: Messages may be successfully delivered without ever generating a delivery report. This is a common SMSC limitation and does not indicate platform failure. The absence of a DLR does not mean the message was not delivered.
  • Intermediate Statuses: You may receive multiple delivery reports for the same message as it progresses through different states (e.g., SUBMITTED → PENDING → DELIVERED). Only final statuses (DELIVERED, FAILED, EXPIRED, DELETED) indicate completion.
  • Network-Specific Behavior: DLR availability and reliability varies significantly between mobile networks, countries, and message types (domestic vs. international, numeric vs. alphanumeric sender IDs). International messages typically have lower DLR rates.
  • Platform Limitation: This platform cannot force a network to send delivery reports. If your network operator does not support DLRs or has not enabled them for your account, you will not receive delivery reports regardless of platform configuration.
Technical Note: DLR webhooks are sent using HTTP POST with JSON payload and HMAC-SHA256 signatures. Webhook sending is asynchronous (fire-and-forget) to avoid blocking delivery report processing.
POST https://your-api.com/webhooks/delivery-reports

Payload Format

Request Headers

Content-Type: application/json
X-Signature: sha256=<hmac-sha256-signature>
User-Agent: SMSDesk-Webhook/1.0

Request Payload

{
  "type": "delivery_receipt",
  "timestamp": "2026-07-11T08:15:30.0000000Z",
  "data": {
    "messageId": "987654321",
    "externalId": "SMSC-MSG-12345",
    "mobile": "+264812182326",
    "channel": "44880",
    "status": "DELIVERED",
    "statusCode": 2,
    "statusDescription": "Message delivered to handset",
    "deliveredAt": "2026-07-11T08:15:28.0000000Z",
    "clientId": 1059,
    "reference": "ORDER-12345"
  }
}

Payload Fields

Field Type Description
type string Always "delivery_receipt"
timestamp string (ISO 8601) UTC timestamp when webhook was sent
data.messageId string Internal message ID from sms_outgoing table
data.externalId string SMSC message ID (if available)
data.mobile string Recipient mobile number
data.channel string Sender channel/shortcode used
data.status string Status text (see Status Codes below)
data.statusCode integer Numeric status code (see Status Codes below)
data.statusDescription string Detailed status description
data.deliveredAt string (ISO 8601) UTC timestamp when status changed (may be empty)
data.clientId integer Your client ID
data.reference string Your custom reference ID (if provided when sending)

Status Codes

Code Status Description Final
0 UNKNOWN Status unknown or not yet available No
1 PENDING / SUBMITTED Message submitted to SMSC, awaiting delivery No
2 DELIVERED Message successfully delivered to handset Yes
4 FAILED / UNDELIVERED / EXPIRED Message delivery failed or expired Yes
9 DELETED Message was deleted before delivery Yes
Important: Only final statuses (DELIVERED, FAILED, DELETED) are guaranteed to be the last update. PENDING status may be followed by DELIVERED or FAILED.

Configuration

Enabling Delivery Reports for Outgoing Messages

To receive delivery reports, you must request them when sending messages:

-- When inserting into sms_outgoing table
INSERT INTO smsdesk.sms_outgoing 
    (clientid, channel, mobile, msg, delivery_report, ...)
VALUES 
    (1059, '44880', '+264812182326', 'Hello', true, ...);
    
-- Via API (if using SMS API)
{
  "messages": [{
    "to": "+264812182326",
    "text": "Hello",
    "deliveryReport": true  // Request DLR
  }]
}
Important: Setting delivery_report=true only requests a delivery report from the mobile network's SMSC. Receiving a delivery report is NOT guaranteed and depends entirely on:
  • Whether the mobile network operator supports delivery reports
  • Whether DLRs are enabled for your specific account/connection
  • Network conditions, message routing, and SMSC configuration
  • Message type (domestic vs. international, SMS vs. premium, etc.)
The platform cannot control or guarantee DLR delivery. Contact your network operator to verify DLR support and account configuration.

Delivery Report Matching Logic

When a delivery report arrives from the SMSC, the platform automatically matches it to the original outgoing message using:

  1. Primary Match (by SMSC ID): Matches sms_delivery_reports.axid to sms_outgoing.axid
  2. Fallback Match (by characteristics): If SMSC ID is unavailable, matches by:
    • Channel (shortcode)
    • Mobile number (with + prefix normalization)
    • Message text (partial match)

Once matched, the system updates sms_outgoing with:

Web Portal Webhook Setup

  1. Log in to your SMSDESK portal (e.g., https://desk.sms.com.na for Namibia deployment)
  2. Navigate to Account → SMS Channels
  3. Click on your channel to edit
  4. In Webhooks section, configure:
    • DLR Webhook URL: Your webhook endpoint (e.g., https://YOUR-DOMAIN/webhooks/dlr)
    • DLR Shared Secret: Your HMAC secret (use "Generate" button for secure random key)
  5. Save changes
Note: DLR webhooks are optional. If not configured, delivery reports are still stored in sms_delivery_reports and linked to sms_outgoing, but no webhook notification is sent.

Database Tables

Delivery reports are stored in the following tables:

Table Purpose
sms_outgoing Original outgoing messages with delivery_report=true
sms_delivery_reports Raw delivery reports received from SMSC
client_channels Webhook configuration (delivery_report_webservice_url, dlr_webhook_secret)

Security & Signature Verification

HMAC-SHA256 Signature

All DLR webhooks are signed using HMAC-SHA256 to ensure authenticity:

  1. Take the exact raw JSON body bytes
  2. Compute HMAC-SHA256(key = sharedSecret, message = rawBodyBytes)
  3. Hex-encode the result in lowercase
  4. Prepend sha256=
  5. Send in the X-Signature header

Example Signature Header

X-Signature: sha256=3a7f2c8d9e1f4b5a6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b

Code Examples

Python / Flask

from flask import Flask, request, jsonify
import hmac
import hashlib

app = Flask(__name__)
SECRET = 'your-secret-here'

@app.route('/webhooks/delivery-reports', methods=['POST'])
def delivery_report():
    # Get raw body
    payload = request.get_data(as_text=True)
    
    # Verify signature
    signature = request.headers.get('X-Signature', '')
    if not verify_signature(payload, SECRET, signature):
        return jsonify({'error': 'Invalid signature'}), 401
    
    # Parse JSON
    webhook = request.get_json()
    process_delivery_report(webhook['data'])
    
    return '', 200

def verify_signature(payload, secret, sig):
    if not sig or not sig.startswith('sha256='):
        return False
    
    expected = hmac.new(
        secret.encode('utf-8'),
        payload.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()
    
    received = sig[7:]
    return hmac.compare_digest(expected, received)

def process_delivery_report(data):
    # Update database with delivery status
    update_message_status(
        data['messageId'],
        data['status'],
        data['statusCode'],
        data.get('deliveredAt')
    )
    
    # Trigger notifications
    if data['status'] == 'DELIVERED':
        notify_customer(data.get('reference'))
    
    print(f"DLR: Message {data['messageId']} status: {data['status']}")

if __name__ == '__main__':
    app.run(port=3000)

JavaScript / Node.js / Express

const express = require('express');
const crypto = require('crypto');
const app = express();

const SECRET = 'your-secret-here';

app.post('/webhooks/delivery-reports',
    express.text({ type: 'application/json' }),
    async (req, res) => {
        // Verify signature
        const signature = req.headers['x-signature'];
        if (!verifySignature(req.body, SECRET, signature)) {
            return res.status(401).json({ error: 'Invalid signature' });
        }

        // Parse payload
        const webhook = JSON.parse(req.body);
        await processDeliveryReport(webhook.data);

        res.status(200).send('OK');
    }
);

function verifySignature(payload, secret, sig) {
    if (!sig || !sig.startsWith('sha256=')) return false;
    
    const hmac = crypto.createHmac('sha256', secret);
    hmac.update(payload, 'utf8');
    const expected = hmac.digest('hex');
    
    return crypto.timingSafeEqual(
        Buffer.from(sig.substring(7)),
        Buffer.from(expected)
    );
}

async function processDeliveryReport(data) {
    // Update database
    await db.updateMessageStatus(
        data.messageId,
        data.status,
        data.statusCode,
        data.deliveredAt
    );

    // Trigger notifications
    if (data.status === 'DELIVERED') {
        await notifyCustomer(data.reference);
    }
    
    console.log(`DLR: Message ${data.messageId} status: ${data.status}`);
}

app.listen(3000);

C# / ASP.NET Core

using Microsoft.AspNetCore.Mvc;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

[ApiController]
[Route("webhooks/delivery-reports")]
public class DlrWebhookController : ControllerBase
{
    private readonly string _secret = "your-secret-here";

    [HttpPost]
    public async Task<IActionResult> ReceiveDlr()
    {
        using var reader = new StreamReader(Request.Body);
        string payload = await reader.ReadToEndAsync();

        // Verify signature
        string signature = Request.Headers["X-Signature"];
        if (!VerifySignature(payload, _secret, signature))
            return Unauthorized(new { error = "Invalid signature" });

        // Parse payload
        var webhook = JsonSerializer.Deserialize<DlrWebhook>(payload);
        await ProcessDeliveryReport(webhook.Data);

        return Ok();
    }

    private bool VerifySignature(string payload, string secret, string sig)
    {
        if (string.IsNullOrEmpty(sig) || !sig.StartsWith("sha256="))
            return false;

        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
        var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
        var expected = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
        
        return sig.Substring(7) == expected;
    }

    private async Task ProcessDeliveryReport(DlrData data)
    {
        // Update database with delivery status
        await _db.UpdateMessageStatus(
            data.MessageId,
            data.Status,
            data.StatusCode,
            data.DeliveredAt
        );

        // Trigger notifications
        if (data.Status == "DELIVERED")
        {
            await _notifications.NotifyCustomer(data.Reference);
        }
        
        Console.WriteLine($"DLR: Message {data.MessageId} status: {data.Status}");
    }
}

PHP

<?php
$secret = 'your-secret-here';

// Get raw POST body
$payload = file_get_contents('php://input');

// Get signature header
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';

// Verify signature
if (!verifySignature($payload, $secret, $signature)) {
    http_response_code(401);
    echo json_encode(['error' => 'Invalid signature']);
    exit;
}

// Parse JSON
$webhook = json_decode($payload, true);
processDeliveryReport($webhook['data']);

http_response_code(200);

function verifySignature($payload, $secret, $sig) {
    if (empty($sig) || strpos($sig, 'sha256=') !== 0) {
        return false;
    }
    
    $expected = hash_hmac('sha256', $payload, $secret);
    $received = substr($sig, 7);
    
    return hash_equals($expected, $received);
}

function processDeliveryReport($data) {
    // Update database with delivery status
    updateMessageStatus(
        $data['messageId'],
        $data['status'],
        $data['statusCode'],
        $data['deliveredAt'] ?? null
    );
    
    // Trigger notifications
    if ($data['status'] === 'DELIVERED') {
        notifyCustomer($data['reference'] ?? null);
    }
    
    error_log("DLR: Message {$data['messageId']} status: {$data['status']}");
}
?>

Java / Spring Boot

import org.springframework.web.bind.annotation.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;

@RestController
@RequestMapping("/webhooks/delivery-reports")
public class DlrWebhookController {
    
    private static final String SECRET = "your-secret-here";
    
    @PostMapping
    public ResponseEntity<String> receiveDeliveryReport(
            @RequestBody String payload,
            @RequestHeader("X-Signature") String signature) {
        
        // Verify signature
        if (!verifySignature(payload, SECRET, signature)) {
            return ResponseEntity.status(401)
                .body("{\"error\": \"Invalid signature\"}");
        }
        
        // Parse JSON
        ObjectMapper mapper = new ObjectMapper();
        DlrWebhook webhook = mapper.readValue(payload, DlrWebhook.class);
        processDeliveryReport(webhook.getData());
        
        return ResponseEntity.ok("");
    }
    
    private boolean verifySignature(String payload, String secret, String sig) {
        if (sig == null || !sig.startsWith("sha256=")) {
            return false;
        }
        
        try {
            Mac hmac = Mac.getInstance("HmacSHA256");
            SecretKeySpec secretKey = new SecretKeySpec(
                secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
            hmac.init(secretKey);
            
            byte[] hash = hmac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
            String expected = bytesToHex(hash);
            String received = sig.substring(7);
            
            return MessageDigest.isEqual(
                expected.getBytes(StandardCharsets.UTF_8),
                received.getBytes(StandardCharsets.UTF_8)
            );
        } catch (Exception e) {
            return false;
        }
    }
    
    private String bytesToHex(byte[] bytes) {
        StringBuilder result = new StringBuilder();
        for (byte b : bytes) {
            result.append(String.format("%02x", b));
        }
        return result.toString();
    }
    
    private void processDeliveryReport(DlrData data) {
        // Update database with delivery status
        updateMessageStatus(
            data.getMessageId(),
            data.getStatus(),
            data.getStatusCode(),
            data.getDeliveredAt()
        );
        
        // Trigger notifications
        if ("DELIVERED".equals(data.getStatus())) {
            notifyCustomer(data.getReference());
        }
        
        System.out.println("DLR: Message " + data.getMessageId() + " status: " + data.getStatus());
    }
}

Retry Logic

Expected Response Codes

Response Action Description
200 OK Success Webhook processed successfully
204 No Content Success Webhook processed, no response needed
4xx Client Error No Retry Client error, will not retry
5xx Server Error Retry Server error, will retry with backoff
Timeout / Network Error Retry Network issue, will retry with backoff

Retry Schedule

If your endpoint returns a 5xx error or times out, the platform will retry up to 2 times (3 total attempts) with exponential backoff:

Important: After 2 failed retries (3 total attempts), the webhook will be marked as failed and no further attempts will be made for that specific delivery report.
Ready to Start? Configure your DLR webhook in your SMSDESK portal and start tracking message delivery status!