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, how to rotate it safely, and the separate server-side oauth endpoints that broker
third-party 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://api.ohohub.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 carries the actor and the operation that was refused:
{
"error": "urn:li:corpuser:jdoe is unauthorized to CREATE workers."
}
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 against the Oho API — they help Oho obtain and store credentials for third-party
providers on your behalf. They run in the operator's authenticated browser session, so they
carry no API token or signature of their own (security: [] in the spec). You will rarely
call them directly; the Oho app drives them during integration setup. They're documented here so
the flow isn't a black box.
There are two flows today, defined in api/v1/oauth.openapi.yaml.
JobAdder token exchange
POST /oauth/jobadder/token is a CORS-safe, server-to-server proxy for the OAuth
authorization_code exchange against https://id.jobadder.com/connect/token. The browser holds
the one-time authorization code but never the client secret — it sends only a
clientSecretRef, the name of an encrypted secret stored server-side (created in
Settings → Secrets), which Oho resolves and decrypts before calling JobAdder. The 200
response is a verbatim passthrough of JobAdder's token JSON.
curl -sS -X POST "$OHO_BASE/oauth/jobadder/token" \
-H "Content-Type: application/json" \
-d '{
"code": "<one-time-authorization-code>",
"clientId": "<jobadder-client-id>",
"clientSecretRef": "jobadder-client-secret",
"redirectUri": "https://app.ohohub.com/integrations/jobadder/callback"
}'
{
"access_token": "...",
"refresh_token": "...",
"token_type": "Bearer",
"expires_in": 3600
}
redirectUri must match the one used in the authorize step. Notable failure modes:
| Status | Meaning |
|---|---|
400 | clientSecretRef couldn't be resolved or decrypted |
504 | JobAdder didn't respond within 15 seconds |
500 | Unexpected internal error during the exchange |
default | JobAdder rejected the exchange (e.g. 401/403); its status is passed through, with the body wrapped in Oho's error envelope |
NCC police-check consent flow
The NCC (police-check provider) flow is a browser-redirect consent handshake across two endpoints, scoped to one organisation:
GET /oauth/ncc/authorize?organizationUrn=... mints a CSRF state, builds the NCC consent URL
(response_type=code, client_id, redirect_uri, scope, state), and returns a 302
redirect to it. The operator approves consent at NCC. If the deployment's internal NCC app or
authorize URL isn't configured, it returns 503 instead.
curl -sS -i "$OHO_BASE/oauth/ncc/authorize?organizationUrn=urn:li:organisation:org_V1StGXR8Z5jdHi6B"
# 302 Location: https://<ncc-consent-url>?response_type=code&...
GET /oauth/ncc/callback is the redirect target NCC sends the operator back to. It validates the
state, exchanges the code for tokens, and persists the refresh token as an encrypted
per-tenant secret, updating the organisation's police-check settings to point at it. This is the
provider credential Oho rotates against silently from then on, so the operator never handles the
NCC token themselves.
GET /oauth/ncc/callback?code=<auth-code>&state=<csrf-state>
# or, on denial:
GET /oauth/ncc/callback?error=access_denied&error_description=<msg>
The callback always returns 200 with a small self-closing HTML page that postMessages the
result back to the window that opened it:
// posted to window.opener — the message always carries type, ok, and error
{ type: "policecheck.ncc.oauth", ok: true, error: "" }
// every failure mode — consent denied, missing/expired state, expired session,
// exchange or persistence failure — also returns 200, with ok: false and a message
{ type: "policecheck.ncc.oauth", ok: false, error: "<msg>" }
Because there is no non-200 path, the opener window must branch on the ok field rather than on
an HTTP status.
The JobAdder and NCC controllers are annotated @Hidden, so they do not appear in the live
springdoc Swagger UI or the API Reference. api/v1/oauth.openapi.yaml is the
hand-maintained contract for them — treat it as the source of truth.
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 for the full delivery and
verification model.
Where to go next
- API Basics — base URLs, identifiers, response envelope, and error shapes
- Workers & Credentials Tutorial — a complete authenticated flow
- API Reference — every endpoint, field, and query parameter
- Webhooks — receiving and verifying signed events