Delivery webhooks
Status events pushed to your endpoint as soon as the operator reports back.
Configure the endpoint
Set the URL in the portal under Developer API, or per request with callbackUrl. It must be HTTPS and answer 2xx within 10 seconds — the same window the retry section below describes, and the one the contract states.
https://bulksmsapi.selanim.com/v1/webhooks| Field | Type | Description |
|---|---|---|
urlrequired | string (url) | Must be https. Must answer within 10 seconds. |
eventsrequired | WebhookEvent[] | |
enabled | boolean | Defaults to true. |
rotateSecret | boolean | Reissue the signing secret and return it. Off by default: a call that only changes the URL must not change the secret you are already verifying with. This is the only way to recover from a lost secret. Defaults to false. |
curl -X PUT "https://bulksmsapi.selanim.com/v1/webhooks" \
-H "Authorization: Bearer $SBS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://brightschool.co.tz/hooks/sbs-delivery",
"events": [
"message.delivered",
"message.failed"
]
}'Needs: curl, on every machine already.
Events
| Event | Type | Description |
|---|---|---|
message.sent | event | Handed to the operator. |
message.delivered | event | Confirmed on the handset. |
message.failed | event | Rejected by the operator, with a reason code. |
message.expired | event | Validity period lapsed before delivery. |
campaign.completed | event | Every message in a campaign has reached a final state. |
wallet.low_balance | event | Balance dropped below your configured threshold. |
whatsapp.received | event | A customer wrote to your WhatsApp number. The push half of GET /whatsapp/messages. |
whatsapp.status | event | A WhatsApp message you sent was delivered, read or failed. |
Inbound events are different
whatsapp.received reports on something you started. That one does not: it is a customer writing to you, and it arrives whether or not you have anything queued. See WhatsApp for the 24-hour window it opens.Payload
Every event has the same envelope: event, createdAt, and a data object whose shape depends on the event. Parse the envelope first and branch on event — new event types will arrive, and a handler that assumes one shape for all of them will break on the first of them.
{
"event": "message.failed",
"createdAt": "2026-08-24T09:14:02Z",
"data": {
"id": "8ca73e63-be1e-4c1b-a0fd-4a3002c1de9f",
"to": "255754112233",
"senderId": "SELANIM",
"status": "failed",
"reference": "term3-fees",
"failureReason": "absent subscriber"
}
}Verifying the signature
Every request carries X-SBS-Signature, an HMAC-SHA256 of {timestamp}.{rawBody} using your webhook secret, where the timestamp is the value of the X-SBS-Timestamp header. Compare it in constant time and reject anything that does not match.
The timestamp is inside the signed material rather than merely alongside it. Signing the body alone would let anyone who captured one request replay it forever with a fresh header, which is precisely what the timestamp is meant to prevent.
import crypto from "node:crypto";
app.post("/hooks/sbs-delivery", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.get("X-SBS-Timestamp") ?? "";
// Reject anything older than five minutes, before doing any crypto.
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!timestamp || Number.isNaN(age) || age > 300) return res.sendStatus(401);
const expected = "sha256=" + crypto
.createHmac("sha256", process.env.SBS_WEBHOOK_SECRET)
.update(timestamp + "." + req.body)
.digest("hex");
const received = req.get("X-SBS-Signature") ?? "";
const ok =
received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body.toString());
// ... persist the delivery status, then acknowledge quickly
res.sendStatus(200);
});Lost the secret?
{ "rotateSecret": true } with your PUT /v1/webhooks call. That reissues it and returns it once. A normal PUT deliberately keeps the existing secret, so changing your URL never breaks verification underneath you — and there is no endpoint that can read the secret back.Retries
A non-2xx response or a timeout is retried after 30s, 2m, 10m, 30m and 90m — six attempts over roughly two hours. After the last one the event is abandoned and appears as failed in your delivery log.
Your endpoint has 10 seconds to answer. Redirects are not followed. Events can arrive out of order and, after a retry, more than once — key on data.id together with event, and ignore a status that moves backwards.
Acknowledge first, process later

