API reference
Webhooks
The event envelope, the delivery headers, the HMAC signature scheme, and every event type paylod can send you.
Webhooks are optional. Register an endpoint on an application's Endpoints & Webhooks tab and paylod POSTs a signed event the moment something settles. If you would rather not run a receiver, poll `GET /status/:id` instead — every payment is recorded either way.
Delivery headers
| Header | Description |
|---|---|
x-webhook-event | The event type, e.g. payment.success. |
x-webhook-id | Unique delivery id. Use it to dedupe retries. |
x-webhook-signature | HMAC signature, format t=<unix>,v1=<hex>. |
Event types
| Event | Fired when |
|---|---|
payment.success | A collection settled (STK Push or offline C2B). |
payment.failed | A collection failed — cancelled, timed out, wrong PIN, insufficient funds. |
payout.result | A `/payout` reached a terminal state. |
refund.result | A `/reversal` reached a terminal state. |
admin.transaction_status | A `/transaction-status` query resolved. |
admin.account_balance | An `/account-balance` query resolved. |
Every event uses the same envelope and the same signature scheme.
The envelope
POST https://your-app.com/webhooks/mpesa
x-webhook-event: payment.success
x-webhook-id: 8f3c…
x-webhook-signature: t=1751394302,v1=5a2f…c9
{
"type": "payment.success",
"created": 1751394302,
"data": {
"paymentId": "e69e5c00-…",
"applicationId": "…",
"env": "production",
"status": "success",
"amount": 10,
"phone": "254712345678",
"accountRef": "INV-2041",
"mpesaReceipt": "UG1F3A1U7J",
"checkoutRequestId": "ws_CO_…",
"resultCode": 0,
"resultDesc": "The service request is processed successfully.",
"metadata": { "orderId": "2041" }
}
}Verifying the signature
The signature is an HMAC-SHA256 over the string t + "." + rawBody, keyed by the endpoint's signing secret (shown once, when you add the endpoint).
import crypto from "node:crypto";
// Express raw-body handler
export function verify(req, secret) {
const header = req.headers["x-webhook-signature"]; // "t=…,v1=…"
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const signed = `${parts.t}.${req.rawBody}`; // timestamp + raw JSON
const expected = crypto
.createHmac("sha256", secret)
.update(signed)
.digest("hex");
const ok = crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(parts.v1),
);
// Reject if !ok, or if Date.now()/1000 - parts.t > 300 (replay window).
return ok;
}Three rules, in order:
- Compute the HMAC over the raw request body — not a re-serialised object.
- Compare in constant time against
v1. - Reject deliveries whose
tis older than about five minutes, to blunt replay.
Responding
Respond 2xx to acknowledge. A non-2xx response is retried with backoff, and the attempts are visible in the dashboard. Deliveries are at-least-once: dedupe on x-webhook-id.
Verify the signature, then compare data.amount against your own order total, before you fulfil. The webhook always carries the true settled amount. See Secure integration.