Authentication & Tokens
A deep dive into how the Oho REST API decides who you are and what you're allowed to touch:
the bearer token you send on every request, how that token is scoped per entity type and
operation, and how to rotate it safely. There are two ways to obtain that token — a personal
access token you generate in the app, or an
OAuth2 client for machine-to-machine integrations. The separate
server-side oauth/jobadder and oauth/ncc endpoints that broker third-party integrations are a
different thing again, covered in OAuth Integrations.
API Basics covers the short version — base URLs, the Authorization header, and
the error envelope. This page expands the Authentication section there into everything you
need for production. The examples assume $OHO_BASE and $OHO_TOKEN are set:
export OHO_BASE="https://<tenant>.weareoho.com/openapi/v1"
export OHO_TOKEN="<your-access-token>"
The bearer token
Every call to the API authenticates with a bearer token in the Authorization header. The header
value must start with Bearer (the prefix is matched case-insensitively); without it the request
is treated as unauthenticated:
curl -sS "$OHO_BASE/workers" \
-H "Authorization: Bearer $OHO_TOKEN"
The token is a signed JWT with a finite lifespan. You don't construct or sign it yourself —
generate one from Settings → Access Tokens in the Oho app ("Access Tokens allow you to make
programmatic requests to Oho's APIs"), copy it once, and store it as a secret in your own
environment. (Deployments wired to an external OAuth identity provider can present a bearer token
from that provider in the same header — but an Oho access token is the default.) A token inherits the privileges of the user who created it — it doesn't carry a
separate, independently configured permission set. The server validates it on every request; a
missing, malformed, or expired token is rejected by the authentication filter with 401 Unauthorized and the message Unauthorized to perform this action.
Anyone holding the token can act as you, with all of your privileges. Don't commit it to source control, paste it into shared docs or tickets, or log it. Inject it from a secrets manager or an environment variable as shown above, and rotate immediately if it leaks (see Rotating a token).
Authenticate with OAuth2
An access token is tied to the person who created it, which makes it a poor fit for a server-side integration: it carries an individual's name, and it dies with their account. For machine-to-machine callers — an HR or payroll system pushing workers into Oho on a schedule — register an OAuth2 client instead and exchange its credentials for a token.
Oho implements the OAuth2 client_credentials grant (RFC 6749 §4.4).
It is the only supported grant type: there is no authorization-code, password, or implicit flow, and
no user consent step.
Register a client
Create the client from Settings → API Credentials in the Oho app. You get back a client_id
(always prefixed oho_oauth_) and a client_secret.
Like an access token, the client_secret is revealed only at creation and cannot be retrieved
afterwards. Store it in your secrets manager straight away. If you lose it, register a new client.
Exchange credentials for a token
POST /oauth/token with a form-encoded body. Note this endpoint takes
application/x-www-form-urlencoded, not JSON — it is the one endpoint in the API that does.
curl -sS -X POST "$OHO_BASE/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=$OHO_CLIENT_ID" \
-d "client_secret=$OHO_CLIENT_SECRET"
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600
}
Credentials may also go in an Authorization: Basic header (client_secret_basic) rather than the
body, if that's what your HTTP client does naturally:
curl -sS -X POST "$OHO_BASE/oauth/token" \
-u "$OHO_CLIENT_ID:$OHO_CLIENT_SECRET" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials"
When both are supplied, the body wins.
Use the token
The access_token is an ordinary Oho bearer token. Send it exactly as you would a personal access
token — every other endpoint on the API treats the two identically:
curl -sS "$OHO_BASE/workers?pageSize=1" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Lifetime and renewal
Tokens last one hour by default (expires_in, in seconds; your deployment may be configured
differently — read the value rather than hard-coding 3600).
client_credentials has no refresh token (RFC 6749 §4.4.3).
To renew, request a new token with the same client credentials. A sound pattern is to cache the
token in memory, reuse it until shortly before expires_in elapses, and fetch a fresh one on
expiry or on the first 401.
Errors
| Status | error | Cause |
|---|---|---|
400 | unsupported_grant_type | grant_type was missing or was not client_credentials |
401 | invalid_client | Unknown client_id, wrong client_secret, or a revoked client |
The body follows the OAuth2 error shape — { "error": ..., "error_description": ... } — rather
than Oho's usual { "error": { "code", "message", "requestId" } } envelope. A failed Basic
attempt also carries a WWW-Authenticate: Basic realm="oho-oauth" header.
Every OAuth2 client in a tenant mints tokens for the same shared service account, and that account holds the Super Admin role. This has two consequences worth planning around:
- There is no per-client scoping. A client registered for a narrow purpose — say, pushing worker records — can read and write everything the API exposes. The per-entity-type authorization below still describes how the API decides, but a Super Admin passes every check.
- The audit trail attributes the action to the service account, not to the client. Writes made through any OAuth2 client are indistinguishable from one another in the history.
Treat the client_secret with the same care as a Super Admin password, and prefer a personal access
token belonging to a purpose-made, least-privilege user where you need the action attributed or the
blast radius contained.
Scopes: per-entity-type authorization
A valid token tells the server who you are. Authorization then decides what you can do,
and it is enforced against your account's privileges per entity type combined with an
operation — not per URL path. Internally each endpoint calls an
isAPIAuthorizedEntityType(operation, entityType) check before doing any work, so two things have
to line up for a request to succeed:
- Your account holds privileges for the entity type the endpoint operates on (
worker,credential, and so on). - Those privileges cover the operation that the HTTP method maps to on that entity.
HTTP methods map to operations consistently across the API:
| HTTP method | Operation |
|---|---|
GET | READ |
POST (create) | CREATE |
PATCH / PUT | UPDATE |
DELETE | DELETE |
So an account that can read workers but not create them will succeed on GET /workers and be
refused on POST /workers, even though both target the same entity type.
Entity types by endpoint group
Each group of endpoints is guarded by one entity type. Privileges on one entity type grant
nothing on another — an account authorized only for worker cannot read credentials.
| Endpoints | Entity type | Operations available |
|---|---|---|
/workers | worker | READ, CREATE, UPDATE, DELETE |
/credentials | credential | READ, CREATE, UPDATE, DELETE |
/recruitment-checks | credentialCheck | READ, CREATE, UPDATE, DELETE |
/fetch-requests | captureRequest | READ, CREATE, UPDATE, DELETE |
/exemptions | exemption | READ, CREATE, UPDATE, DELETE |
/webhooks | webhook | READ, CREATE, UPDATE, DELETE |
/bans | ban | READ only |
/bans is a read-only surface — there is no write operation on the ban entity, so even an
account with broad privileges only ever gets READ there. The table above covers the most-used
endpoint groups; other surfaces (/applicants, /screening-packages, and more) follow the same
entity-type + operation model, each guarded by its own entity type.
401 vs 403: telling the two apart
The distinction between authentication and authorization shows up directly in the status code, and getting them straight saves debugging time:
| Status | Meaning | What to check |
|---|---|---|
401 | The token is missing, malformed, or expired | Is the header present? Has the token been rotated out or expired? |
403 | The token is valid, but the account lacks that entity-type / operation privilege | Does the account behind the token have this entity type and this operation? |
The 403 body names the operation that was refused in error.message. It also includes the
caller's own account URN — the identity behind the token — so the message reads like:
{
"error": {
"code": "FORBIDDEN",
"message": "urn:li:corpuser:jdoe is unauthorized to CREATE workers.",
"requestId": "01JD8Y2Q4K7N3M0P5R8T9V2W6X"
}
}
The urn:li:corpuser:... value identifies the calling account, not any worker or resource;
resources are always addressed by their synthetic wkr_/cred_ IDs.
A 403 is never fixed by re-issuing the same token — the token works, the account behind it just
lacks the privilege. Grant the missing entity-type / operation privilege to that account (via its
role or access policies), or use a token from an account that already has it.
Because a token inherits its creator's privileges, scope access at the account level. Run an
automated job under a service account whose privileges cover only what it needs — e.g. READ on
worker and credential and nothing else. If that token leaks, the blast radius is a read-only
data exposure rather than full write access across every entity type.
Rotating a token
Tokens don't last forever, and any token that may have been exposed should be replaced immediately. Rotate without downtime by overlapping the old and new token rather than swapping in one step:
- Issue a new token in Settings → Access Tokens from the same account (it inherits that account's privileges, just like the one you're replacing). Both tokens are now valid at once.
- Roll out the new token to every caller — update the secret in your secrets manager, redeploy, and confirm traffic is flowing with the new value.
- Revoke the old token once nothing is using it. From that point a stale caller still on the
old value gets
401.
# Sanity-check a freshly issued token against a cheap, read-only endpoint before rolling it out
curl -sS -o /dev/null -w "%{http_code}\n" "$OHO_BASE/workers?pageSize=1" \
-H "Authorization: Bearer $NEW_OHO_TOKEN" # expect 200
A token's value is shown once at creation. If you lose it, you can't retrieve it — issue a new one and revoke the lost one. This is the same one-time-reveal pattern used for webhook signing secrets.
OAuth proxy endpoints
Not to be confused with Authenticate with OAuth2 above. The
/oauth/jobadder/* and /oauth/ncc/* endpoints are a different mechanism again — they don't
authenticate you, they let Oho broker credentials for third-party providers (JobAdder, NCC)
on your behalf during integration setup. You will rarely call them directly. See
OAuth Integrations for the JobAdder token exchange and the NCC police-check
consent flow.
Related: webhook signing secret rotation
Inbound API auth is one half of trust; verifying that a webhook delivery genuinely came from Oho
is the other. Each webhook carries an HMAC signing secret that Oho uses to sign outbound
deliveries, and it rotates on its own endpoint, POST /webhooks/{id}/rotate.
Like an access token, the new secret is returned once. To rotate without dropping deliveries,
accept both the old and new secret during a changeover window (at least 24 hours) while you
roll your verification code. See Webhooks & Event Delivery for the
full delivery and verification model.
Where to go next
- API Basics — base URLs, identifiers, response envelope, and error shapes
- OAuth Integrations — the server-side JobAdder and NCC OAuth proxy flows
- Workers & Credentials Tutorial — a complete authenticated flow
- API Reference — every endpoint, field, and query parameter
- Webhooks & Event Delivery — receiving and verifying signed events