Failure Reasons
Reading failure_reason on a failed payment, transfer or refund.
A 4xx means the request was refused. This page is about the other kind of failure: a request
that succeeded, created a resource, and then failed later at the operator. The object and its
webhook carry two fields that explain it.
failure_reasonstringoptionalstatus: "failed".failure_messagestringoptional`reason` is a different field
Transfers and refunds also have a reason, and it has nothing to do with failure: it is the
label you supplied when you created the payout or the refund (requested_by_customer,
Salaire septembre). A failed transfer carries both, and they mean opposite things. Always
read failure_reason.
Where it appears
The same pair is delivered in the payment.failed, transfer.failed and refund.failed webhooks,
since those payloads are the resource itself.
| Object | Field carried | Set when |
|---|---|---|
| Transfer | failure_reason, failure_message | Only while status is failed. |
| Refund | failure_reason, failure_message | Only while status is failed. |
| Payment | failure_reason, failure_message | Only while status is failed. See Failed payments. |
Codes Wajub issues itself
These are stable and exhaustive. They are decided before, or instead of, any call to an operator, which is why Wajub controls their spelling.
Transfers
failure_reason | What happened | What to do |
|---|---|---|
team_restricted | Payouts are suspended on your account. | Contact support. Do not retry. |
invalid_recipient | The beneficiary is missing, or has no payout channel. | Fix the beneficiary, then create a new transfer. |
no_provider | No provider is eligible for that channel and amount right now. | Retry later, or route to another channel. |
provider_rejected | Every eligible provider refused the payout. | Read failure_message, fix, then create a new one. |
admin_rejected | Wajub declined it after a review. | Contact support. Do not retry. |
merchant_rejected | You rejected it from the Dashboard before it was sent. | Nothing: you stopped it. Do not retry. |
cancelled_by_wajub | Wajub cancelled it before it was sent. | Contact support. Do not retry. |
The last two come on a cancelled payout from API version 2026-10-12, and on a failed one before.
Refunds
A failed refund always carries one of these, live and sandbox alike. When the provider reports the
failure, Wajub maps it to one of these codes; failure_message keeps the provider's sentence when
it sent one.
failure_reason | What happened | What to do |
|---|---|---|
insufficient_funds | The provider's float could not cover the refund. | Retry later, once. |
refund_rejected_by_issuer | A card issuer refused it. | Pay the customer back another way. |
transaction_already_reversed | The payment was already reversed or refunded at the provider. | Check the Dashboard before acting. |
invalid_number | The payer's number or account cannot receive it. Live only. | Ask the customer for another way to pay them back. |
limit_exceeded | A limit on the receiving account or at the provider was reached. Live only. | Retry later, once. |
network_error | The provider could not be reached in time. | One retry is reasonable. |
provider_error | The provider was down, or refused the call itself. | Wait, then retry once. |
provider_declined | The provider refused it without saying why. | Read failure_message, then decide. |
ops_failed | Wajub could not complete the refund and closed it, for no known reason. A refund the provider had refused ends with the provider's reason above instead. Live only. | Contact support before refunding again. |
cancelled_by_wajub | Wajub cancelled the refund before sending it: nothing left your balance. The refund is cancelled from API version 2026-10-12, failed before. Live only. | Refund again if the customer is still owed. |
A refund that cannot be completed automatically is not dropped: Wajub completes it, and it stays
pending (or processing) in the meantime. failed is the end of that road, not the start. A
retry is a new refund.
Failed payments
A failed payment carries the reason of its last failed attempt, on the payment and in the
payment.failed webhook:
failure_reason | When |
|---|---|
| The provider's own code | The provider refused the attempt with a code. It is passed through unchanged, see Codes the operator issues, and failure_message carries the provider's sentence. |
payment_failed | The attempt failed without a provider code, a timeout or an error on our side of the call. failure_message is The payment could not be completed. |
declined, authentication_failed | Sandbox only, see Sandbox is deterministic. |
Older failed payments may carry neither field.
The status still separates the three cases that change what you do next:
status | What happened |
|---|---|
failed | The operator refused the charge. |
expired | The customer never validated in time and the session lapsed. |
cancelled | The customer, or your own DELETE /payments/{id}, stopped it. |
For every attempt, not only the last, open the payment in Konsole: its timeline shows each attempt, the provider involved, and the raw gateway response. That is also where the dashboard reads from when it prints a plain-language explanation next to a failed payment.
Codes the operator issues
Anything not in the tables above is the provider's own error code, passed through unchanged. There is no shared vocabulary between operators: MTN, Orange, Wave and a card acquirer each name the same customer mistake differently, and a new provider can introduce a new code without a release on our side.
Do not build an exhaustive switch
Treat the two tables above as known cases and everything else as one default branch: surface
failure_message, let the customer act, and do not retry automatically. A match with no
default will break the first time an operator ships a new code.
const RETRY_NEVER = new Set([
'team_restricted',
'admin_rejected',
'merchant_rejected',
'invalid_recipient',
]);
function onTransferFailed(transfer) {
const reason = transfer.failure_reason;
if (RETRY_NEVER.has(reason)) {
return escalateToSupport(transfer);
}
if (reason === 'no_provider') {
return scheduleRetry(transfer, { in: '15m' });
}
return notifyMerchant(transfer.failure_message);
}Sandbox is deterministic
The sandbox calls no operator: the test number, account or address you use decides the outcome, so every branch of your handler can be exercised on purpose before you go live. The tables of test numbers are in Test scenarios.
Transfers. The last six digits of a Mobile Money test number (and the suffix of a bank test account or a crypto test address) pick the result. A transfer that fails carries one of three reasons:
| Suffix | Result | failure_reason |
|---|---|---|
000000 | succeeded | |
000001 | Refused at once with a 422, error_code: insufficient_funds; no transfer is created. | |
000002 | failed | failure |
000003 | failed | network_error |
000004 | failed | invalid_recipient |
A number that is not a test number is refused with a 422 that lists the ones to use.
Refunds. The payment method of the refunded payment decides: a Mobile Money payment paid with a
number ending in 000000 is refunded, one ending in 000001 to 000004 or 000009 fails; the
card 4000 0000 0000 5423 is paid but its refunds always fail; bank and crypto payments are always
refunded. Only a payment with no payment method attached is left to chance, 70 % success. The
failure_reason of a failed sandbox refund is drawn from the same list as live, in
Refunds.
Payments. The last six digits of the test number decide the status: 000002 ends failed,
000003 ends expired, 000004 ends cancelled. 000001 is refused at once with a 402 and
error_code: insufficient_funds, as a live decline is, and the payment stays pending. A sandbox
payment that fails says why with a fixed pair, chosen by the test number or card you paid with:
| Scenario | failure_reason | failure_message |
|---|---|---|
Failure (…000002, a declining card) | declined | The operator declined the payment. |
| The issuer declines after 3D Secure | declined | The issuer declined the card after authentication. |
| The payer fails 3D Secure | authentication_failed | The payer did not pass 3D Secure authentication. |
A failure is not always the end
A no_provider or a network error leaves your balance untouched: the reservation is released
when the transfer fails. Check the balance rather than assuming, then create a
new transfer with a new idempotency key.