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. The separate server-side oauth endpoints that broker
third-party integrations are 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).
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. It also includes the caller's own account
URN — the identity behind the token — so the message reads like:
{
"error": "urn:li:corpuser:jdoe is unauthorized to CREATE workers."
}
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
The /oauth/* endpoints are a different mechanism from your API token — 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