Webhooks
Receive delivery reports (DLR) and inbound (MO) messages, with HMAC signature verification.
Overview
Instead of polling for status, configure webhooks and eSMS will POST events to
your endpoints in real time:
dlr_url- delivery reports: every status change on a message you sent (submitted,delivered,failed, ...).mo_url- inbound (MO) messages: replies from recipients.
Every request is signed with HMAC-SHA256 so you can verify it came from eSMS.
Configure endpoints
Set your URLs in Developers → Webhooks in the dashboard, or over the API:
GET https://sms.esmsafrica.io/api/webhooks
PUT https://sms.esmsafrica.io/api/webhooks
POST https://sms.esmsafrica.io/api/webhooks/test
POST https://sms.esmsafrica.io/api/webhooks/rotate-secretcurl -X PUT https://sms.esmsafrica.io/api/webhooks \
-H "Authorization: Bearer esms_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"dlr_url": "https://example.com/hooks/dlr",
"mo_url": "https://example.com/hooks/mo",
"is_active": true
}'GET /api/webhooks returns your current config including the signing_secret.
Endpoints must be publicly reachable - private, loopback and metadata addresses
are refused by an SSRF guard.
POST /api/webhooks/test sends sample signed DLR and MO events to your
configured URLs so you can verify your integration end to end.
Delivery report (DLR) payload
Delivered to your dlr_url. The event is message.<status>, e.g.
message.delivered or message.failed.
{
"event": "message.delivered",
"message_id": "msg_abc123",
"status": "delivered",
"phone": "+254712345678",
"sender_id": "MyApp",
"segments": 1,
"encoding": "GSM7",
"environment": "live",
"livemode": true,
"cost": 0.0096,
"error_code": null,
"retry_count": 0,
"delivered_at": "2026-08-14T12:30:04+00:00",
"failed_at": null
}On a delivery failure the event is message.failed, status is failed,
error_code carries the failure reason and failed_at is set.
Inbound (MO) payload
Delivered to your mo_url when a recipient replies:
{
"event": "message.inbound",
"message_id": "mo_def456",
"from": "+254712345678",
"to": "MyApp",
"text": "YES",
"keyword": null,
"opt_out": false,
"connector": "AT_KE"
}keyword and opt_out are set when the reply is a STOP/START keyword - see
Opt-outs.
Headers
| Header | Description |
|---|---|
X-Webhook-ID | Unique event ID (evt_...). |
X-Webhook-Signature | sha256=<hex> HMAC of the raw request body, keyed by your signing_secret. |
Content-Type | application/json. |
Verifying the signature
Compute HMAC-SHA256 over the raw request body with your signing_secret and
compare it (constant-time) to the hex in X-Webhook-Signature after the
sha256= prefix.
import crypto from "node:crypto";
// Use the raw body, e.g. express.raw({ type: "application/json" })
function verify(req, secret) {
const header = req.get("X-Webhook-Signature") || "";
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(req.body).digest("hex");
const a = Buffer.from(header);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post("/hooks/dlr", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req, process.env.ESMS_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString());
// ... handle event.status / event.event
res.sendStatus(200);
});import hashlib
import hmac
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = "your_signing_secret"
@app.post("/hooks/dlr")
def dlr():
raw = request.get_data() # raw bytes, before JSON parsing
expected = "sha256=" + hmac.new(SECRET.encode(), raw, hashlib.sha256).hexdigest()
header = request.headers.get("X-Webhook-Signature", "")
if not hmac.compare_digest(expected, header):
abort(401)
event = request.get_json()
# ... handle event["status"] / event["event"]
return "", 200Delivery & retries
Respond with a 2xx status quickly. Failed DLR deliveries are retried with
exponential backoff (10s, 30s, 90s, 270s, 810s). Rotate your secret any time with
POST /api/webhooks/rotate-secret (this returns a new signing_secret).