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
| Status | Means | Terminal? |
|---|---|---|
pending | Created in our DB; channel call not yet made | No |
processing | Channel accepted the request; awaiting async callback | No |
succeeded | Money received; ledger credited | Yes |
failed | Channel rejected or customer abandoned | Yes |
canceled | We canceled the payment server-side (sweeper timeout) | Yes |
For synchronous channels (card, today's card adapter), the payment goes straight from pending → succeeded/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:
| Channel | Identifier | Initiated by | Callback |
|---|---|---|---|
| M-Pesa STK Push | mpesa_stk_push | Merchant (push) | Async |
| M-Pesa C2B (paybill) | mpesa_c2b | Customer (pull) | Async confirmation |
| Card | card | Merchant | Sync |
| Bank transfer | bank_transfer | Customer | Async |
| Mobile money | mobile_money | Merchant | Async |
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:
- The
payment.paymentsrow. - An
payment.outboxevent row with typepayment.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 inprocessinglonger thanSWEEPER_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
- Refunds — full and partial, sync and async.
- Webhooks — signed deliveries with retries.
- API reference: Payments — every field, every status code.
