# 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

<Mermaid chart={`
stateDiagram-v2
    [*] --> PENDING: POST /v1/payments returns 201
    PENDING --> SUCCESS: customer approved,<br/>operator confirmed
    PENDING --> FAILED: declined, cancelled,<br/>or expired
    SUCCESS --> [*]
    FAILED --> [*]
    note right of PENDING
        Seconds to minutes.
        The customer is deciding.
    end note
    note right of FAILED
        Carries failure_code
        and failure_message.
    end note
`} />

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:

```js
// Fulfil once, driven by a state change your own database owns.
const changed = await db.payments.updateOne(
    { paymentId, status: { $ne: "SUCCESS" } },   // only if not already SUCCESS
    { $set: { status: "SUCCESS" } },
);
if (changed.modifiedCount === 1) {
    await fulfilOrder(paymentId);                // runs exactly once
}
```

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](/en/concepts/idempotency).

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](/en/payments/testing).

## Next steps

- [How a payment works](/en/concepts/how-payments-work) — why the model is asynchronous at all
- [Errors and failure codes](/en/concepts/errors) — what `FAILED` is telling you
- [Webhooks](/en/payments/webhooks) — being told about transitions instead of asking
- [Retrieve payment status](/en/api/direct-payments) — the polling endpoint
