Errors
Every failure is RFC 9457 problem
details, served as application/problem+json:
{
"type": "https://app.operentra.com/docs/api/errors/insufficient_scope",
"title": "Insufficient scope",
"status": 403,
"detail": "This API key does not carry the scopes required for this endpoint.",
"code": "insufficient_scope",
"request_id": "req_577efed0d40a4be3bd493245f50a8712",
"required_scopes": ["attendance:write"]
}
Branch on code, never on the text
code is a stable machine-readable identifier. It is never translated and
never reworded. title and detail are prose for a human reading a log, and
they may be improved at any time.
We have made the opposite mistake in this codebase before: a background job
classified failures by matching English message text, which meant translating
those messages would have broken it silently. That is why code exists.
if (!res.ok) {
const problem = await res.json();
switch (problem.code) {
case 'rate_limit_exceeded':
await sleep(Number(res.headers.get('retry-after') ?? 60) * 1000);
return retry();
case 'api_key_revoked':
case 'api_key_expired':
return alertOperator(problem); // needs a human, not a retry
case 'validation_failed':
return logAndSkip(problem.errors); // our bug; retrying won't help
default:
throw new Error(`${problem.code} (${problem.request_id})`);
}
}
Errors are always English
Even when the rest of your workspace is not. An integrator sent Arabic prose
cannot act on it, and anyone who worked around that by matching on the text
would have built something that breaks the moment a translation is edited.
Accept-Language is ignored on /api/v1.
Always quote the request_id
Every response carries one, in the body and in the X-Request-Id header. It is
the same string our logs record, so quoting it turns "a call failed sometimes
yesterday" into one line we can look at.
Field-level detail
A validation_failed carries an errors array naming what was wrong:
{
"code": "validation_failed",
"status": 400,
"errors": [
{ "field": "punch_time", "message": "Cannot be in the future." },
{ "field": "employee_id", "message": "employee_id must be a UUID" }
]
}
An insufficient_scope carries required_scopes instead, naming the grants
the key was missing.
Plan features
Some resources are part of a plan feature — payroll and loans, recruitment,
and geofenced attendance sites. When the workspace's plan does not include one,
the request answers 402 with code: plan_upgrade_required, and feature
names what an upgrade would add:
{
"code": "plan_upgrade_required",
"status": 402,
"detail": "Payroll is not included in the workspace's Starter plan.",
"feature": "payroll",
"plan": "Starter"
}
Branch on feature, not on detail. This is not something a retry or a
different key will fix: the workspace needs a plan that includes the feature.
Each gated endpoint lists the 402 in its reference entry, and the OpenAPI
document marks it with x-plan-feature.
Every code
A note on 404
Asking for a record that belongs to another workspace returns 404, not 403.
That is deliberate: a 403 would confirm the identifier exists somewhere,
which is an enumeration oracle. From your side the two are indistinguishable,
and that is the point.