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" }
}
}
codeis a class, not an incident. It is stable, uppercase, and safe to branch on.messageis 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_ididentifies the exact request in the server log. Quote it in a support conversation; it is usually the difference between a diagnosis and a guess.detailsis present on some failures only. When it is present it carriesreason, 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-is —
ERR_TIMEOUT,ERR_MAINTENANCE, andERR_INTERNALafter a pause. Back off; do not hammer. - Retry after a wait —
ERR_RATE_LIMITED. There is noRetry-Afterheader, so back off on your own schedule. - Fix the request —
ERR_VALIDATION,ERR_CONFLICT. Retrying the same bytes will fail the same way. - Fix the credential or the configuration —
ERR_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