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.
