Skip to main content

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

CodeHTTPMeaning
missing_credentials401No API key was provided. Send it as Authorization: Bearer opk_live_....
invalid_api_key401The API key is not valid.
api_key_expired401This API key has passed its expiry date. Create a new one.
api_key_revoked401This API key has been revoked.
ip_not_allowed401This API key restricts which addresses may use it, and the request did not come from one of them.
workspace_inactive401This workspace is suspended and cannot be accessed through the API.
insufficient_scope403This API key does not carry the scopes required for this endpoint.
validation_failed400The request could not be processed. See errors for the fields at fault.
not_found404No such resource in this workspace.
conflict409The request conflicts with the current state of the resource.
plan_upgrade_required402This endpoint is not included in the workspace's current plan.
workspace_read_only402This workspace cannot be written to because its subscription has lapsed. Reads still work.
rate_limit_exceeded429Too many requests. Retry after the interval given in Retry-After.
internal_error500Something went wrong on our side. The failure has been logged.
api_not_configured503The 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.