Webhooks

Get payment outcomes on your server as they happen.

Register an endpoint

Call Create a webhook endpoint with a public https URL and the events you want. Store the secret in the response: it is shown once.

Register once with a test key and once with a live key. Each mode allows 16 endpoints.

  • To move an endpoint, update its URL. The secret stays the same.
  • To pause it, set enabled to false.
  • To remove it, delete it.

Events

EventWhen
payment.succeededA collection or payout succeeded.
payment.failedA collection or payout failed. See failureCode.
payment.expiredA payment was not approved within 4 hours.
payment.reversedA reversal succeeded.
payment.reversal_failedA reversal was refused. The payment is SUCCEEDED again.

Subscribe to * to get every event, including new ones.

Payload

{
  "id": "4b1d7c2e-9f3a-4e6b-8c5d-2a1f0e9d8c7b",
  "type": "payment.succeeded",
  "livemode": false,
  "createdAt": "2026-09-28T09:15:42Z",
  "data": {
    "object": {
      "id": "0b6f3c1e-8f2a-4a51-9a57-6d1f0c2b7e93",
      "status": "SUCCEEDED",
      "kind": "COLLECTION",
      "provider": "MPESA",
      "amount": "10000.0000",
      "currency": "TZS",
      "merchantReference": "order-1042",
      "metadata": { "orderId": "1042" }
    }
  }
}

livemode is false for test and true for live. For anything not in data.object, such as the fee, retrieve the payment.

Each event is a POST with these headers:

HeaderValue
X-Webhook-Signaturet=<unix seconds>,v1=<signature>. Check it before you act on the event.
X-Webhook-Event-IdThe event's id.
X-Webhook-Event-TypeThe event's type.

The signature covers the body only, so deduplicate on the body's id and act on the body's type.

Check the signature

Anyone can send a request to your URL, so check every event's signature before you act on it:

  1. Split X-Webhook-Signature on ,. Take the value of t (a unix timestamp) and every value of v1. Ignore any other part.
  2. Compute HMAC-SHA256 of the string <t>.<raw body>, keyed with your endpoint's whole secret (whsec_…), as lower-case hex.
  3. Compare it with each v1 in constant time. Accept the event if any one matches. If none does, answer 400 and ignore the event.
  4. Reject events whose t is more than five minutes old, so a captured request cannot be replayed later.

After you rotate your secret, events carry two v1 values for a while, one per secret. Check them all.

Use the raw body exactly as received. Parsing and serialising it again breaks the signature.

Verify with the steps above. A webhook library built for another signature format cannot verify these events.

import hashlib
import hmac
import time

def verify(raw_body: bytes, signature_header: str, secret: str, tolerance: int = 300) -> bool:
    timestamp, signatures = None, []
    for part in signature_header.split(","):
        key, _, value = part.partition("=")
        if key == "t":
            timestamp = value
        elif key == "v1":
            signatures.append(value)
    if timestamp is None or not signatures:
        return False
    signed = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    matches = any(hmac.compare_digest(expected, signature) for signature in signatures)
    return matches and abs(time.time() - int(timestamp)) <= tolerance

Rotate your secret

Call Rotate a webhook endpoint's secret and deploy the new secret. The old one keeps working for 24 hours, or for oldSecretValidForHours (0 stops it at once).

Respond and retry

  • Answer 2xx within 10 seconds, then do the work.
  • A failed delivery is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 24 hours and 48 hours.
  • After 20 failures in a row the endpoint is switched off and you get an email. Switch it back on with Update a webhook endpoint.
  • You can get an event twice. Skip any id you have handled.
  • Events can arrive out of order. Act on the payment's status, not on the order they came in.

On this page