# 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`](/docs/api/payments) 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`](/docs/api/payouts) reached a terminal state. |
| `refund.result` | A [`/reversal`](/docs/api/payouts) reached a terminal state. |
| `admin.transaction_status` | A [`/transaction-status`](/docs/api/other) query resolved. |
| `admin.account_balance` | An [`/account-balance`](/docs/api/other) query resolved. |

Every event uses the same envelope and the same signature scheme.

## The envelope

```http title="Example delivery"
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).

```js title="Verify (Node)"
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:

1. Compute the HMAC over the **raw** request body — not a re-serialised object.
2. Compare in **constant time** against `v1`.
3. Reject deliveries whose `t` is 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`.

> [!warn] 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](/docs/guides/security).
