Web Rabbit MediaDocs
Reference

Webhooks

Signed, retried event delivery for collections, payouts and top-ups.

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 is the envelope — data.object is the transaction
Read the transaction from 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.
iOne webhook, any gateway
Events are emitted by our settlement layer, not by the underlying provider. Whether a charge was routed through OGateway, 360Pay or JuniPay, you receive the same signed payload with the same fields.

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

FieldTypeDescription
collection.approvedtransactionA MoMo or checkout collection was approved and credited to your balance.
collection.failedtransactionA 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.reversedtransactionA 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.settledtransactionA collection with a split instruction settled — every leg was credited to its sub-account ledger.
split.reversedtransactionA settled split collection was reversed — every credited leg was debited back.
payout.completedpayoutA 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.failedpayoutA withdrawal failed; the full amount requested, including the payout fee, is returned to your available funds.
sms_topup.approvedsms_topupA messaging wallet top-up was credited.
message.deliveredmessageOne SMS reached the handset. Emitted per recipient, not per campaign.
message.failedmessageOne 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.rejectedmessageThe 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.

message.delivered
{
  "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}.

collection.approved
{
  "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

FieldTypeDescription
Webrabbit-Signaturerequiredstringt=<unix seconds>,v1=<hex HMAC-SHA256>
Webrabbit-Event-IdrequiredstringCanonical event id — identical to the body's id and identical on every retry. Key your idempotency on this.
Webrabbit-Event-Typerequiredstringe.g. collection.approved
Webrabbit-Delivery-IdrequiredstringUnique per delivery attempt chain.
Webrabbit-AttemptrequiredintegerAttempt 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.

collection.failed · correction
{
  "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.

verify.js
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.

iAcknowledge first, process later
Do the slow work (emails, fulfilment, third-party calls) after you have returned 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:

FieldTypeDescription
Recover failed messagesbulkRe-queues every delivery in the window that exhausted its retries. Use this after fixing an outage.
Replay missing messagesbulkSends subscribed events from the window that never reached this endpoint — for example while it was disabled.
Bulk replaybulkRe-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.

transformation.js
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).

shell
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