paylod
Webhooks

API reference

Webhooks

.md

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

HeaderDescription
x-webhook-eventThe event type, e.g. payment.success.
x-webhook-idUnique delivery id. Use it to dedupe retries.
x-webhook-signatureHMAC signature, format t=<unix>,v1=<hex>.

Event types

EventFired when
payment.successA collection settled (STK Push or offline C2B).
payment.failedA collection failed — cancelled, timed out, wrong PIN, insufficient funds.
payout.resultA `/payout` reached a terminal state.
refund.resultA `/reversal` reached a terminal state.
admin.transaction_statusA `/transaction-status` query resolved.
admin.account_balanceAn `/account-balance` query resolved.

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

The envelope

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

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.

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.