Payment lifecycle
Every state a payment holds, which webhook announces it, and what to do in each.
A payment does not succeed or fail the moment you create it. Between those two outcomes it passes through states that reflect what is happening on the customer's phone and at the mobile money operator. Your integration has to behave differently in each one.
Two of those states mean "wait", and telling them apart matters: one means Wajub has not contacted an operator yet, the other means the customer is being prompted right now. Retrying during the second one creates a second charge.
The states
Branch on `status`, never on the message
The message field is written for humans and can change without notice. The status value is
part of the API contract and is what your code should switch on. Branch on it rather than on which
event arrived, because more than one event can announce the same state.
status | What it means | Webhook | What to do |
|---|---|---|---|
pending | The payment exists. No operator has been contacted yet. | payment.created | Redirect the customer, or make the charge call. Nothing to fulfil. |
processing | Sent to the operator. The customer is being prompted on their phone. | payment.processing | Wait. Do not retry and do not create a second payment. |
partial | Some instalments of a split payment are settled, others are not. | payment.split.* | Wait, or read the instalment breakdown on the payment. |
succeeded | Funds captured. | payment.succeeded | Fulfil the order. |
failed | The attempt did not go through. | payment.failed | Read failure_reason, then decide whether a retry makes sense. |
cancelled | Cancelled, either by the customer on their handset or by you. | payment.cancelled | Offer to start a new payment. |
expired | The payment window elapsed before the customer acted. | payment.expired or payment.failed | Create a new payment. The old one can never complete. |
Four of those are terminal: succeeded, failed, cancelled and expired. Once a payment reaches
one, nothing moves it again, and Wajub stamps the moment to decide how long its hosted page stays
reachable.
Which transitions are possible
Wajub only moves a payment along the paths below. A status you did not expect after another one is a bug on our side, not a state you need to handle.
| From | Can become |
|---|---|
pending | processing · cancelled · expired · failed |
processing | succeeded · partial · failed · cancelled |
partial | succeeded · processing · failed · cancelled |
Note what is absent: nothing returns to pending, and a failed, cancelled or expired payment
never comes back to life. When you need another attempt, you create a new payment.
Four things that surprise people
processing means the customer's phone is ringing
This is the state your integration will spend the most time in, and the one most likely to be
mishandled. On mobile money, processing means the operator has pushed a prompt to the customer's
handset and is waiting for them to enter their PIN. That can take seconds, or the length of time it
takes someone to find their phone.
Never retry a processing payment
Creating a second payment because the first one is "taking too long" is the most common way to
charge a customer twice. A payment in processing is still live. Wait for payment.succeeded,
payment.failed, or payment.expired.
A refund never changes the payment's status
This is the one that catches most integrations. A payment that has been refunded still reads
succeeded on GET /payments/{id}, in full or in part, forever. The API does not rewrite the
status, and there is no payment.refunded event.
Refunds have their own event family instead: refund.created, refund.succeeded, refund.failed
and refund.cancelled. To know how much of a payment came back, list its refunds with
GET /payments/{id}/refunds and sum the ones that succeeded.
`refunded` is a Dashboard label, not an API status
The Dashboard shows Refunded and Partially refunded on a payment. Those are computed from
the refunds attached to it, for people reading a screen. Do not write code that waits for a
payment status of refunded; it will never arrive.
expired can reach you under two names
An expired payment is announced by payment.expired when the sweeper that closes stale sessions
picks it up. A sandbox timeout number lands on the same expired status through a different path
and announces itself as payment.failed.
Both mean the same thing and both are terminal. This is exactly why the rule at the top of the page
is to branch on status rather than on the event name.
partial belongs to split payments only
A payment can be settled in two to four instalments. While some are paid and others are not, its
status is partial, and each instalment reports on its own through payment.split.processing,
payment.split.succeeded and payment.split.failed.
There is no payment.partial event, so a listener subscribed to the plain payment.* family sees
nothing between processing and the final outcome. Subscribe to payment.split.* as well, and
treat the instalment breakdown on the payment as the authoritative record of what has settled.
Expiry
Every payment carries a deadline, set with expires.in at creation, in minutes:
| Minimum | 5 minutes |
| Default | 1 440 minutes (24 hours) |
| Maximum | 43 200 minutes (30 days) |
The floor is five minutes because a mobile money prompt needs time to reach the handset and be confirmed. Shorter windows expire before the customer can pay.
Expiry is not instantaneous. A scheduled job sweeps stale sessions every five minutes, so a payment
whose deadline has just passed can still read pending for a moment, and payment.expired arrives
when that sweep runs rather than on the exact second.
That delay is a feature, not a rounding error. Do not compute expiry yourself to decide an order is dead: an operator can still confirm a payment in the final seconds of its window, and the sweep only touches payments that were never credited.
What you receive when a payment fails
A failed payment carries a failure_reason. Its value comes from the provider that handled the
attempt, and Wajub routes across many providers.
`failure_reason` is not a fixed enumeration
Because the value originates upstream, the exact strings differ between providers and can change
when a provider updates its own API. Treat failure_reason as a diagnostic to log and show to
your support team, and not as a stable key to branch business logic on.
Branch on status for control flow. Use failure_reason to explain to a human what happened, and to
decide manually whether a class of failure is worth retrying.