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 │ in-flight gateway holds
└──────────────┘ └──────────────┘
spendable
  • 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 moves available → held_balance when the gateway starts forwarding, and either back (failure) or to the provider (success).
  • spendable — alias for available. 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

1

Initiate a top-up

Call POST /v1/topups/initiate with an amount and a Kenyan phone number:

1POST /v1/topups/initiate
2Authorization: Bearer <credential>
3Content-Type: application/json
4
5{
6 "amount": "1000",
7 "currency_code": "KES",
8 "phone": "+254700000000"
9}

Returns immediately with a topup_id, the payment rail used by Lipafy, and status='pending'.

2

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.

3

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.
4

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 a wallet_transaction of type topup.
  • Failure → topup.status='failed', no credit.
5

Poll or stream the status

The client either polls GET /v1/topups/:id until status moves out of pending, or subscribes to /v1/notifications/stream (SSE) for topup.completed / topup.failed events.

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:

FlowEndpointNotes
User top-upPOST /v1/topups/initiateTriggers an M-Pesa STK prompt for the signed-in account.
Top-up statusGET /v1/topups/:idPoll until completed, failed, cancelled, or expired.
Realtime updatesGET /v1/notifications/streamSSE events include topup.completed and topup.failed.

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.
[Gateway call starts]
├── available -= price
├── held_balance += price
├── insert wallet_transaction(type='hold', status='pending')
[Upstream returns 2xx] [Upstream returns 4xx/5xx]
├── held_balance -= price ├── held_balance -= price
├── (don't credit available; ├── available += price
│ payment is debited) ├── insert wallet_transaction(type='release')
├── update hold tx → status='confirmed' └── update hold tx → status='failed'
├── credit provider wallet
└── write signed receipt

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:

1{
2 "transactions": [
3 {
4 "id": "wtx_...",
5 "type": "debit",
6 "status": "confirmed",
7 "amount": "500",
8 "currency_code": "KES",
9 "capability_id": "cap_...",
10 "reference": "cr_8f3a...",
11 "created_at": "2026-05-17T09:14:00Z"
12 }
13 ]
14}

Types:

TypeDirectionTriggers
topup+M-Pesa STK confirmation
transfer_in+Another account sent you money
transfer_outYou sent another account money
hold(parked)Gateway call started; price reserved
release+Gateway call failed; hold returned to available
debitGateway call succeeded; hold confirmed as a charge
refund+A successful charge was refunded
adjustment±Admin manual correction (rare)

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:

1POST /v1/transfers
2Authorization: Bearer <credential>
3Content-Type: application/json
4
5{
6 "to_email": "alice@example.com",
7 "amount": "500",
8 "currency_code": "KES",
9 "note": "lunch split"
10}

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.