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;
Validation errors

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.

Declines are not errors

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.

StatusMeaningWhen
200 OKSuccessRead succeeded, or a write that returns a body (e.g. /authorize).
201 CreatedResource createdA Principal, Agent, Mandate, webhook, Client, or key was created.
202 AcceptedPending verificationA Principal registered in platform_provider mode; verification is pending; the body carries a verification_session.
204 No ContentSuccess, empty bodyA webhook was deleted.
400 Bad RequestMalformed requestThe request is structurally invalid (e.g. no fields provided to update, unknown outcome value).
401 UnauthorizedMissing/invalid keyNo 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 ForbiddenNot allowedAuthenticated 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 FoundNo such resourceThe Principal, Agent, Mandate, webhook, Client, or credential does not exist (or isn't yours).
409 ConflictState conflictA uniqueness constraint was violated, e.g. a duplicate client_customer_ref.
422 UnprocessableValidation / business ruleThe request parsed but failed schema or a business rule (e.g. a Program you don't own, a bad phone format).
429 Too Many RequestsRate limitedYou 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 ErrorServer-side faultAn 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.

CodeStatusMeaning
VALIDATION_ERROR422The request body, path or query failed schema validation. See details.errors.
INVALID_API_KEY401No 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_EXPIRED401The per-Principal key is past its expiry date.
API_KEY_REVOKED401The per-Principal key was revoked.
AGENT_NOT_FOUND404The agent does not exist, or is not yours. On /authorize an unknown agent is a DECLINE with this reason code instead.
PRINCIPAL_NOT_FOUND404The Principal does not exist, or is not yours.
MANDATE_NOT_FOUND404The mandate does not exist, or is not yours.
NOT_FOUND404Any other missing resource, such as a webhook or an authorization.
TENANT_MISMATCH403The resource exists but belongs to another Principal.
KYC_REQUIRED403The Principal has not completed KYC or KYB verification.
STEP_UP_REQUIRED400A step-up confirm or deny could not be applied, for example because the challenge expired or was already answered.
RATE_LIMITED429Too many requests. Wait the number of seconds in Retry-After.
INTERNAL_ERROR500An unexpected server fault. Safe to retry with backoff; quote the request_id if you contact support.
ERRORvariesNo 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
variesRare. Generic codes for a failure that carries no message, named after its status.

Onboarding codes

Returned as the error string in the onboarding envelope.

CodeStatusMeaning
program_not_owned422A program_id in the request does not belong to the calling Client.
duplicate_client_customer_ref409The client_customer_ref already exists for this Client; refs are unique per tenant.
principal_not_in_program422An 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_enabled422A mandate names a rail that the agent's Program does not enable.
principal_not_found404The Principal (or the agent's Principal) was not found for this Client.
idempotency_key_reuse422The same Idempotency-Key was sent with a different request body.
sandbox_only403A 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).

Branch on the code, not the message

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.

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.
Authorization idempotency

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:

Authorization is fail-safe

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.

Next steps