# 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 a `FAILED` payment carrying a `failure_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](/en/concepts/scopes), 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](/en/concepts/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:

```json
{
  "id": "knpay_test_9f2c41a7b8e04d6fa1c3e58b7d92f014",
  "status": "FAILED",
  "failure_code": "insufficient_funds",
  "failure_message": "The customer's mobile money account did not have enough funds."
}
```

| `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](/en/concepts/payment-states) — where `FAILED` sits, and why it is final
- [Scopes](/en/concepts/scopes) — what a `403` is telling you
- [Idempotency](/en/concepts/idempotency) — retrying a `5xx` safely
- [Testing](/en/payments/testing) — triggering each failure on purpose
