Mobile Money and Hosted Checkout payments are asynchronous: a charge starts as pending while the customer authorises it. When the payment reaches a final state, Web Rabbit sends an HMAC-signed POST to every endpoint you have registered — so you never have to poll.
data.object, not from data. data also carries resource_type and resource_id beside the object. This is deliberately different from GET /v1/transactions/{id}, which returns the transaction flat at the top level. A handler written against the flat shape parses nothing and fails silently.Register an endpoint
In the merchant dashboard go to Developer → Webhooks, add an https:// URL, and choose the events you want. Endpoints are scoped per mode: test endpoints only receive test events and live endpoints only receive live events. You can register up to 5 endpoints per mode, send a test event at any time, and rotate the signing secret without downtime. The signing secret is shown once, at creation and after each rotation.
Events
| Field | Type | Description |
|---|---|---|
collection.approved | transaction | A MoMo or checkout collection was approved and credited to your balance. |
collection.failed | transaction | A collection failed, was declined, or expired. Also used when a collection reported approved in error is put right — that payload carries a correction block and reason_code settled_in_error. |
collection.reversed | transaction | A previously approved collection was clawed back by the provider after real money moved. The amount and our commission are removed from your balance. Never used for a payment that was never collected. |
split.settled | transaction | A collection with a split instruction settled — every leg was credited to its sub-account ledger. |
split.reversed | transaction | A settled split collection was reversed — every credited leg was debited back. |
payout.completed | payout | A withdrawal was disbursed to your bank or wallet. gross_amount is the amount requested, fees is the payout fee, and net_amount is what reached the destination. |
payout.failed | payout | A withdrawal failed; the full amount requested, including the payout fee, is returned to your available funds. |
sms_topup.approved | sms_topup | A messaging wallet top-up was credited. |
message.delivered | message | One SMS reached the handset. Emitted per recipient, not per campaign. |
message.failed | message | One SMS could not be delivered, or no delivery report arrived within 24 hours (status undelivered, provider_status NO_DLR, error_reason no_delivery_report). |
message.rejected | message | The provider or network refused the message outright — sender ID, blocked number, or content. |
Delivery receipts
Messaging emits one terminal event per message, signed with the same scheme as payment events, so you never have to poll messaging/status in a loop. Every submitted message settles exactly once: whichever arrives first — the provider callback, a status poll, or our sweeper — is the one that fires the event. Messages with no upstream report are closed as undelivered after 24 hours, so nothing stays pending indefinitely. Test mode emits the same events deterministically without touching the provider. Payment provider notifications are accepted only at our private callback address; anything else is refused. If a notification never arrives, the payment still settles through our automatic status check, which runs every 10 minutes.
{
"id": "0f61b6a5-3c58-4d0f-9d8f-8b1f1a2c7de1",
"type": "message.delivered",
"mode": "live",
"created_at": "2026-09-10T09:12:44.108Z",
"data": {
"resource_type": "message",
"resource_id": "a3f1c0de-6b21-4d38-9c1a-2f70b8d51e44",
"object": {
"message_id": "a3f1c0de-6b21-4d38-9c1a-2f70b8d51e44",
"campaign_id": "c14b7c2e-9a4d-4c07-9e12-0f3b6f2a8d55",
"to": "+233240000000",
"sender": "WRB",
"mode": "live",
"status": "delivered",
"provider_status": "DELIVERED",
"provider_message_id": "SM-88213004",
"error_reason": null,
"segments": 1,
"cost": 0.045,
"submitted_at": "2026-09-10T09:12:41.002Z",
"settled_at": "2026-09-10T09:12:44.061Z"
}
}
}Payload
The body is JSON. data.object for a collection is the same object returned by GET /v1/transactions/{transaction_id}.
{
"id": "b6f1c1de-6a1f-4b9e-9b0e-7f0f5a2d1c33",
"type": "collection.approved",
"mode": "live",
"created_at": "2026-08-22T10:14:05.221Z",
"data": {
"resource_type": "transaction",
"resource_id": "521888807466",
"object": {
"transaction_id": "521888807466",
"provider_transaction_id": "JP-88213771",
"status": "approved",
"resolved_status": "approved",
"code": "00",
"reason_code": "approved",
"reason": "TRANSACTION SUCCESSFUL",
"subscriber_number": "0240000000",
"channel": "momo",
"gross_amount": 10,
"fee_amount": 1.5,
"net_amount": 8.5,
"currency": "GHS",
"created_at": "2026-08-22T10:13:41.005Z"
}
}
}Headers
| Field | Type | Description |
|---|---|---|
Webrabbit-Signaturerequired | string | t=<unix seconds>,v1=<hex HMAC-SHA256> |
Webrabbit-Event-Idrequired | string | Canonical event id — identical to the body's id and identical on every retry. Key your idempotency on this. |
Webrabbit-Event-Typerequired | string | e.g. collection.approved |
Webrabbit-Delivery-Idrequired | string | Unique per delivery attempt chain. |
Webrabbit-Attemptrequired | integer | Attempt number, starting at 1. |
Which id is canonical
The Webrabbit-Event-Id header is authoritative. It is the same value as the id in the untransformed body, it is generated once when the event is created, and it never changes across retries or across multiple endpoints receiving the same event. The Webrabbit-Delivery-Id identifies the attempt chain to a single endpoint and will differ per endpoint — never key idempotency on it. If you use a payload transformation, the body may be reshaped, but the header is untouched, which is the other reason to prefer it.
Reversal window
collection.reversed can arrive well after settlement. Mobile Money collections can be clawed back by the network for up to 7 days after settlement; card collections carry chargeback exposure for up to 120 days. If you pay out to drivers, vendors or couriers faster than that window, size your payout hold or reserve accordingly — you are carrying the reversal risk for anything already disbursed.
Corrections — approved reported in error
If a collection was ever reported approved without the money actually being collected, we put it right with a collection.failed event carrying a correction block, not a collection.reversed. Nothing was collected, so there is nothing to claw back — but if you credited a customer on the earlier approval, this is your signal to unwind it. The transaction ends up status: "failed", reason_code: "settled_in_error", settled_at: null, reversed_at: null, and any platform fee is removed.
{
"type": "collection.failed",
"data": {
"resource_type": "transaction",
"resource_id": "912751664555",
"object": {
"transaction_id": "912751664555",
"status": "failed",
"reason_code": "settled_in_error",
"reason": "Reported approved in error from a provider acknowledgement; the payment was never collected",
"settled_at": null,
"reversed_at": null,
"fee_amount": 0,
"correction": {
"previously_reported_status": "approved",
"corrected_at": "2026-09-11T14:02:11.000Z",
"detail": "If you credited on the earlier approval, unwind it."
}
}
}
}Verify the signature
Compute HMAC-SHA256 over `${t}.${rawBody}` using your signing secret and compare it to v1 with a constant-time comparison. Always use the raw request body — re-serialising the JSON changes the bytes and breaks the signature. Reject timestamps older than five minutes to prevent replays.
import crypto from 'node:crypto'
// Express: app.post('/webhooks/webrabbit', express.raw({ type: '*/*' }), handler)
export function handler(req, res) {
const header = req.get('Webrabbit-Signature') || ''
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
const raw = req.body // Buffer — never req.body parsed as JSON
const expected = crypto
.createHmac('sha256', process.env.WEBRABBIT_WEBHOOK_SECRET)
.update(`${parts.t}.${raw}`)
.digest('hex')
const ok =
parts.v1 &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1)) &&
Math.abs(Date.now() / 1000 - Number(parts.t)) < 300
if (!ok) return res.status(400).send('invalid signature')
const event = JSON.parse(raw.toString())
// ... fulfil the order, then acknowledge fast
res.status(200).send('ok')
}Retries and failures
Reply with any 2xx within 10 seconds to acknowledge. Anything else — a non-2xx status, a timeout, or a connection error — is retried up to 6 attempts with backoff at 10s → 1m → 5m → 30m → 2h → 6h. After 10 consecutive events exhaust their retries, the endpoint is disabled automatically and you can re-enable it from the dashboard once the issue is fixed. Every attempt, its response code and body are visible under Developer → Webhooks → Deliveries, where you can also replay a delivery manually.
200. Events can be delivered more than once, so make your handler idempotent — key it on Webrabbit-Event-Id or the transaction_id.Testing an endpoint
Open an endpoint and use the Testing tab to dispatch a realistic example of any event type you are subscribed to. The example is signed with your real signing secret and appears in Message attempts alongside production traffic, so you can confirm your signature check, headers and parsing before a real payment happens. Example events carry "mode": "test" and are never counted as settlement activity.
Message attempts and replay
Message attempts lists every delivery with its status, response code, message ID, duration and full request body. Filter by status or event type, expand a row to inspect what we sent and what your server returned, and press Resend to re-queue a single message.
The Replay menu handles bulk recovery over a time window:
| Field | Type | Description |
|---|---|---|
Recover failed messages | bulk | Re-queues every delivery in the window that exhausted its retries. Use this after fixing an outage. |
Replay missing messages | bulk | Sends subscribed events from the window that never reached this endpoint — for example while it was disabled. |
Bulk replay | bulk | Re-sends every subscribed event in the window, including ones that already succeeded. Your handler must be idempotent. |
Each run queues at most 200 messages and still respects your endpoint throttle, so a large replay drains gradually rather than flooding your server.
Advanced delivery options
The Advanced tab on an endpoint holds three optional controls.
Custom headers
Add key/value headers sent with every request to this endpoint — a shared secret for a gateway, a tenant identifier, or a routing hint. Reserved headers (Webrabbit-*, Content-Type, Host) cannot be overridden.
Endpoint throttling
Cap how many events per minute we deliver to this endpoint. Anything above the cap waits in the queue and is delivered on the next run instead of being dropped — useful for rate-limited or shared hosting.
Payload transformation
Rewrite the request before it leaves us, so a legacy consumer does not need to change. Write a synchronous handler(webhook) function that returns any of payload, url or method. It runs in an isolated sandbox: no network, no timers, no async/await, an 8 KB source limit, a hard execution timeout and a 256 KB cap on the transformed body. The signature is always computed over the final body, so verification works unchanged.
function handler(webhook) {
// webhook.payload — the Web Rabbit event body
// webhook.url — the current destination
// webhook.method — "POST"
const tx = webhook.payload.data.object
return {
payload: {
kind: webhook.payload.type,
reference: tx.transaction_id,
amount_minor: Math.round(tx.gross_amount * 100),
currency: tx.currency,
},
}
}Use Simulate in the same tab to run your function against a sample payload and see the exact body we would send. If a transformation throws at delivery time we fall back to the original payload and record the error on the attempt, so a bad transform never silently drops an event.
Polling fallback
Webhooks are the recommended path, but polling still works. After POST /v1/collect/momo returns 202, poll GET /v1/transactions/{transaction_id} every 3 seconds up to 20 times (~60 seconds — the standard MoMo prompt window).
id=521888807466
for i in $(seq 1 20); do
tx=$(curl -s https://api.webrabbitmedia.com/v1/transactions/$id \
-H "Authorization: Bearer wr_live_...")
echo "$tx"
echo "$tx" | grep -q '"resolved_status":"pending"' || break
sleep 3
done