Skip to content

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.

statusWhat it meansWebhookWhat to do
pendingThe payment exists. No operator has been contacted yet.payment.createdRedirect the customer, or make the charge call. Nothing to fulfil.
processingSent to the operator. The customer is being prompted on their phone.payment.processingWait. Do not retry and do not create a second payment.
partialSome instalments of a split payment are settled, others are not.payment.split.*Wait, or read the instalment breakdown on the payment.
succeededFunds captured.payment.succeededFulfil the order.
failedThe attempt did not go through.payment.failedRead failure_reason, then decide whether a retry makes sense.
cancelledCancelled, either by the customer on their handset or by you.payment.cancelledOffer to start a new payment.
expiredThe payment window elapsed before the customer acted.payment.expired or payment.failedCreate 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.

FromCan become
pendingprocessing · cancelled · expired · failed
processingsucceeded · partial · failed · cancelled
partialsucceeded · 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.

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.

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:

Minimum5 minutes
Default1 440 minutes (24 hours)
Maximum43 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.

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.

What did you think of this content?