Webhooks Documentation
Real-time notifications for delivery receipts and opt-out events with HMAC-SHA256
signature verification.
Overview
The SMSDESK platform supports three types of webhooks:
| Type |
Purpose |
Trigger |
Security |
| INCOMING |
Incoming SMS |
SMS received on your channel |
Security Key (Base64) |
| DLR |
Delivery Receipt |
Message delivered, failed, or status changed |
HMAC-SHA256 |
| OPT-OUT |
Blacklist/STOP |
Contact sends STOP keyword |
HMAC-SHA256 |
ℹ️ Note: DLR and Opt-out webhooks use HMAC-SHA256 signatures. Incoming SMS webhooks use
a security key with Base64 encoding for backward compatibility.
2. Security & Authentication
HMAC-SHA256 Signature
Every webhook request is signed using HMAC-SHA256 to ensure authenticity.
Signature Process
- Take the exact raw JSON body bytes (no normalization or whitespace changes)
- Compute
HMAC-SHA256(key = sharedSecret, message = rawBodyBytes)
- Hex-encode the result in lowercase
- Prepend
sha256=
- Send in the
X-Signature header
Example Header
X-Signature: sha256=3a7f2c8d9e1f4b5a6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b
⚠️ Important: The signature must be calculated using the exact bytes sent on the
wire.
Any re-serialization will result in a different hash.
Signature Verification (C# Example)
using System.Security.Cryptography;
using System.Text;
public bool VerifySignature(string payload, string secret, string signatureHeader)
{
if (string.IsNullOrEmpty(signatureHeader) || !signatureHeader.StartsWith("sha256="))
return false;
// Calculate expected signature
byte[] payloadBytes = Encoding.UTF8.GetBytes(payload);
byte[] secretBytes = Encoding.UTF8.GetBytes(secret);
using (var hmac = new HMACSHA256(secretBytes))
{
byte[] hashBytes = hmac.ComputeHash(payloadBytes);
string expectedSignature = BitConverter.ToString(hashBytes)
.Replace("-", "")
.ToLowerInvariant();
string receivedSignature = signatureHeader.Substring(7); // Remove "sha256="
// Constant-time comparison
return CryptographicEquals(expectedSignature, receivedSignature);
}
}
private bool CryptographicEquals(string a, string b)
{
if (a == null || b == null || a.Length != b.Length)
return false;
int result = 0;
for (int i = 0; i < a.Length; i++)
result |= a[i] ^ b[i];
return result == 0;
}
3. Configuration
Web Portal Configuration
Configure webhooks through your SMSDESK web portal:
- Log in to your SMSDESK portal (e.g.,
https://desk.sms.com.na for Namibia deployment)
- Navigate to Account → SMS Channels
- Click on your channel to edit
- Scroll to the Webhook Settings section
- Configure the following fields:
| Field |
Description |
Required |
| Opt-out Webhook URL |
Your endpoint for STOP/opt-out notifications |
Optional |
| Opt-out Shared Secret |
Secret token for HMAC signature (auto-generated or custom) |
Optional |
| Opt-in Webhook URL |
Your endpoint for JOIN/opt-in notifications |
Optional |
| Opt-in Shared Secret |
Secret token for HMAC signature (auto-generated or custom) |
Optional |
| Blacklist Response |
Custom opt-out confirmation message (fallback if webhook returns empty) |
Optional |
| Whitelist Response |
Custom opt-in confirmation message (fallback if webhook returns empty) |
Optional |
ℹ️ Note: You can use the "Generate" button next to the secret fields to create a
cryptographically secure 64-character secret token.
3. Incoming SMS Webhook
Overview
Receive real-time notifications when SMS messages are received on your channels. Your webhook endpoint will
be called for each incoming message, allowing you to process and respond automatically.
Configuration
Configure incoming SMS webhooks in the SMS Channel Settings page of the SMSDESK portal:
- Navigate to Account → SMS Channels
- Select your channel
- Configure the API/Webhook Settings section:
- WebService Handler: Choose GET or SOAP
- WebService URL: Your endpoint URL
- Security Key: Secret key for authentication
HTTP GET Method
When using the GET method, the platform will call your endpoint with the following query parameters:
Request Parameters
| Parameter |
Type |
Description |
securitykey |
string |
Your configured security key for authentication |
incomingsmsid |
long |
Unique ID of the incoming message |
timestamp |
long |
Unix timestamp (seconds since epoch) |
sender |
string |
Mobile number of the sender (e.g., +264811234567) |
recipient |
string |
Your channel/shortcode that received the message |
msg |
string |
Base64-encoded message wrapped with security key:
<{key}>{message}<{key}>
|
message |
string |
Plain text message content |
Example GET Request
GET https://your-api.com/webhook/incoming?securitykey=YOUR_KEY&incomingsmsid=12345×tamp=1720522800&sender=%2B264811234567&recipient=30470&msg=PFlPVVJfS0VZPkhlbGxvPFlPVVJfS0VZPg==&message=Hello
Response
Your endpoint should return a plain text response. This response will be sent back to the sender as an
auto-reply SMS:
- Return text: The text will be sent as an auto-reply to the sender
- Return empty: The configured default auto-response will be used (if enabled)
- Return HTML: HTML tags will be stripped automatically
Example Response
Thank you for your message. We will respond shortly.
SOAP Method
When using SOAP, the platform will send a SOAP 1.2 request to your endpoint.
SOAP Request
<soap12:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:soap12="http://www.w3.org/2003/05/soap-envelope">
<soap12:Body>
<incomingsms xmlns="http://tempuri.org/">
<incomingsmsid>12345</incomingsmsid>
<timestamp>1720522800</timestamp>
<sender>+264811234567</sender>
<msg>PFlPVVJfS0VZPkhlbGxvPFlPVVJfS0VZPg==</msg>
<securitykey>YOUR_KEY</securitykey>
</incomingsms>
</soap12:Body>
</soap12:Envelope>
SOAP Response
Return a SOAP response with the auto-reply message in the response body. The platform will extract and send
the text content.
Security
The msg parameter contains the message wrapped with your security key and Base64-encoded:
- Original message:
Hello
- Wrapped:
<YOUR_KEY>Hello<YOUR_KEY>
- Base64 encoded:
PFlPVVJfS0VZPkhlbGxvPFlPVVJfS0VZPg==
Verify the security key by decoding the msg parameter and checking that it's wrapped with your
configured key.
⚠️ Important: Always validate the securitykey parameter matches your
configured key before processing the message.
Processing Flow
- SMS received on your channel
- Platform calls your webhook endpoint (GET or SOAP)
- Your endpoint processes the message and returns a response
- Platform sends your response as an auto-reply SMS to the sender
- If webhook fails or returns empty, default auto-response is used (if configured)
Timeout & Retry
- Timeout: 30 seconds
- Retry: No automatic retry for incoming SMS webhooks
- Fallback: If webhook fails, the default auto-response is used
4. Delivery Receipt (DLR) Webhook
✅ Implemented: DLR webhooks are fully implemented as of version 4.8.43 (July 9,
2026).
Delivery reports are automatically sent to your configured webhook endpoint with HMAC-SHA256
signatures.
How It Works
- 📨 Delivery report received from SMSC via SMPP
- 💾 Stored in
sms_delivery_reports table
- 🔗 Automatically matched to original message in
sms_outgoing
- 📡 Webhook sent to your configured endpoint (fire-and-forget, non-blocking)
- 🔄 Retries on 5xx errors (3 attempts with exponential backoff)
Trigger Conditions
DLR webhooks are sent when:
- ✅ Message is delivered to the handset
- ❌ Message fails to deliver
- ⏱️ Message expires
- 📊 Carrier reports intermediate status
POST
https://{your-host}/webhooks/v1/connect/{dlrSourceId}
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-08T14:25:10.1234567Z",
"data": {
"messageId": "987654321",
"externalId": "SMSC-MSG-ID-12345",
"mobile": "+27821234567",
"channel": "30470",
"status": "DELIVERED",
"statusCode": 2,
"statusDescription": "Message delivered to handset",
"deliveredAt": "2026-07-08T14:25:09.0000000Z",
"clientId": 1026,
"reference": "REF-ABC-123"
}
}
Payload Fields
| Field |
Type |
Description |
type |
string |
Always "delivery_receipt" |
timestamp |
string (ISO 8601) |
UTC timestamp when webhook was sent |
data.messageId |
string |
Your internal message ID |
data.externalId |
string |
SMSC/carrier message ID |
data.mobile |
string |
Recipient mobile number |
data.channel |
string |
Channel/shortcode used to send |
data.status |
string |
DELIVERED, FAILED, PENDING, EXPIRED
|
data.statusCode |
integer |
Numeric status code (see table below) |
data.statusDescription |
string |
Detailed status description |
data.deliveredAt |
string (ISO 8601) |
UTC timestamp of delivery/failure |
data.clientId |
integer |
Client ID |
data.reference |
string |
Your reference ID (if provided when sending) |
Status Codes
| Code |
Status |
Description |
| 0 |
PENDING |
Message queued, awaiting submission |
| 1 |
SUBMITTED |
Submitted to SMSC, awaiting delivery |
| 2 |
DELIVERED |
Successfully delivered to handset |
| 3 |
DELIVERED |
Delivered (alternative code) |
| 4 |
FAILED |
Permanent failure (invalid number, blocked, etc.) |
| 5 |
FAILED |
Temporary failure (will retry) |
| 9 |
DELETED |
Message deleted/cancelled |
5. Opt-out/STOP Webhook
Trigger Conditions
The opt-out webhook is triggered when an incoming SMS contains any of these keywords
(case-insensitive):
English Keywords
STOP
UNSUBSCRIBE
CANCEL
END
QUIT
OPTOUT
OPT-OUT
OPT OUT
REMOVE
STOPALL
UNSUB
POST
https://{your-host}/webhooks/v1/connect/{blocklistSourceId}
Request Headers
Content-Type: application/json
X-Signature: sha256=<hmac-sha256-signature>
User-Agent: SMSDesk-Webhook/1.0
Request Payload
{
"type": "opt_out",
"timestamp": "2026-07-08T14:23:45.1234567Z",
"data": {
"from": "+27821234567",
"to": "30470",
"message": "STOP",
"keyword": "STOP",
"messageId": "123456789",
"receivedAt": "2026-07-08T14:23:44.0000000Z",
"clientId": 1026
}
}
Payload Fields
| Field |
Type |
Description |
type |
string |
Always "opt_out" |
timestamp |
string (ISO 8601) |
UTC timestamp when webhook was sent |
data.from |
string |
Mobile number that sent the opt-out (international format) |
data.to |
string |
Channel/shortcode that received the message |
data.message |
string |
Full message text received |
data.keyword |
string |
Detected opt-out keyword (uppercase) |
data.messageId |
string |
Unique ID of the incoming SMS |
data.receivedAt |
string (ISO 8601) |
UTC timestamp when message was received |
data.clientId |
integer |
Client ID associated with the channel |
What Happens After Opt-out
- ✅ Contact is immediately blacklisted in database (
status = 0)
- 📝
status_description is set to:
"OPTED OUT - 2026-07-08 14:23:45 UTC - SMS ID: 123456789"
- 📡 Opt-out webhook is sent to your endpoint
- � Confirmation SMS is sent to the contact (see Response section below)
- �🚫 Future SMS to this contact will be blocked with error:
"Contact has opted out"
Webhook Response (Custom Confirmation Message)
Your webhook endpoint can return a custom confirmation message that will be sent to the contact as an SMS.
Response Priority
- Webhook Response: If your endpoint returns a plain text response (200 OK with body),
that message will be sent to the contact
- Configured Message: If webhook returns empty or fails, the configured
blacklist_response or whitelist_response from the database will be used
- Default Message: If no configured message exists, a generic default message will be
generated
Example Response
HTTP/1.1 200 OK
Content-Type: text/plain
You have successfully unsubscribed from Lexna Insurance. You will no longer receive marketing messages. Reply JOIN to re-subscribe.
ℹ️ Note: HTML tags in the response will be automatically stripped. Return plain text or
empty body to use the default configured message.
Default Messages
Opt-out: "You, {mobile}, have successfully opted out of {clientName} contact lists. You will
no longer receive any messages. You can opt back in by sending JOIN to {channel}."
Opt-in: "You, {mobile}, have successfully registered as a contact of {clientName}. To
opt-out, send STOP to {channel}."
6. Retry Logic
Expected Response Codes
| Response |
Action |
Description |
| 200 OK |
✅ Success |
Webhook processed successfully |
| 204 No Content |
✅ Success |
Webhook processed successfully (no response body) |
| 4xx Client Error |
❌ No Retry |
Permanent error, will not retry |
| 5xx Server Error |
🔄 Retry |
Temporary error, will retry with exponential backoff |
Retry Configuration
- Retry Count: 3 attempts
- Backoff Strategy: Exponential (1s, 2s, 4s)
- Timeout: 30 seconds per attempt
- Idempotency: All endpoints are idempotent (safe to retry)
ℹ️ Note: On 5xx responses, please ensure your endpoint is idempotent as we will
retry
the same request.
7. Testing & Validation
Test Opt-out Webhook
Send a test SMS with a STOP keyword to your configured channel:
# From your mobile phone, send:
TO: 30470
MESSAGE: STOP
# Expected result:
# 1. Contact blacklisted in database
# 2. Webhook sent to your opt_out_url
# 3. Future messages to this number will be rejected
Test DLR Webhook
Send a test SMS and monitor for delivery receipt:
curl -X POST https://{host}/sms/2/text/single \
-H "Authorization: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "30470",
"to": "+27821234567",
"text": "Test message",
"notifyUrl": "https://your-host/webhooks/v1/connect/dlr123"
}'
# Expected result:
# 1. Message sent
# 2. DLR webhook sent when delivered/failed
Webhook Signature Tester
Use this online tool to test your signature verification:
# Example payload
{
"type": "opt_out",
"timestamp": "2026-07-08T14:23:45.1234567Z",
"data": {
"from": "+27821234567",
"keyword": "STOP"
}
}
# Secret: your-secret-token-here
# Expected signature: sha256=... (calculated by your implementation)
8. Code Examples
ASP.NET Core Webhook Receiver
[ApiController]
[Route("webhooks/v1/connect")]
public class WebhookController : ControllerBase
{
private readonly string _sharedSecret = "your-secret-token-here";
[HttpPost("{sourceId}")]
public async Task<IActionResult> ReceiveWebhook(string sourceId)
{
// Read raw body
using var reader = new StreamReader(Request.Body);
string payload = await reader.ReadToEndAsync();
// Verify signature
string signature = Request.Headers["X-Signature"];
if (!VerifySignature(payload, _sharedSecret, signature))
{
return Unauthorized(new { error = "Invalid signature" });
}
// Parse payload
var webhook = JsonSerializer.Deserialize<WebhookPayload>(payload);
// Process based on type
if (webhook.Type == "opt_out")
{
await ProcessOptOut(webhook.Data);
}
else if (webhook.Type == "delivery_receipt")
{
await ProcessDLR(webhook.Data);
}
return Ok();
}
private bool VerifySignature(string payload, string secret, string signatureHeader)
{
if (string.IsNullOrEmpty(signatureHeader) || !signatureHeader.StartsWith("sha256="))
return false;
byte[] payloadBytes = Encoding.UTF8.GetBytes(payload);
byte[] secretBytes = Encoding.UTF8.GetBytes(secret);
using var hmac = new HMACSHA256(secretBytes);
byte[] hashBytes = hmac.ComputeHash(payloadBytes);
string expectedSignature = BitConverter.ToString(hashBytes)
.Replace("-", "")
.ToLowerInvariant();
string receivedSignature = signatureHeader.Substring(7);
return CryptographicEquals(expectedSignature, receivedSignature);
}
}
Node.js Webhook Receiver
const express = require('express');
const crypto = require('crypto');
const app = express();
const SHARED_SECRET = 'your-secret-token-here';
app.post('/webhooks/v1/connect/:sourceId', express.text({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-signature'];
const payload = req.body;
// Verify signature
if (!verifySignature(payload, SHARED_SECRET, signature)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Parse payload
const webhook = JSON.parse(payload);
// Process webhook
if (webhook.type === 'opt_out') {
processOptOut(webhook.data);
} else if (webhook.type === 'delivery_receipt') {
processDLR(webhook.data);
}
res.status(200).send();
});
function verifySignature(payload, secret, signatureHeader) {
if (!signatureHeader || !signatureHeader.startsWith('sha256=')) {
return false;
}
const hmac = crypto.createHmac('sha256', secret);
hmac.update(payload, 'utf8');
const expectedSignature = hmac.digest('hex');
const receivedSignature = signatureHeader.substring(7);
return crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(receivedSignature)
);
}
app.listen(3000);
PHP Webhook Receiver
<?php
$sharedSecret = 'your-secret-token-here';
// Read raw body
$payload = file_get_contents('php://input');
// Get signature header
$signature = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
// Verify signature
if (!verifySignature($payload, $sharedSecret, $signature)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
// Parse payload
$webhook = json_decode($payload, true);
// Process webhook
if ($webhook['type'] === 'opt_out') {
processOptOut($webhook['data']);
} elseif ($webhook['type'] === 'delivery_receipt') {
processDLR($webhook['data']);
}
http_response_code(200);
function verifySignature($payload, $secret, $signatureHeader) {
if (empty($signatureHeader) || strpos($signatureHeader, 'sha256=') !== 0) {
return false;
}
$expectedSignature = hash_hmac('sha256', $payload, $secret);
$receivedSignature = substr($signatureHeader, 7);
return hash_equals($expectedSignature, $receivedSignature);
}
?>
📞 Support
For technical support or questions about webhook integration:
Redirecting to Webhooks Documentation...