# Errors and refusals

Two shapes only: a refusal is HTTP 200 with allowed:false and a machine-stable reason; a real error is an HTTP status with { error, code }. Branch on reason.

## Shape one — a refusal

`HTTP 200`, `allowed: false`, and three message layers:

| Layer | Audience | Rule |
| --- | --- | --- |
| reason | Your code | Machine-stable, never translated, never in a locale file |
| developer_message | Your logs | Always English |
| user_message | The person | Localised by accept-language (en, pt-BR, es); absent on integration errors, by design |

The same refusal, three times, one per `Accept-Language` value. Only `user_message` changes; `reason` never does.

Accept-Language: en:

```
{
  "allowed": false,
  "reason": "ceiling_exceeded",
  "developer_message": "Requested 18900 exceeds the remaining daily ceiling of 12000.",
  "user_message": "This is above the daily limit you set.",
  "step_up_available": true
}
```

Accept-Language: pt-BR:

```
{
  "allowed": false,
  "reason": "ceiling_exceeded",
  "developer_message": "Requested 18900 exceeds the remaining daily ceiling of 12000.",
  "user_message": "Isso está acima do limite diário que você definiu.",
  "step_up_available": true
}
```

Accept-Language: es:

```
{
  "allowed": false,
  "reason": "ceiling_exceeded",
  "developer_message": "Requested 18900 exceeds the remaining daily ceiling of 12000.",
  "user_message": "Esto supera el límite diario que definiste.",
  "step_up_available": true
}
```

### Refusal reasons

| reason | When it fires | step_up_available | What to do |
| --- | --- | --- | --- |
| ceiling_exceeded | The amount or count would pass the ceiling for the period | present | Offer a step-up mandate, or act within remaining |
| scope_mismatch | The requested scope is not in the mandate | present | Ask the person for a mandate that carries the scope |
| mandate_expired | expires_at has passed | — | Issue a new mandate from a fresh capture |
| mandate_revoked | The mandate was revoked | — | Stop; do not retry |
| constraint_violated | The merchant, category or country is outside the constraints | — | Route the action elsewhere or ask for a wider mandate |
| liveness_required | The act needs a fresh live capture | — | Send the person to a capture surface |
| not_found | Unknown mandate, or one the caller may not read | — | Do not probe; unknown and unauthorised answer identically |
| rate_limited | Too many calls | — | Back off and retry |
| idempotency_key_required | mandate-consume without Idempotency-Key | — | Add the header and retry |

`liveness_required` is a refusal in a 200 body. It is not a 422.

## Shape two — a real HTTP error

```
{ "error": "Amount must be an integer in the minor unit", "code": "AMOUNT_INVALID" }
```

| Status | Codes |
| --- | --- |
| 400 | INVALID_REQUEST, INVALID_INPUT (carries fields), AMOUNT_INVALID, UNKNOWN_SCOPE, WILDCARD_SCOPE_FORBIDDEN, AMOUNT_CEILING_NOT_APPLICABLE, CONSTRAINTS_REQUIRED_FOR_SCOPE, AGENT_DISPLAY_NAME_TOO_LONG, AGENT_DESCRIPTOR_TOO_LONG, AGENT_DISPLAY_NAME_EMPTY, AGENT_DESCRIPTOR_EMPTY |
| 401 | API_KEY_REQUIRED, INVALID_API_KEY, AUTH_REQUIRED (a pk_ on a server endpoint) |
| 403 | DOCUMENT_REQUIRED, CONSENT_REQUIRED, BIOMETRIC_LIVENESS_FAILED, IDENTITY_MISMATCH, FACE_MATCH_BELOW_THRESHOLD, COERCION_DETECTED, UNAUTHORIZED_DEVICE, ACCESS_REVOKED |
| 404 | not_found — unknown and unauthorised answer identically, on purpose |
| 405 | METHOD_NOT_ALLOWED |
| 409 | Replay or nonce reuse; SESSION_PRESENT, IDENTITY_UNREACHABLE |
| 422 | FACE_TEMPLATE_REQUIRED |
| 429 | RATE_LIMITED |
| 500 | Carries trace_id; IDENTITY_CREATE_FAILED, TEMPLATE_STORE_FAILED, SESSION_MINT_FAILED |
| 503 | SLO_BREACH_PAUSED, TEMPLATE_STORE_UNAVAILABLE |

> Branch on `reason`, never on the status code. A refused action is a successful call.
