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. amount_paid on the payment says what has settled so far. |
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 by you with DELETE /payments/{id}, or by the customer with the Cancel button of the hosted page. A decline on the handset is not a cancellation. | payment.cancelled | Offer to start a new payment. |
expired | The payment window elapsed before the customer acted. | payment.expired | Create a new payment. The old one can never complete. |
Four of those end the attempt: succeeded, failed, cancelled and expired. Wajub stamps the
moment to decide how long the hosted page stays reachable. succeeded never moves again, except
through refunds: from API version 2026-10-11 a refunded payment reads refunded or
partially_refunded.
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 · partial · succeeded · failed · cancelled · expired |
processing | pending · partial · succeeded · failed · cancelled · expired |
partial | processing · succeeded · failed · cancelled |
failed · cancelled · expired | succeeded only |
Two of those paths surprise people. processing goes back to pending when the operator declines a
charge at once, with a 402, so that the same payment can be charged again; payment.attempt_failed
tells your server. And a failed,
cancelled or expired payment can still become succeeded when the operator confirms the money
late: fulfil on payment.succeeded whenever it arrives. It does so at once when your own provider
collected it. When a Wajub rail did, or when another attempt had already collected the payment,
Wajub reviews the late confirmation first: it either credits the payment, and payment.succeeded
follows, or refunds the payer and the payment stays as it was. Nothing else leaves those three
states, so another attempt after them means 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 changes the payment's status only from API version 2026-10-11
This is the one that catches most integrations, and it depends on your API version. From
2026-10-11, a payment reads partially_refunded once refunds that succeeded cover part of it, and
refunded once they cover all of it. On an earlier version it keeps reading succeeded, in full or
in part, forever. Either way it is a reading of the refunds: the payment itself does not change, and
there is no payment.refunded event. What the payment's status says
Refunds have their own event family instead: refund.created, refund.processing (live only),
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.
Do not wait for a status that your version never shows
On an account pinned before 2026-10-11, refunded and partially_refunded never arrive: the
Dashboard's Refunded and Partially refunded labels are computed from the refunds, for
people reading a screen. Whatever your version, count the refunds that succeeded to know what came
back.
expired is announced as payment.expired
An expired payment is announced by payment.expired, whether its window closed or a sandbox
timeout number ended it. A sandbox timeout used to announce itself as
payment.failed; it no longer does. Branch on status all the same: it is what the rule at the top
of the page is for.
partial belongs to split payments only
A payment can be settled in two to four instalments, in sandbox and, on Mobile Money, in live
(Split payments). While some are paid and others are not, its status is
partial, processing while an instalment waits for the payer, and each instalment reports on its
own through payment.split.processing, payment.split.succeeded and payment.split.failed.
There is no payment.partial event, and a subscription matches exact names only: payment.* matches
nothing. List the three payment.split events by name, or subscribe to *. The payment carries no
list of its instalments: amount_paid is the authoritative record of what has settled, and each
payment.split.* event tells you which instalment moved.
A partial payment stays open until its last instalment is paid: nothing expires or cancels it. To
stop before, close it with POST /payments/{id}/close: it becomes
succeeded for what was collected, which you keep or refund (outcome). DELETE /payments/{id}
refuses a payment with instalments already paid.
Expiry
Every payment carries a deadline, set with expires.in at creation, in minutes, and read back in
its expires_at:
| 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: it can lag the deadline by a few minutes. A payment whose deadline has
just passed can still read pending for a moment, and payment.expired arrives a little after the
deadline 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 expiry only
applies to payments that were never credited. Nor does it apply to a payment paid in instalments
once one of them is paid: that money is the payer's, and the payment stays open for the next
instalment. A partial payment never expires: it stays open until it is settled or cancelled.
What you receive when a payment fails
A failed payment carries a failure_reason, and failure_message says it in one sentence. The
value is the code of the provider that refused the last attempt, and Wajub routes across many
providers, or payment_failed when that attempt failed without a provider code. See
Failure Reasons.
`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.
Cancelling a payment
A payment that is pending, processing or partial can be cancelled from your server, when the
customer abandons the order on your side.
https://api.wajub.com/payments/{id}The payment moves to cancelled, payment.cancelled is sent, and its page stops accepting
payment. Any other status answers 400 This payment cannot be cancelled (already completed or expired). A confirmation the operator sends after the cancel still moves the payment to
succeeded, as described above.
Listing payments by state
https://api.wajub.com/payments| Query parameter | Accepts |
|---|---|
status | pending, processing, partial, succeeded, failed, cancelled, expired |
reference | Exactly the reference you sent at creation. It is not unique, so every payment that carries it comes back |
search | Free text over the payment id, your reference and the description |
date_from, date_to | Dates, compared with the creation date |
amount_min, amount_max | Amounts in the major unit of the currency |
per_page | 1 to 100, defaults to 25 |
cursor | Switches to cursor pagination |
status also accepts refunded and partially_refunded. From API version 2026-10-11 they list
the payments refunded in full or in part, which succeeded then leaves out. On an earlier version
they return an empty list. What the payment's status says
To see the payments still waiting on a customer, filter on pending:
curl -G https://api.wajub.com/payments \
-H "Authorization: $WAJUB_API_KEY" \
-d status=pending \
-d per_page=50The receipt of a succeeded payment
Once a payment has succeeded, it carries receipt_url: the payer's receipt, a page hosted on your
invoice domain, in the payer's language. The link is signed, and the signature in its s parameter
is what opens it, so pass the URL on as it is.
The same document comes in two other forms. Add /pdf to the path for a PDF, or /data for the
receipt as JSON, and keep the query string.