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.
Every code
| Code | HTTP | Meaning |
|---|---|---|
missing_credentials | 401 | No API key was provided. Send it as Authorization: Bearer opk_live_.... |
invalid_api_key | 401 | The API key is not valid. |
api_key_expired | 401 | This API key has passed its expiry date. Create a new one. |
api_key_revoked | 401 | This API key has been revoked. |
ip_not_allowed | 401 | This API key restricts which addresses may use it, and the request did not come from one of them. |
workspace_inactive | 401 | This workspace is suspended and cannot be accessed through the API. |
insufficient_scope | 403 | This API key does not carry the scopes required for this endpoint. |
validation_failed | 400 | The request could not be processed. See errors for the fields at fault. |
not_found | 404 | No such resource in this workspace. |
conflict | 409 | The request conflicts with the current state of the resource. |
plan_upgrade_required | 402 | This endpoint is not included in the workspace's current plan. |
workspace_read_only | 402 | This workspace cannot be written to because its subscription has lapsed. Reads still work. |
rate_limit_exceeded | 429 | Too many requests. Retry after the interval given in Retry-After. |
internal_error | 500 | Something went wrong on our side. The failure has been logged. |
api_not_configured | 503 | The API is not fully configured on this deployment. Contact support. |
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.