Refunds
A refund reverses some or all of a previously-succeeded payment. Each refund is its own resource with its own status and its own webhook event.
Full vs partial
POST /v1/payments/{id}/refunds accepts an optional amount field:
- Omitted or zero: refund the remaining unrefunded gross.
- Positive number: refund that exact amount (decimal major units, e.g.
30.00). Must be≤ payment.amount - payment.refunded.
Cumulative refunded amount is tracked on payment.refunded. Once it hits the original gross, refunded_at is set and further refund calls return 422 fully_refunded.
# Full remaining refund
curl -X POST https://api.teronapay.com/v1/payments/$ID/refunds \
-u "$NOWPESA_KEY_ID:$NOWPESA_SECRET"
# Partial refund of KES 30
curl -X POST https://api.teronapay.com/v1/payments/$ID/refunds \
-u "$NOWPESA_KEY_ID:$NOWPESA_SECRET" \
-H "Content-Type: application/json" \
-d '{"amount": 30.00, "currency": "KES"}'Channel routing
The refund channel mirrors the payment's original channel:
card→ synchronous. We call card-svc.Refund inline; the refund issucceeded/failedin one tx.- M-Pesa channels → async. We call Daraja Reversal. The refund is
pendinguntil the callback lands.
Lifecycle
| Status | Means |
|---|---|
pending | Refund accepted, awaiting channel resolution (M-Pesa only) |
succeeded | Channel reversed; ledger reversed; webhook payment.refunded emitted |
failed | Channel rejected; no ledger movement; webhook payment.refund_failed emitted |
Ledger semantics
A successful refund posts the inverse of the original inbound (2 legs):
CR platform:<channel>:clearing:CUR -gross (money returned to clearing)
DR merchant:<id>:payable:CUR +gross (we claw back from merchant's payable)
The original platform fee is NOT refunded — the merchant absorbs it (Stripe-style). For a full refund where you want to also reimburse the fee, contact support; that's not a standard API operation today.
Idempotency
- Send
Idempotency-Key: <key>on the refund POST to dedupe retries. - Without one, sending two refund POSTs against the same payment creates two refund rows. That's the Stripe semantic for partial refunds — you can legitimately want two slices of the same payment.
What's in the event payload
A payment.refunded event includes:
{
"id": "evt_...",
"type": "payment.refunded",
"created_at": "2026-05-19T10:54:37Z",
"merchant_id": "...",
"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" }
}
}The refunded_minor_total is the running cumulative across all successful refund slices, so you can compute remaining = original_amount.minor_units - refunded_minor_total.
What's next
- Webhooks for delivery semantics.
- API: Refunds for the full request/response reference.
