TeronaPay

Payments

A payment in TeronaPay is one inbound money movement from a customer to a merchant. The Payment resource is the source of truth for that movement.

The lifecycle

StatusMeansTerminal?
pendingCreated in our DB; channel call not yet madeNo
processingChannel accepted the request; awaiting async callbackNo
succeededMoney received; ledger creditedYes
failedChannel rejected or customer abandonedYes
canceledWe canceled the payment server-side (sweeper timeout)Yes

For synchronous channels (card, today's card adapter), the payment goes straight from pendingsucceeded/failed in one tx. For async channels (M-Pesa STK Push, C2B, B2C), the payment is processing until the callback resolves it.

Channels

TeronaPay supports five channels through one endpoint. The channel field on POST /v1/payments selects which one:

ChannelIdentifierInitiated byCallback
M-Pesa STK Pushmpesa_stk_pushMerchant (push)Async
M-Pesa C2B (paybill)mpesa_c2bCustomer (pull)Async confirmation
CardcardMerchantSync
Bank transferbank_transferCustomerAsync
Mobile moneymobile_moneyMerchantAsync

Channel-currency rules

  • M-Pesa channels (mpesa_stk_push, mpesa_c2b, mpesa_b2c) are KES only. We reject requests with any other currency.
  • Card accepts any 3-letter ISO-4217 code.
  • Bank transfer follows the merchant's default_currency.

If you omit currency, we use the merchant's default_currency.

Multi-currency

Every account has a default_currency (KES by default, settable in the dashboard). The ledger keeps separate accounts per (channel, currency) pair — so platform:card:clearing:USD is distinct from platform:card:clearing:KES. Statements honor the currency filter.

Fees

Fees are locked in at payment creation. The merchant's effective rate at that moment is stamped onto the payment row (fee_minor) and never changes — even if you later update the rate or add a per-channel override.

The ledger posting credits platform:fees:revenue:CUR separately from the merchant payable, so reporting can split gross from net.

Idempotency

Pass Idempotency-Key: <your-key> on POST /v1/payments. We dedupe by (merchant_id, idempotency_key) for 24 hours. A retry returns the original response — same id, same status, same created_at.

Without the header, retries create separate payments. We strongly recommend always sending one.

The outbox guarantee

Every successful payment writes two rows in the same DB transaction:

  1. The payment.payments row.
  2. An payment.outbox event row with type payment.succeeded.

An in-process relay drains the outbox, calls ledger-svc to post the inbound payment, and calls webhook-svc to dispatch the event. At-least-once delivery — both downstreams must be idempotent on event_id (ledger uses external_ref; webhook uses (endpoint_id, event_id) for registered-endpoint deliveries or (event_id, target_url) for per-payment callback deliveries).

Dispatch routing: if the payment was created with a callback_url, the event goes only to that URL (signed with the merchant's callback signing secret). If no callback_url was set, the event fans out to all active registered webhook endpoints.

What "settled" means

A payment in status succeeded is immediately reflected in the merchant's available payable balance (merchant:<id>:payable:<CUR> ledger account). There's no T+N delay — once the channel callback lands and the outbox event ships, the money is yours to pay out.

Sweepers

Two sweepers run continuously to handle lost callbacks:

  • STK Push sweeper (SWEEPER_INTERVAL, default 1m): polls Daraja's STK Query API for payments stuck in processing longer than SWEEPER_STUCK_AFTER (default 5m). Drives them through the same outbox path the callback would.
  • Refund sweeper (REFUND_SWEEPER_INTERVAL, default 5m): times out refunds whose callback never arrived. Marks them failed; releases the reservation.

These are dedup'd across replicas via Postgres advisory locks.

What's next

Payments · TeronaPay docs