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
- Message Submission: When you
send an SMS with
delivery_report=trueinsms_outgoing, the platform requests a delivery report from the SMSC - SMSC Processing: The SMSC attempts delivery and generates a delivery report
- Report Reception:
Platform receives the delivery report via SMPP and stores it in
sms_delivery_reports - Automatic Matching: System
matches the delivery report to the original outgoing message by:
- SMSC message ID (axid)
- Channel + Mobile + Message text
- Database Update: Original
message in
sms_outgoingis updated with delivery status - Webhook Trigger: If a DLR webhook URL is configured for the client/channel, the webhook is sent (fire-and-forget, non-blocking)
- 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.
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 |
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
}]
}
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.)
Delivery Report Matching Logic
When a delivery report arrives from the SMSC, the platform automatically matches it to the original outgoing message using:
- Primary Match (by SMSC ID): Matches
sms_delivery_reports.axidtosms_outgoing.axid - 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:
delivery_id- ID from sms_delivery_reportsdelivery_status- Status string (DELIVERED, FAILED, etc.)delivery_msg- Original message text from DLRdelivery_date- Delivery timestamp
Web Portal Webhook Setup
- Log in to your SMSDESK portal (e.g.,
https://desk.sms.com.nafor Namibia deployment) - Navigate to Account → SMS Channels
- Click on your channel to edit
- 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)
- DLR Webhook URL: Your webhook endpoint (e.g.,
- Save changes
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:
- Take the exact raw JSON body bytes
- Compute
HMAC-SHA256(key = sharedSecret, message = rawBodyBytes) - Hex-encode the result in lowercase
- Prepend
sha256= - Send in the
X-Signatureheader
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:
- Attempt 1: Immediate
- Attempt 2: After 1 second (first retry)
- Attempt 3: After 2 seconds (final retry)
