Authentication
Every request carries an API key as a bearer token:
Authorization: Bearer opk_live_...
There is no other way in. No session cookie, no basic auth, no key in a query string — a key in a URL ends up in server logs, browser history and referrer headers, all of which outlive your intent to keep it secret.
Keys
A key belongs to one workspace and acts as a service account within it. It
is created in the app under Settings → API keys by someone holding the
api.manage permission.
The secret is shown once, at creation. We store only a peppered hash, so we cannot show it again and cannot recover it. If it is lost, revoke the key and create another.
| Property | Behaviour |
|---|---|
| Expiry | Mandatory, at most one year. You are warned by email before it lapses. |
| Revocation | Immediate. The next request with that key fails, everywhere. |
| IP allowlist | Optional per key. The cheapest, strongest mitigation for a leaked key. |
| Rate limit | Per key, set by the workspace's plan — see Rate limits. |
An expired or revoked key returns 401 with api_key_expired or
api_key_revoked, so a monitor can tell those apart from a key that was simply
typed wrong (invalid_api_key).
/api/v1 does not permit cross-origin credentialed requests, and that is not
an oversight we intend to fix. A key in front-end code is readable by everyone
who loads the page. Call the API from your server, and have your front end talk
to your server.
Scopes
A key carries an explicit list of scopes, and they are its only authority. Ask
for the narrowest set that does the job: a directory sync needs
employees:read, not employees:read plus the sensitive grant.
| Scope | Grants |
|---|---|
employees:read | Read employee records, excluding sensitive personal and pay data. |
employees:write | Create, update and deactivate employee records. |
employees.sensitive:read | Read sensitive employee fields: national ID, compensation and bank accounts. |
org:read | Read organisation structure: departments, designations, branches, employment types, pay grades, shifts and holidays. |
org:write | Create and update organisation structure. |
attendance:read | Read attendance records, punches, overtime requests, regularisations, rest-day swaps, shift assignments, geofenced sites and the attendance settings. |
attendance:write | Record and amend attendance and punches, decide overtime and regularisation requests, manage rest-day swaps, assign shifts to employees, and manage geofenced sites. |
leave:read | Read leave types, balances and applications. |
leave:write | Apply for, approve and reject leave. |
payroll:read | Read salary components, structures and assignments, payroll runs, entries and their lines, salary slips, and loans with their installments. |
expenses:read | Read expense categories, claims and their line items. |
documents:read | Read document types and employee documents, employment contracts, and issued HR letters with the requests behind them. |
assets:read | Read asset categories, assets, and issue/return assignments. |
announcements:read | Read announcement categories and published announcements. |
documents:write | Upload and verify employee documents. |
recruitment:read | Read job openings, candidates and applications. |
recruitment:write | Create and update job openings, candidates and applications. |
webhooks:manage | Create, update and delete webhook endpoints, and replay deliveries. |
Three rules worth knowing before you plan a key:
Scopes are AND, not OR. A route requiring two scopes needs both.
There is no hierarchy. employees:write does not confer employees:read.
"May update this record" and "may read the national ID on it" are genuinely
different grants, and collapsing them would mean every writer could read
everything.
Unknown scopes are refused, not ignored. Creating a key with
employees:reed fails at creation. Silently dropping the typo would mint a key
that looks right in the console and then 403s in production, where the cause is
invisible.
Sensitive employee data
employees.sensitive:read is split from employees:read on purpose. An
employee record carries a national ID, compensation and bank accounts; handing
all of that to anything with a plain read scope is a breach-magnitude mismatch,
and most integrations never need it.
Without the scope those fields are omitted from the response, not returned
as null. That distinction matters: null would assert the employee has no
national ID, which is a different and false statement. An absent key means your
key may not see it.
Failures
| Code | HTTP | Cause |
|---|---|---|
missing_credentials | 401 | No Authorization header, or not a bearer token. |
invalid_api_key | 401 | Malformed, unknown, or wrong secret. |
api_key_expired | 401 | Past its expiry date. |
api_key_revoked | 401 | Revoked in the console. |
ip_not_allowed | 401 | The key has an allowlist and this address is not on it. |
workspace_inactive | 401 | The workspace is suspended. |
insufficient_scope | 403 | Valid key, but it lacks a scope this route requires. |
invalid_api_key is deliberately the same answer for "malformed", "no such
key" and "wrong secret". Distinguishing them would tell an attacker which half
of a guess was right.
A 403 carries a required_scopes array naming what was missing, so you can
fix the key without guessing.
Keeping a key safe
- Put it in your secret store, not in source control or a
.envyou commit. - Give each integration its own key. Then revoking one does not break the others, and the usage chart tells you which system is making which calls.
- Set an IP allowlist when the caller has a stable address. A leaked key is then useless from anywhere else.
- Rotate by creating the new key, deploying it, then revoking the old one — in that order, so there is no window where neither works.