Skip to main content

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.

PropertyBehaviour
ExpiryMandatory, at most one year. You are warned by email before it lapses.
RevocationImmediate. The next request with that key fails, everywhere.
IP allowlistOptional per key. The cheapest, strongest mitigation for a leaked key.
Rate limitPer 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).

Never call this API from a browser

/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.

ScopeGrants
employees:readRead employee records, excluding sensitive personal and pay data.
employees:writeCreate, update and deactivate employee records.
employees.sensitive:readRead sensitive employee fields: national ID, compensation and bank accounts.
org:readRead organisation structure: departments, designations, branches, employment types, pay grades, shifts and holidays.
org:writeCreate and update organisation structure.
attendance:readRead attendance records, punches, overtime requests, regularisations, rest-day swaps, shift assignments, geofenced sites and the attendance settings.
attendance:writeRecord and amend attendance and punches, decide overtime and regularisation requests, manage rest-day swaps, assign shifts to employees, and manage geofenced sites.
leave:readRead leave types, balances and applications.
leave:writeApply for, approve and reject leave.
payroll:readRead salary components, structures and assignments, payroll runs, entries and their lines, salary slips, and loans with their installments.
expenses:readRead expense categories, claims and their line items.
documents:readRead document types and employee documents, employment contracts, and issued HR letters with the requests behind them.
assets:readRead asset categories, assets, and issue/return assignments.
announcements:readRead announcement categories and published announcements.
documents:writeUpload and verify employee documents.
recruitment:readRead job openings, candidates and applications.
recruitment:writeCreate and update job openings, candidates and applications.
webhooks:manageCreate, 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

CodeHTTPCause
missing_credentials401No Authorization header, or not a bearer token.
invalid_api_key401Malformed, unknown, or wrong secret.
api_key_expired401Past its expiry date.
api_key_revoked401Revoked in the console.
ip_not_allowed401The key has an allowlist and this address is not on it.
workspace_inactive401The workspace is suspended.
insufficient_scope403Valid 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 .env you 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.