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
enabledtofalse. - To remove it, delete it.
Events
| Event | When |
|---|---|
payment.succeeded | A collection or payout succeeded. |
payment.failed | A collection or payout failed. See failureCode. |
payment.expired | A payment was not approved within 4 hours. |
payment.reversed | A reversal succeeded. |
payment.reversal_failed | A 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:
| Header | Value |
|---|---|
X-Webhook-Signature | t=<unix seconds>,v1=<signature>. Check it before you act on the event. |
X-Webhook-Event-Id | The event's id. |
X-Webhook-Event-Type | The 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:
- Split
X-Webhook-Signatureon,. Take the value oft(a unix timestamp) and every value ofv1. Ignore any other part. - Compute HMAC-SHA256 of the string
<t>.<raw body>, keyed with your endpoint's whole secret (whsec_…), as lower-case hex. - Compare it with each
v1in constant time. Accept the event if any one matches. If none does, answer400and ignore the event. - Reject events whose
tis 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)) <= toleranceRotate 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
2xxwithin 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
idyou have handled. - Events can arrive out of order. Act on the payment's
status, not on the order they came in.