TeronaPay

Webhook events reference

Every delivery has the same envelope:

{
  "id": "evt_...",
  "type": "payment.succeeded",
  "created_at": "2026-05-19T10:23:14Z",
  "merchant_id": "...",
  "data": { /* event-specific */ }
}

The envelope is constant. data varies per event type.

Headers on every delivery:

Content-Type: application/json
User-Agent: NowPesa-Webhook/1.0
X-Nowpesa-Event-Id: <event_id>
X-Nowpesa-Event-Type: <type>
X-Nowpesa-Signature: t=<unix>,v1=<hex_hmac>
X-Nowpesa-Signature-Next: t=<unix>,v1=<hex_hmac>     (only during rotation)

See Concepts → Webhooks for signature verification.

Per-payment and per-payout callbacks use the same headers and payload envelope as registered-endpoint deliveries, but are signed with your callback signing secret (not your per-endpoint secret). The verification code is identical — just swap in the correct secret. Find it in the dashboard under Webhooks → Per-payment callbacks or via GET /v1/webhooks/callback-secret.

payment.succeeded

Inbound payment captured. Ledger has been credited.

{
  "id": "evt_abc",
  "type": "payment.succeeded",
  "created_at": "2026-05-19T10:23:14Z",
  "merchant_id": "...",
  "data": {
    "payment_id": "...",
    "reference": "order-1234",
    "merchant_id": "...",
    "amount": { "minor_units": 12500, "currency": "KES" },
    "fee": { "minor_units": 188, "currency": "KES" },
    "net_amount": { "minor_units": 12312, "currency": "KES" },
    "channel": "mpesa_stk_push",
    "channel_receipt": "UEAL141VYG"
  }
}

payment.failed

Inbound payment declined or expired.

{
  "type": "payment.failed",
  "data": {
    "payment_id": "...",
    "reference": "order-1234",
    "amount": { "minor_units": 12500, "currency": "KES" },
    "channel": "mpesa_stk_push",
    "failure_reason": "Insufficient funds"
  }
}

payment.refunded

Refund slice succeeded. Note: emitted per slice, not per cumulative completion.

{
  "type": "payment.refunded",
  "data": {
    "payment_id": "...",
    "refund_id": "...",
    "reference": "order-1234",
    "channel": "card",
    "channel_receipt": "ch_...",
    "amount": { "minor_units": 3000, "currency": "KES" },
    "fee_retained": { "minor_units": 50, "currency": "KES" },
    "refunded_minor_total": 5000,
    "original_amount": { "minor_units": 10000, "currency": "KES" }
  }
}

Compute remaining = original_amount.minor_units - refunded_minor_total.

payment.refund_failed

Refund slice failed at the channel.

{
  "type": "payment.refund_failed",
  "data": {
    "payment_id": "...",
    "refund_id": "...",
    "amount": { "minor_units": 3000, "currency": "KES" },
    "channel": "mpesa_stk_push",
    "failure_reason": "Reversal not permitted: already settled"
  }
}

payout.succeeded

Outbound transfer to M-Pesa completed. The reservation has moved to the platform bank account.

{
  "type": "payout.succeeded",
  "data": {
    "payout_id": "...",
    "amount": { "minor_units": 50000, "currency": "KES" },
    "destination_phone": "+254712345678",
    "channel": "mpesa_b2c",
    "channel_receipt": "PFR9XYZA12"
  }
}

payout.failed

Outbound transfer failed. The reservation has been released back to the merchant's available payable.

{
  "type": "payout.failed",
  "data": {
    "payout_id": "...",
    "amount": { "minor_units": 50000, "currency": "KES" },
    "destination_phone": "+254712345678",
    "channel": "mpesa_b2c",
    "failure_reason": "Recipient phone not registered"
  }
}

Replay

A replayed delivery has the same event_id as the original. Your handler must be idempotent on event_id.

Ordering

We do NOT guarantee delivery order across event types. If you need a strict ordering (e.g. for a transaction view), key off created_at in the envelope and sort server-side.

Webhook events · TeronaPay docs