Errors and failure codes
Keep these apart, because they need completely different handling:
- A request error means we could not accept the instruction. HTTP
4xx/5xx. Usually your bug. - A payment failure means we accepted it, asked the operator, and the customer's side said no. HTTP
201, then aFAILEDpayment carrying afailure_code. Usually nobody's bug.
Treating the second as an exception is the most common mis-integration we see. A customer with no money is not an error condition; it is Tuesday.
Request errors
| Status | Meaning | Fix |
|---|---|---|
400 | Validation failed | Read error.details β it names the property |
401 | Key missing, malformed or revoked | Check the X-API-Key header |
403 | Key lacks the scope, or the IP is not allowed | Widen the scope, or allowlist the IP |
404 | No such resource for your business | Check the id and the environment |
409 | Idempotency conflict | Same key, different body, or one still in flight |
429 | Rate limited | Back off, then retry |
5xx | Ours | Retry with the same idempotency key |
A 404 never distinguishes "does not exist" from "belongs to someone else". That is deliberate: the alternative lets anyone probe for valid ids.
Payment failures
A failed payment carries a provider-agnostic code:
Code
failure_code | What happened | Worth retrying? |
|---|---|---|
insufficient_funds | Not enough money in the wallet | Yes, after they top up |
customer_declined | Cancelled the prompt, or wrong PIN | Yes, immediately |
payment_expired | The prompt was never answered | Yes |
customer_account_issue | The wallet cannot authorise this payment | No β they must contact their operator |
generic_decline | Declined, no reason given. Most often funds. | Yes, after they check their balance |
provider_unavailable | The operator is down | Yes, with backoff |
temporarily_unavailable | A transient problem on the path | Yes, with backoff |
unknown | We could not classify it | Treat as terminal; contact support |
These codes are stable
They are provider-independent by design, and that is a contract rather than a convenience:
- Values are added, never renamed or removed. Code you write today against the table above keeps working.
- No code names an operator, so adding a payment provider does not change what your handler receives.
- The set stays small, so your handling can be exhaustive rather than best-effort with a fallback.
Write your switch once, against these. There is no operator-specific error
handling to maintain, and nothing to revisit when we add a provider.
Showing failures to customers
failure_message is written to be shown as-is. It never contains a provider's raw text, our internal state, or anything that would mislead.
Do not surface failure_code itself. "insufficient_funds" is for your logs and your switch; your customer wants "Not enough funds in your Mobile Money account. Top up and try again."
Things that surprise people
generic_decline is not a bug. It is the provider refusing to be specific. Tell the customer to check their balance.
A 5xx from us is not a failed payment. It means we could not tell you the outcome, not that there was not one. Retry with the same idempotency key and you will either create it or be told it already exists.
Rate limits apply per key. A noisy reconciliation job can 429 your checkout if they share a key. Separate keys per consumer.
Next steps
- Payment states β where
FAILEDsits, and why it is final - Scopes β what a
403is telling you - Idempotency β retrying a
5xxsafely - Testing β triggering each failure on purpose

