Wallets and top-ups
Wallets and top-ups
A wallet in Lipafy is the KES-denominated account balance that funds every capability call. One wallet per account. Topped up from M-Pesa, drawn down by gateway calls, transferred peer-to-peer, refunded on upstream failures.
This page explains the four states money can be in, how top-ups work from an application developer’s point of view, and the integration contract.
The four states
balance— total ledger position.available + held_balance. Never negative.available— money you can spend right now. Top-ups land here, refunds return here, peer transfers arrive here.held_balance— reserved by in-flight gateway holds. The price of a capability call movesavailable → held_balancewhen the gateway starts forwarding, and either back (failure) or to the provider (success).spendable— alias foravailable. Used in some endpoints for clarity.
GET /v1/wallet returns all four. The dashboard shows available prominently and held_balance as a secondary number when nonzero.
How top-up works
Initiate a top-up
Call POST /v1/topups/initiate with an amount and a Kenyan phone number:
Returns immediately with a topup_id, the payment rail used by Lipafy, and status='pending'.
STK push to the phone
Lipafy calls its configured M-Pesa payment rail. The user’s phone gets an STK prompt asking them to enter their PIN to authorize KES 1,000 to Lipafy.
User authorizes (or doesn't)
- User enters PIN → M-Pesa debits their phone account → Lipafy receives confirmation.
- User cancels / times out → Lipafy receives a failure notification.
Provider calls Lipafy back
Lipafy receives the payment callback, verifies it, looks up the top-up by reference, and writes the resulting state:
- Success →
topup.status='completed', credit the wallet (available += amount), insert awallet_transactionof typetopup. - Failure →
topup.status='failed', no credit.
Total round-trip is typically 5–30 seconds depending on the user and M-Pesa.
Payment rails
Payment-provider configuration is private to Lipafy operators and is intentionally not part of the public developer contract. Public integrations should only depend on:
Hold lifecycle (during a gateway call)
Holds are short-lived ledger reservations. The gateway uses them so that:
- An in-flight call doesn’t let other concurrent calls overspend.
- A failed upstream is fully refunded with no manual intervention.
A hold cannot be confirmed twice. Race conditions (the same gateway call somehow being settled twice) error with hold_already_settled and are surfaced as 500 ledger_inconsistent — they should never happen and indicate a bug worth investigating.
Transactions ledger
Every state change to a wallet is appended to wallet_transactions (immutable). GET /v1/wallet/transactions returns the paginated history:
Types:
The reference field is unique; for gateway holds it’s the capability_request id, which is the same id you get back in the X-Payment-Reference header.
Peer transfers
Move money between Lipafy accounts directly:
Routes by to_account_id, to_email, or to_phone (in that priority order — first one provided wins). The recipient sees a transfer_in line in their ledger; you see a transfer_out. There’s no withdrawal step — funds land in available immediately.
Transfers are useful for:
- Refunding a customer outside of Lipafy’s standard refund flow (e.g. you charged via gateway, then realized you owe them back more).
- Funding sub-accounts of the same organization.
- Settling between human-controlled accounts on the same platform.
For automated revenue share to capability providers, see Settlements — that’s a different (batched, scheduled) flow.
Idempotency and retries
Top-up POST /v1/topups/initiate has a short duplicate guard: if the same account starts another pending or processing top-up for the same amount and currency within about 90 seconds, Lipafy returns the existing topup_id with idempotent_reuse: true instead of dispatching a second STK push. For longer retries, store the topup_id you got back and poll GET /v1/topups/:id before issuing another POST.
Gateway calls (/gateway/<slug>) are also not idempotent — each call is a distinct charge. If your agent needs at-most-once semantics, use the Approvals flow (the approval_id consumes atomically) or fulfill against an IntentMandate (per-fulfillment count is enforced).
Failure modes
insufficient_balance(402) —available < price. Top up.currency_mismatch(400) — Wallet is KES, the capability charges in something else, and we don’t auto-FX yet.provider_unavailable/provider_rate_limited/ etc. (502/429) — Payment rail is down or throttling. Surface to the user as “try again in a moment” rather than treating as a Lipafy bug.webhook_signature_invalid(401) — Internal callback verification failed. This is an operator-facing signal, not an action item for API integrators.webhook_replay(200) — A payment callback was already processed. No state changed.
Related
- Top-ups API
- Realtime notifications
- Settlements — the other side of the ledger: paying providers out
- Receipts
