Docs

Errors

One envelope for every failure, ten generic codes to branch on, and a separate localized message that your client must never parse or compare.

Every non-2xx response from the X-Radius API is the same JSON object. There is no second error shape, no bare string body, and no HTML page — including on a 500.

The envelope

{
  "error": {
    "code": "ERR_VALIDATION",
    "message": "Request validation failed.",
    "request_id": "8f2c1d0a4b",
    "details": { "reason": "invalid_expiration" }
  }
}
  • code is a class, not an incident. It is stable, uppercase, and safe to branch on.
  • message is prose for a human, already translated into the caller's language. It is not a contract. Treat it as unstable in both wording and language.
  • request_id identifies the exact request in the server log. Quote it in a support conversation; it is usually the difference between a diagnosis and a guess.
  • details is present on some failures only. When it is present it carries reason, a stable machine-readable discriminator, plus occasionally a field the caller sent back verbatim so the client can report what it tried without parsing prose.

Success responses are the mirror image: a single object under data, with a meta block added by list endpoints.

The ten generic codes

These ten are the whole vocabulary of the class field. Every status below is the conventional one; a handler picks the status explicitly, so a code can appear with a different status where the flow warrants it.

Code Status Meaning
ERR_VALIDATION 400 The request is malformed, or a value is out of range or not allowed. By far the most common.
ERR_UNAUTHORIZED 401 The credential is missing, malformed, or no longer valid.
ERR_FORBIDDEN 403 The credential is fine; the authority is not.
ERR_NOT_FOUND 404 No such record — or one you may not see. The two are deliberately identical.
ERR_CONFLICT 409 The request contradicts current state: a taken username, an already-redeemed card, an idempotency key bound to a different subject.
ERR_RATE_LIMITED 429 A request budget is spent. See rate limits.
ERR_INTERNAL 500 Something on our side broke. The request_id is the only useful thing you can send us.
ERR_TIMEOUT 503 The work did not fit in the request or statement budget. Nothing is broken; retry, ideally narrower.
ERR_LICENSE_BLOCKED 403 The tenant is over its subscriber cap or past its licence expiry. The whole admin surface is locked to the licence page until that is resolved.
ERR_MAINTENANCE 503 The instance was put into maintenance from the operator console. Not a verdict on your request.

Two of these need a second glance:

ERR_TIMEOUT is 503 and distinct from ERR_INTERNAL on purpose. It fires when the per-request budget or the per-statement database timeout elapses. Nothing is wrong; the query was simply too big for the time allowed. Narrow the filter or the page and try again.

ERR_NOT_FOUND covers both "does not exist" and "exists but is outside your manager subtree". A 403 on those paths would confirm the record exists and make ids enumerable, so the two answers are byte-identical, details.reason included.

One more code can reach you on the tenant API without being in that table: ERR_UNAVAILABLE at 503, with details.reason of site_offline, when an operator has switched the tenant's portal off. It is answered at login and at the subscriber-portal entry points only.

Codes versus reasons

The code field is coarse by design, and several very different outcomes share one. Six distinct login failures all answer ERR_UNAUTHORIZED: no such account, wrong password, two-factor required, two-factor wrong, session revoked, token expired. The class is genuinely the same, and a client that wants to tell them apart needs something finer.

That is details.reason. It is always the message key the server used to build the localized prose, which keeps the two in sync by construction. There are about twelve hundred such keys, so do not try to enumerate them — switch on the handful your integration actually needs, and let everything else fall through to the code.

{
  "error": {
    "code": "ERR_UNAUTHORIZED",
    "message": "Two-factor authentication is required.",
    "request_id": "1c7f0b99e2",
    "details": { "reason": "mfa_required" }
  }
}

Not every failure carries details. Auth failures, rate limits and the idempotency-key rejections do. A plain permission denial does not: a 403 ERR_FORBIDDEN from the permission gate carries only the code and the message.

Messages are localized, so never match on them

The message field is rendered in the caller's language, chosen from the Accept-Language header and falling back to the tenant's default. The same failure is English prose for one client and Arabic prose for another:

curl -s https://acme.example.com/api/v1/users/999999 \
  -H "Authorization: Bearer $XRADIUS_TOKEN" \
  -H "Accept-Language: ar"

A client that compares message text is broken the moment a translator improves a sentence, and broken for every caller whose language is not the one the client was written against. Branch on error.code, then on error.details.reason if you need the detail. Display message to people, log request_id, and treat both as data you pass through rather than logic you depend on.

A practical handler

A reasonable client distinguishes four situations and ignores the rest:

  • Retry as-isERR_TIMEOUT, ERR_MAINTENANCE, and ERR_INTERNAL after a pause. Back off; do not hammer.
  • Retry after a waitERR_RATE_LIMITED. There is no Retry-After header, so back off on your own schedule.
  • Fix the requestERR_VALIDATION, ERR_CONFLICT. Retrying the same bytes will fail the same way.
  • Fix the credential or the configurationERR_UNAUTHORIZED, ERR_FORBIDDEN, ERR_LICENSE_BLOCKED. Nothing your code does at runtime will resolve these.

ERR_NOT_FOUND is the fifth, and belongs to whichever of those your data model says it does.

A retry of anything that moves money or provisions service must reuse the original idempotency key. See idempotency; getting this wrong is how a timeout becomes a double charge.

Last updated

Ask a question

Try it on your own network.

50 subscribers for 7 days. You pay nothing.