Errors & idempotency
How the API reports failures, what each status and machine code means, and how to make create requests safely retryable with idempotency keys.
Error format
Every error returns a single, uniform JSON envelope. Branch on the machine-readable error field, not on the human-readable message — and log the request_id so support can trace the call.
{
"error": "<code>", // stable, machine-readable code
"message": "…", // human-readable explanation
"status": 422, // HTTP status, mirrored in the body
"request_id": "req_…" // correlate with support / logs
}
For example, reusing an Idempotency-Key with a different body returns:
{
"error": "idempotency_key_reuse",
"message": "Idempotency-Key was reused with a different request body.",
"status": 422,
"request_id": "req_8sZc1pQ2"
}
Request-schema failures (missing or malformed fields) surface as 422 with details about the offending field(s). Treat error: "validation_error" as the generic schema-validation code.
HTTP status codes
The API uses conventional HTTP semantics. The status is also mirrored inside the error envelope.
| Status | Meaning | When |
|---|---|---|
| 200 OK | Success | Read succeeded, or a write that returns a body (e.g. /authorize). |
| 201 Created | Resource created | A Principal, Agent, Mandate, webhook, Client, or key was created. |
| 202 Accepted | Pending verification | A Principal registered in platform_provider mode — verification is pending; the body carries a verification_session. |
| 204 No Content | Success, empty body | A webhook was deleted. |
| 400 Bad Request | Malformed request | The request is structurally invalid (e.g. no fields provided to update, unknown outcome value). |
| 401 Unauthorized | Missing/invalid key | No credential, or a credential that does not authenticate (missing X-API-Key / X-Admin-Key). |
| 403 Forbidden | Not allowed | Authenticated but not permitted: wrong scope, IP not allowlisted, cross-tenant access, a sandbox-only action on live, or an invalid admin key. |
| 404 Not Found | No such resource | The Principal, Agent, Mandate, webhook, Client, or credential does not exist (or isn't yours). |
| 409 Conflict | State conflict | A uniqueness constraint was violated — e.g. a duplicate client_customer_ref. |
| 422 Unprocessable | Validation / business rule | The request parsed but failed schema or a business rule (e.g. a Program you don't own, a bad phone format). |
| 429 Too Many Requests | Rate limited | You exceeded a rate limit. Back off and retry (see below). |
| 5xx Server Error | Server-side fault | An unexpected error. Safe to retry with backoff. Note: /authorize is designed never to 500 — it fails safe to a decision. |
Error codes
The error field carries a stable machine code. The onboarding API in particular maps its business rules to these codes; build your error handling against them.
| Code | Status | Meaning |
|---|---|---|
| program_not_owned | 422 | A program_id in the request does not belong to the calling Client. |
| duplicate_client_customer_ref | 409 | The client_customer_ref already exists for this Client — refs are unique per tenant. |
| principal_not_in_program | 422 | An agent's program_id isn't one the Principal is enrolled in, or program_id is required because the Principal is in 0 or >1 Programs. |
| principal_not_found | 404 | The Principal (or the agent's Principal) was not found for this Client. |
| invalid_phone_format | 422 | SMS step-up was requested but no valid E.164 phone (step_up.phone or profile.phone) was provided. |
| attestation_required | 422 | verification.mode is client_attested but the required attestation block is missing. |
| idempotency_key_reuse | 422 | The same Idempotency-Key was sent with a different request body. |
| sandbox_only | 403 | A sandbox-only action (e.g. minting a per-Principal sandbox key) was attempted with a live credential. |
| validation_error | 422 | Generic request-schema validation failure (missing/typed/out-of-range field). |
| unauthorized | 401 | Authentication failed — missing or invalid X-API-Key / X-Admin-Key. |
| forbidden | 403 | Authorization failed — scope, IP allowlist, cross-tenant access, or invalid admin key. |
Messages are tuned for humans and may change. The error code and HTTP status are the stable contract.
Idempotency
The onboarding create endpoints — POST /onboard, POST /principals, POST /principals/{id}/agents, and POST /agents/{id}/mandates — accept an Idempotency-Key header so a retried request can't create a duplicate.
- Retry with the same key + same body → the original response (status and body) is replayed. No second Principal, Agent, or Mandate is created.
- Same key + a different body →
422 idempotency_key_reuse. A key is bound to the exact request that first used it. - Scope & retention → keys are scoped per Client and retained for roughly 24 hours, after which a key may be reused for a new operation.
Use a fresh UUID per logical operation — generate it once for the operation, reuse it across network retries of that same operation, and never recycle it for a different payload.
curl -X POST https://api.mandatelabs.ai/api/v1/principals \ -H "X-API-Key: mdt_live_…" \ -H "Idempotency-Key: 8f1c2e3a-9b7d-4f60-a1c2-2d3e4f5a6b7c" \ -H "Content-Type: application/json" \ -d '{ "principal_kind": "merchant", "program_ids": ["prg_…"], "client_customer_ref": "acme-001", "profile": { "display_name": "Acme Corp", "email": "[email protected]", "country": "US" }, "verification": { "type": "KYB", "mode": "client_attested", "attestation": { "attested_by": "[email protected]", "scope": "Tier-2 CDD", "statement": "Acme performed CDD on this merchant", "evidence_ref": "case-123" } } }' # Re-sending the identical request with the SAME Idempotency-Key # replays the original 201 response — no duplicate Principal is created.
POST /authorize takes its idempotency key in the request body as idempotency_key (8–128 chars), not the header — a duplicate transaction with the same key replays the original decision instead of producing a second Authorization.
Retries & rate limits
A simple, safe policy:
- Retry
5xxand429with exponential backoff and jitter. For429, respect anyRetry-Afterhint before retrying. - Always send an
Idempotency-Keyon create POSTs so those retries can't duplicate work — the original response is replayed. - Do not blindly retry
4xx— they are terminal and indicate a request you must fix (bad input, missing scope, conflict). The one exception is an idempotent replay of the same create request, which is always safe. - Cap retries (e.g. 3–5 attempts) and surface the
request_idin your logs and any support tickets.
The /authorize endpoint is engineered never to answer 500 to a caller — on an internal fault it returns a safe decision rather than an error. You should still apply backoff to transient network failures.