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.
{
"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
}{
"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
}{
"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 |
Sidaxis is the human authorisation layer. It never moves money, holds funds or settles. The machine-readable contract is openapi.yaml.