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.
Configure your endpoint
Section titled “Configure your endpoint”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: Basicheader.
Responding
Section titled “Responding”Return any 2xx as soon as you’ve stored the event. That’s your
acknowledgement.
Retries
Section titled “Retries”Your response code decides what happens next:
| You return | We do |
|---|---|
2xx | Consider it delivered. Done. |
5xx | Retry on the schedule below. |
429 | Retry, and reduce throughput to your endpoint. |
Other 4xx | Give up permanently. No retry. |
| Timeout / connection refused | Retry on the schedule below. |
The retry schedule, measured from the first failed attempt:
- +30 seconds — first retry
- +60 seconds
- +120 seconds
- +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.
Make your handler idempotent
Section titled “Make your handler idempotent”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.
What you’ll receive
Section titled “What you’ll receive”SMS delivery receipts
Section titled “SMS delivery receipts”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.
Inbound messages and VAS events
Section titled “Inbound messages and VAS events”Subscriber messages (MO) and subscription lifecycle events — activation, renewal, deactivation — are documented with full payloads in VAS → Receiving messages and Subscription lifecycle.
Voice call results
Section titled “Voice call results”Voice has no polling endpoint. The webhook is the only way to learn what happened to a call. Payload in Voice.
Developing locally
Section titled “Developing locally”We can’t reach localhost. Use a tunnel:
# Cloudflare Tunnelcloudflared tunnel --url http://localhost:3000
# or ngrokngrok http 3000Put the public HTTPS URL it gives you into callback_url (or the dashboard) and
you’ll receive live events on your laptop.