SelanimDocs

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.

PUThttps://bulksmsapi.selanim.com/v1/webhooks
FieldTypeDescription
urlrequiredstring (url)Must be https. Must answer within 10 seconds.
eventsrequiredWebhookEvent[]
enabledbooleanDefaults to true.
rotateSecretbooleanReissue 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.
bash
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

EventTypeDescription
message.senteventHanded to the operator.
message.deliveredeventConfirmed on the handset.
message.failedeventRejected by the operator, with a reason code.
message.expiredeventValidity period lapsed before delivery.
campaign.completedeventEvery message in a campaign has reached a final state.
wallet.low_balanceeventBalance dropped below your configured threshold.
whatsapp.receivedeventA customer wrote to your WhatsApp number. The push half of GET /whatsapp/messages.
whatsapp.statuseventA WhatsApp message you sent was delivered, read or failed.

Inbound events are different

Everything above 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.

json
{
  "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.

javascript
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?

Send { "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

Write the event to a queue and return 200 immediately. Slow handlers are the most common cause of duplicate deliveries.