TeronaPay

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:

  • cardsynchronous. We call card-svc.Refund inline; the refund is succeeded/failed in one tx.
  • M-Pesa channels → async. We call Daraja Reversal. The refund is pending until the callback lands.

Lifecycle

StatusMeans
pendingRefund accepted, awaiting channel resolution (M-Pesa only)
succeededChannel reversed; ledger reversed; webhook payment.refunded emitted
failedChannel 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

Refunds · TeronaPay docs