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
Errors come back in one of two JSON envelopes. Branch on the HTTP status first and the machine-readable code second, never on the human-readable message. Log the request_id so support can trace the call; responses also carry it in the X-Request-ID header.
Platform envelope
Used on every endpoint for authentication failures, request validation, missing resources and server errors. Here error is an object, and docs_url links to the code's entry on this page.
{
"error": {
"code": "VALIDATION_ERROR",
"message": "1 validation error(s) in request",
"request_id": "7c41e0b2",
"docs_url": "https://mandatelabs.ai/docs/errors#VALIDATION_ERROR",
"details": {
"errors": [
{
"field": "body -> country",
"message": "String should have at least 3 characters",
"type": "string_too_short"
}
]
}
}
}
Onboarding envelope
The onboarding endpoints (/onboard, /principals and its sub-resources, /agents/{id}/mandates) report their business rules in a flat envelope: error is the code string, and the status is mirrored in the body. Validation and authentication failures on those endpoints still use the platform envelope.
{
"error": "idempotency_key_reuse",
"message": "Idempotency-Key was reused with a different request body.",
"status": 422,
"request_id": "3f9a2c1d"
}
One check handles both shapes:
body = resp.json() err = body.get("error") code = err["code"] if isinstance(err, dict) else err # "VALIDATION_ERROR" or "idempotency_key_reuse"
const body = await resp.json(); const code = typeof body.error === "object" ? body.error.code : body.error;
Schema failures return 422 with code VALIDATION_ERROR and one entry per offending field in details.errors. Rules checked inside the request schema land here too: a client_attested Principal without an attestation block reports Value error, attestation_required, and an SMS step-up without a valid E.164 phone reports Value error, invalid_phone_format.
POST /authorize answers a valid request with 200 and a decision. A transaction outside the mandate, a high risk score or an unknown agent comes back as DECLINE or STEP_UP with reason codes (see Reason codes), not as an error.
HTTP status codes
The API uses conventional HTTP semantics. The onboarding envelope also mirrors the status in its body.
| 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, a credential that does not authenticate on this endpoint, an expired or revoked key, or an invalid Bearer token. Also returned when a Client credential with several Principals calls a runtime endpoint other than /authorize without X-Principal-Id. |
| 403 Forbidden | Not allowed | Authenticated but not permitted: wrong scope, IP not allowlisted, cross-tenant access, an X-Principal-Id outside your Client, 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 the per-key rate limit, or your IP made too many failed authentication attempts. Wait the number of seconds in Retry-After (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
Platform codes
Returned in error.code. The code is derived from the error message, so treat it as a hint and branch on the HTTP status first: the same code can occasionally appear with a status other than the one listed. When no more specific code applies, error.code is ERROR.
| Code | Status | Meaning |
|---|---|---|
| VALIDATION_ERROR | 422 | The request body, path or query failed schema validation. See details.errors. |
| INVALID_API_KEY | 401 | No X-API-Key, or a key that does not authenticate on this endpoint (for example a per-Principal key on the onboarding API). An invalid or expired Bearer token also returns 401, with code ERROR. |
| API_KEY_EXPIRED | 401 | The per-Principal key is past its expiry date. |
| API_KEY_REVOKED | 401 | The per-Principal key was revoked. |
| AGENT_NOT_FOUND | 404 | The agent does not exist, or is not yours. On /authorize an unknown agent is a DECLINE with this reason code instead. |
| PRINCIPAL_NOT_FOUND | 404 | The Principal does not exist, or is not yours. |
| MANDATE_NOT_FOUND | 404 | The mandate does not exist, or is not yours. |
| NOT_FOUND | 404 | Any other missing resource, such as a webhook or an authorization. |
| TENANT_MISMATCH | 403 | The resource exists but belongs to another Principal. |
| KYC_REQUIRED | 403 | The Principal has not completed KYC or KYB verification. |
| STEP_UP_REQUIRED | 400 | A step-up confirm or deny could not be applied, for example because the challenge expired or was already answered. |
| RATE_LIMITED | 429 | Too many requests. Wait the number of seconds in Retry-After. |
| INTERNAL_ERROR | 500 | An unexpected server fault. Safe to retry with backoff; quote the request_id if you contact support. |
| ERROR | varies | No more specific code applies. Examples: a scope or IP-allowlist rejection (403), an X-Principal-Id outside your Client (403), a webhook URL that is not public HTTPS (422). |
| BAD_REQUEST UNAUTHORIZED FORBIDDEN CONFLICT SERVICE_UNAVAILABLE UNKNOWN_ERROR MANDATE_VIOLATION | varies | Rare. Generic codes for a failure that carries no message, named after its status. |
Onboarding codes
Returned as the error string in the onboarding envelope.
| 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. |
| rail_not_enabled | 422 | A mandate names a rail that the agent's Program does not enable. |
| principal_not_found | 404 | The Principal (or the agent's Principal) was not found for this Client. |
| 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. |
OAuth token errors
POST /api/v1/oauth/token follows RFC 6749 §5.2 instead of either envelope, for example {"error": "invalid_client", "error_description": "Client authentication failed."}. Codes: invalid_request (400, client_id or client_secret missing), unsupported_grant_type (400), and invalid_client (401, unknown id, wrong secret, or a credential that is no longer valid).
Messages are tuned for humans and may change. The HTTP status and the error code 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, wait for theRetry-Afterseconds before retrying. Responses carryX-RateLimit-LimitandX-RateLimit-Remainingso you can slow down before you hit the limit. - Make retries safe. Send an
Idempotency-Keyheader on the onboarding create POSTs and anidempotency_keyin the body on/authorize; the original response is replayed. Other create endpoints (POST /agents,/mandates,/webhooks) take no key, so check whether a timed-out create succeeded before retrying it. - 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.