Skip to content

Webhooks

A webhook is an HTTP POST we make to a URL you own, to tell you something happened: a message was delivered, a subscriber texted your shortcode, a subscription renewed.

Webhooks are how you find out what actually happened. Every send in the 9bits API is asynchronous — the 200 you get back means “queued”, never “delivered”. Polling works while you’re building, but in production you want us to push.

Set your URL under Settings → Webhook in the dashboard. For SMS you can also override it per-batch with callback_url on the send, which is useful when different campaigns report to different services.

Two authentication options, configured on your account by your account manager:

  • No authentication. The endpoint is open.
  • HTTP Basic. You provision the callback URL with a username and password, and we send an Authorization: Basic header.

Return any 2xx as soon as you’ve stored the event. That’s your acknowledgement.

Your response code decides what happens next:

You returnWe do
2xxConsider it delivered. Done.
5xxRetry on the schedule below.
429Retry, and reduce throughput to your endpoint.
Other 4xxGive up permanently. No retry.
Timeout / connection refusedRetry on the schedule below.

The retry schedule, measured from the first failed attempt:

  1. +30 seconds — first retry
  2. +60 seconds
  3. +120 seconds
  4. +3600 seconds (1 hour) — final attempt

After the last attempt, the event is dropped. It is not queued for later replay, and there is no dead-letter you can read afterwards.

You will receive the same event more than once. A retry fires because we didn’t hear your 2xx — but your handler may well have run to completion and just been slow. From our side those two cases are indistinguishable.

So: de-duplicate on arrival. Key on message_id for delivery receipts, or on session_id + sent_date for inbound messages.

app.post('/webhooks/9bits/dlr', async (req, res) => {
const evt = req.body;
// Ack first — do the real work off the request path.
res.sendStatus(200);
// INSERT ... ON CONFLICT DO NOTHING — the second delivery is a no-op.
const { rowCount } = await db.query(
`INSERT INTO sms_receipts (message_id, destination, status, received_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (message_id) DO NOTHING`,
[evt.message_id, evt.destination, evt.status],
);
if (rowCount === 0) return; // already processed — stop here
await onDelivered(evt);
});

The rule: acknowledge, de-duplicate, then act.

Sent when delivery_report: true on the batch, to your callback_url or your account webhook.

{
"reference_id": "fdab8f46df049e58b0af7c2d7",
"destination": "2348020000000",
"status": "delivered",
"operator": "mtn",
"message_id": "4f2a9c1b7e"
}

status is one of pending, submitted, delivered, failed, expired. Only delivered, failed and expired are final — see Delivery reports.

Subscriber messages (MO) and subscription lifecycle events — activation, renewal, deactivation — are documented with full payloads in VAS → Receiving messages and Subscription lifecycle.

Voice has no polling endpoint. The webhook is the only way to learn what happened to a call. Payload in Voice.

We can’t reach localhost. Use a tunnel:

Terminal window
# Cloudflare Tunnel
cloudflared tunnel --url http://localhost:3000
# or ngrok
ngrok http 3000

Put the public HTTPS URL it gives you into callback_url (or the dashboard) and you’ll receive live events on your laptop.