Payment states
A payment has three states you will see and one rule that matters more than the rest: SUCCESS and FAILED are final. Nothing leaves them.
The state machine
That is the whole machine. There is no PROCESSING, no AUTHORISED, no capture step. Mobile Money has no two-phase authorise-then-capture model: the customer's PIN both authorises and moves the money.
Reading a state
| State | What it means | What you should do |
|---|---|---|
PENDING | Accepted. The customer has been prompted. | Show a waiting state. Do not fulfil. |
SUCCESS | The money moved. | Fulfil exactly once. |
FAILED | It did not, and will not for this payment. | Read failure_code. Offer a retry if it is recoverable. |
The rule that keeps your ledger correct
Fulfil on the transition into SUCCESS, never on the state itself.
You may see SUCCESS more than once: a webhook can be delivered twice, a retry can overlap a poll, and you may re-read a payment during reconciliation. If your code fulfils whenever it observes SUCCESS, it will ship twice.
Make the transition the thing you act on:
Code
The database decides whether this is news. That is what makes duplicate deliveries harmless, which in turn is what lets us guarantee at least once rather than the far weaker at most once.
Terminal means terminal
A FAILED payment never becomes SUCCESS. If the customer topped up and wants to try again, that is a new payment with a new id and a new idempotency key.
This trips people up when a customer says "but I paid" β they approved a second prompt, from a second payment you created. Both exist, both are correct, and your job is to reconcile on ids rather than on the customer's account of events.
Things that surprise people
There is no timeout you control. The operator expires an unapproved prompt on its own schedule. When it does, the payment moves to FAILED with payment_expired. Do not build your own timer that marks a payment failed while ours is still PENDING β you will contradict the money.
Polling a PENDING payment forever is fine, but wasteful. Back off. A payment that has been PENDING for ten minutes is almost certainly waiting on a customer who walked away.
Sandbox is not instant either. It models the same asynchrony deliberately, so code that works in sandbox works in production. See Testing.
Next steps
- How a payment works β why the model is asynchronous at all
- Errors and failure codes β what
FAILEDis telling you - Webhooks β being told about transitions instead of asking
- Retrieve payment status β the polling endpoint

