charges.failure_code is a closed vocabulary owned by the core. Adapters map
their rail's error strings into it. Merchants integrate against this list once
and it does not grow when a rail is added.
| Code | Meaning | Payer can retry? | Whose problem |
|---|---|---|---|
insufficient_funds |
Not enough balance | Yes, new intent | Payer |
payer_timeout |
Never approved in time | Yes, new intent | Payer |
payer_declined |
Actively rejected the prompt | Yes, new intent | Payer |
invalid_payer |
Identifier not valid on this rail | No — fix the number | Payer/merchant |
payer_limit_reached |
Wallet or KYC-tier limit | Later | Payer |
payer_account_blocked |
Payer account not active | No | Payer |
invalid_payee |
Merchant's receiving account invalid | No | Merchant config |
payee_account_blocked |
Merchant's receiving account not active | No | Merchant config |
provider_account_blocked |
Your partner account is blocked | No | Page yourself |
provider_unavailable |
Rail down or timing out | Yes, later | You |
provider_error |
Unmapped; carries the raw reason | Unknown | Investigate |
The table above says what each code means. It does not say whether anything
can produce it — and until 2026-09-10 one of them could not.
payer_declined was defined by the core, typed in both merchant SDKs and
given buyer copy by examples/shop, and no adapter emitted it
(issue #59). Nothing failed,
because nothing compared a promise to a producer.
| Code | MTN MoMo | Orange Money | Proven by |
|---|---|---|---|
insufficient_funds |
NOT_ENOUGH_FUNDS |
— | …0f01 |
payer_timeout |
COULD_NOT_PERFORM_TRANSACTION, EXPIRED |
EXPIRED |
MTN …0f02, …0f04; Orange …0f01 |
payer_declined |
PAYMENT_NOT_APPROVED, APPROVAL_REJECTED |
— | MTN …0f05, …0f06 |
invalid_payer |
PAYER_NOT_FOUND |
— | …0f07 |
payer_limit_reached |
PAYER_LIMIT_REACHED |
— | …0f08 |
payer_account_blocked |
SENDER_ACCOUNT_NOT_ACTIVE † |
— | …0f09 |
invalid_payee |
PAYEE_NOT_FOUND |
— | …0f0a |
payee_account_blocked |
PAYEE_NOT_ALLOWED_TO_RECEIVE |
— | …0f0b |
provider_account_blocked |
NOT_ALLOWED, HTTP 401/403 |
HTTP 401/403 | …0f03, and bad_credentials_are_not_reported_as_a_payer_problem on both rails |
provider_unavailable |
SERVICE_UNAVAILABLE on a FAILED body |
— | …0f0c |
provider_error |
anything unmapped | FAILED, anything unmapped |
MTN …0f0d; Orange …0f02 |
…0fxx is the charge reference a WireMock mapping keys on, under
backends/tests/conformance/wiremock/{mtn,orange}/mappings/.
a_declined_charge_maps_to_the_documented_failure_code drives every row
against a real container, and
the_declines_prove_every_code_each_rail_can_produce asserts the rows are
all of them — it holds the 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.
No code is unreachable on every rail, which is what changed on 2026-09-10.
Eight of the eleven are unreachable on Orange, and that is not a gap in a
table: Orange documents five statuses (INITIATED, PENDING, SUCCESS,
EXPIRED, FAILED) and no sub-reason for FAILED at all, so its protocol
cannot say "not enough funds" or "no such payer". A payer who clicks Cancel
on its hosted page arrives as EXPIRED — indistinguishable from one who
walked away — so payer_declined in particular is unreachable there, and
inventing a CANCELLED to make the rails look alike is refused rather than
done. examples/shop's test-number panel states each of those eight, and its
cannotExpress rows are checked against
vpay_adapter_orange_money::PRODUCED_FAILURE_CODES so a claim cannot outlive
its truth.
No variant is deleted, and none should be. A code nothing produces today is a documented reservation: the vocabulary is a wire contract, and removing a variant would break a merchant deserialising it.
† SENDER_ACCOUNT_NOT_ACTIVE is not in MTN's published ErrorReason enum,
nor is COULD_NOT_PERFORM_TRANSACTION. Both are mapped and both are declared
in vpay_adapter_mtn_momo::UNPUBLISHED_REASONS; see
adapter-mtn-momo.md § Failure mapping.
A rising provider_error rate means an adapter's mapping table has drifted
behind the rail's actual error strings. Alert on it. Do not tolerate it.
Each adapter's mapping lives in its own flow doc: MTN · Orange.
Updated 2026-09-03 (Step 3): both adapters' mappings are implemented.
The taxonomy itself is implemented and tested (vpay-core::failure).
- MTN transcribes the reason table above into
vpay_adapter_mtn_momo::mapping::FAILURE_REASONS, asserted row by row and in both directions byevery_documented_reason_maps_to_its_documented_code,no_reason_appears_twiceandan_unknown_reason_is_provider_error_and_never_a_guess. - Orange maps its four documented statuses in
vpay_adapter_orange_money::mapping(every_documented_status_maps_and_nothing_else_does,expired_is_the_payers_timeout_and_carries_a_raw_reason,an_unrecognised_status_is_an_error_never_a_failure). - Over the wire, both are proven by the shared conformance case
a_declined_charge_maps_to_the_documented_failure_code, which drives a realwiremock/wiremockcontainer per rail and asserts the taxonomy code the documented decline arrives as — and, since 2026-09-10, that the rail's own word survives intofailure_raw, which the previous!raw.is_empty()did not: it passed for a table in which two reasons' stubs had been transposed. Every mapped reason now has a case, thirteen on MTN and two on Orange, where three and one had one before. Measured 2026-09-10: 53 conformance tests, 53 passed, 0 ignored. - A decline reaches a merchant.
POST …/confirmon a rail that refuses the charge writescharges.failure_code+failure_raw, stamps the intent'slast_payment_error, and answers409 charge_declined(a_payer_the_rail_does_not_know_is_a_decline_the_merchant_can_read, aninvalid_payerdecline steered by the one field of the outgoing request a merchant controls — the MSISDN — andcredentials_the_rail_refuses_are_a_page_and_a_terminal_charge, aprovider_account_blockedone, which is a different code, a different severity and a different on-call answer). The rail's raw reason is stored and logged; only the taxonomy code and a generic message are public. Since 2026-09-10 it also emits onepayment_intent.payment_failed, inside that same transaction (issue #57), so a merchant who only listens to webhooks hears about a decline made at submit and not only about one the poll ladder found. Both paths use the same type on purpose — see webhooks.md.
Updated 2026-09-10 (exp48, issue
#59): the taxonomy is now
checked against MTN's published vocabulary, not only against this
repository's own transcription of it. MTN's ErrorReason.code enum
lists seventeen codes; the adapter mapped nine and had never been compared
against the list. Three became new rows (PAYMENT_NOT_APPROVED and
APPROVAL_REJECTED → payer_declined, EXPIRED → payer_timeout), four
stay provider_error with a written reason each, and two rows this
repository maps turn out not to be published by MTN at all. See "Which
rail can produce which code" above.
provider_error is still the escape hatch, and it is now reachable from a
real response path — an unmapped string arrives as provider_error
carrying the raw reason rather than being guessed at
(runbooks/provider-error-rate.md).
What none of this proves is that the tables are faithful to the rails:
every decline above came from WireMock, and no real rail has ever declined a
vpay charge. MTN's real sandbox has been called on one day, 2026-09-15: three
runs, the first two stopped at the token mint by adapter bugs and the third
settled its charge, so no rail-reported decline came out of it
(../status/verification/2026-09-15.md).
Orange's has never been called. (This read "neither rail's real sandbox has
ever been called" until 2026-09-23, and had been wrong about MTN since
2026-09-15.) Orange in particular documents no error vocabulary for
webpayment and no sub-reasons for FAILED, so both land in the
"unmapped, alert on it" bucket by design. See ../status.md.