Skip to content
Partly builtas of vpay v0.4.1

Failure codes ​

When a rail declines a payment, the merchant does not see MTN's or Orange's error string. They see a FailureCode: a closed vocabulary of eleven codes owned by vpay's core, into which each adapter maps its rail's own reasons. A merchant integrates against the list once, and it does not grow when a rail is added. This page gives the vocabulary, how rail reasons map into it, and — just as important — which codes each rail can actually produce.

Agents working on this should load vpay-payments.

The vocabulary ​

CodeMeaningPayer can retry?Whose problem
insufficient_fundsNot enough balanceYes, new intentPayer
payer_timeoutNever approved in timeYes, new intentPayer
payer_declinedActively rejected the promptYes, new intentPayer
invalid_payerIdentifier not valid on this railNo — fix the numberPayer/merchant
payer_limit_reachedWallet or KYC-tier limitLaterPayer
payer_account_blockedPayer account not activeNoPayer
invalid_payeeMerchant's receiving account invalidNoMerchant config
payee_account_blockedMerchant's receiving account not activeNoMerchant config
provider_account_blockedYour partner account is blockedNoPage yourself
provider_unavailableRail down or timing outYes, laterYou
provider_errorUnmapped; carries the raw reasonUnknownInvestigate

"New intent" is literal: an intent can hold only one charge, ever, so a retry is a new PaymentIntent (lifecycle).

How a rail's answer becomes a code ​

flowchart TD
    A["rail answers a submit or a status query"] --> B{"a decline, or a failure to answer?"}
    B -->|"transport error or unreadable body"| T["ProviderError::Transport or Malformed - not a decline, retried"]
    B -->|"401 or 403 on our credentials"| PAB["provider_account_blocked - pages"]
    B -->|"a documented decline reason"| M{"in the adapter's mapping table?"}
    M -->|"yes"| FC["the mapped FailureCode"]
    M -->|"no"| PE["provider_error, raw reason kept"]
    FC --> S["charges.failure_code and failure_raw"]
    PE --> S
    PAB --> S
    S --> L["intent: last_payment_error, payment_intent.payment_failed"]

The rail's own word survives in failure_raw, which is stored and logged. Only the taxonomy code and a generic message are public. A decline at submit answers the confirm with 409 charge_declined; a decline found later by the poll ladder arrives as the same payment_intent.payment_failed event.

Which rail can produce which code ​

The vocabulary says what each code means. It does not say whether anything can produce it — and the two rails differ by eight of eleven.

CodeMTN MoMoOrange Money
insufficient_fundsNOT_ENOUGH_FUNDS—
payer_timeoutCOULD_NOT_PERFORM_TRANSACTION, EXPIREDEXPIRED
payer_declinedPAYMENT_NOT_APPROVED, APPROVAL_REJECTED—
invalid_payerPAYER_NOT_FOUND—
payer_limit_reachedPAYER_LIMIT_REACHED—
payer_account_blockedSENDER_ACCOUNT_NOT_ACTIVE †—
invalid_payeePAYEE_NOT_FOUND—
payee_account_blockedPAYEE_NOT_ALLOWED_TO_RECEIVE—
provider_account_blockedNOT_ALLOWED, HTTP 401/403HTTP 401/403
provider_unavailableSERVICE_UNAVAILABLE on a FAILED body—
provider_erroranything unmappedFAILED, anything unmapped

† SENDER_ACCOUNT_NOT_ACTIVE and COULD_NOT_PERFORM_TRANSACTION are not in MTN's published ErrorReason enum. Both are mapped anyway, and declared as unpublished reasons in the adapter.

Why Orange can say so little ​

Orange documents five statuses — INITIATED, PENDING, SUCCESS, EXPIRED, FAILED — and no sub-reason for FAILED. Its protocol cannot say "not enough funds" or "no such payer". A payer who clicks Cancel on Orange's page arrives as EXPIRED, indistinguishable from one who walked away, so payer_declined is unreachable on Orange. vpay refuses to invent a CANCELLED to make the rails look alike.

For merchants

Write one branch per code — that is the right integration. Do not assume every branch is reachable on the rail in front of you: on Orange only payer_timeout, provider_account_blocked and provider_error can occur.

No code is deleted ​

A code nothing produces is a documented reservation, not dead weight. The vocabulary is a wire contract, and removing a variant would break a merchant deserialising it. What keeps promises honest instead is a test: the_declines_prove_every_code_each_rail_can_produce holds the conformance cases against each adapter's PRODUCED_FAILURE_CODES, so a code that gains a producer without a case — or a case for a code the adapter does not declare — fails.

provider_error is an alert, not a resting place ​

A rising provider_error rate means an adapter's mapping table has drifted behind the rail's real error strings. Alert on it; do not tolerate it. The runbook is provider-error-rate.

Status in v0.4.1 ​

PartStatusEvidence
The taxonomyBuilt and testedvpay-core::failure
MTN mapping tablePartly builtRow by row in both directions; checked against MTN's published enum; every mapped reason has a WireMock case
Orange mappingPartly builtIts documented statuses mapped and tested; proven only against WireMock
Decline reaching the merchantPartly built409 charge_declined, last_payment_error, one payment_intent.payment_failed — rails are stubs
Mappings faithful to the real railsWritten, never run against the real railEvery decline in the test record came from WireMock; the one real-rail call (MTN sandbox) was a success

The full record is the Status section of the failure taxonomy flow.

Go deeper ​

Verified against vpay v0.4.1 (2026-09-22). vpay is a scaffold — do not deploy it.