Skip to main content

API Basics

Everything you need before making your first call to the Oho REST API: where to send requests, how to authenticate, and the shape of what comes back. These conventions apply to every endpoint, so the Workers & Credentials tutorial and the per-endpoint API Reference assume you've read this page.

Base URL​

The API is served per tenant:

https://<tenant>.weareoho.com/openapi/v1

Replace <tenant> with your Oho tenant subdomain — tenant demo is reached at https://demo.weareoho.com/openapi/v1.

The examples throughout these guides use an $OHO_BASE variable so your tenant is set in one place:

export OHO_BASE="https://<tenant>.weareoho.com/openapi/v1"
export OHO_TOKEN="<your-access-token>"

Authentication​

Every request carries a bearer token in the Authorization header:

curl -sS "$OHO_BASE/workers" \
-H "Authorization: Bearer $OHO_TOKEN"

Generate a token from Settings → Access Tokens in the Oho app. A token carries the entity permissions of the user who created it — it has no separately configured permission set of its own. So calls to /workers require that user to have worker access, and calls to /credentials require credential access.

Keep tokens secret

Treat your token like a password. Don't commit it to source control or paste it into shared docs — set it as an environment variable as shown above.

For the full picture — how entity types and operations combine, the 401 vs 403 distinction, safe rotation, and the server-side oauth integration endpoints — see the Authentication & Tokens deep-dive.

Identifiers​

Resources are addressed by a synthetic, prefixed ID generated by the server at create time:

EntityID shapeExample
Workerwkr_<id>wkr_V1StGXR8Z5jdHi6B
Credentialcred_<id>cred_8x2Kf9aQ4mN7pLrT
Applicantapp_<id>app_Qz7yT2mB
Attachmentatt_<id>att_V1StGXR8Z5jdHi6B
Fetch requestcap_<id>cap_V1StGXR8Z5jdHi6B
Recruitment checkchk_<id>chk_V1StGXR8Z5jdHi6B
Qualificationqlf_<id>qlf_V1StGXR8Z5jdHi6B
Exemptionexm_<id>exm_V1StGXR8Z5jdHi6B
Webhookwhk_<id>whk_V1StGXR8Z5jdHi6B

You never supply these — the server mints them and returns them in the create response and the Location header. Internal resource URNs are never used to address resources through the API. One exception: authorization (403) error messages include the caller's own account URN (see Authentication & Tokens). To find a worker by your own upstream HRIS identifier instead, use GET /workers?externalId=....

Response envelope​

Single-resource reads and writes return a data object with a meta companion:

{
"data": {
"id": "wkr_V1StGXR8Z5jdHi6B",
"type": "worker",
"attributes": { "...": "..." }
},
"meta": { "requestId": "f3c1a0e2-..." }
}

List endpoints return a data array and paging metadata:

{
"data": [{ "id": "wkr_V1StGXR8Z5jdHi6B", "type": "worker", "...": "..." }],
"meta": { "page": 1, "pageSize": 25, "total": 1, "requestId": "..." }
}

pageSize defaults to 25 and is clamped to a maximum of 100.

Soft deletes​

Most DELETEs are soft — they set deleted: true and preserve the record and its history. Soft-deleted resources are excluded from lists unless you pass includeDeleted=true, or deletedOnly=true to list nothing else (see Pagination, filtering & sorting).

Workers and applicants can be brought back:

EndpointWhat it does
POST /workers/{workerId}/restoreClears the flag; the worker returns inactive, aspects intact.
POST /applicants/{applicantId}/restoreClears the flag; the applicant returns with its history intact.

Both are idempotent — restoring something that is not deleted is a no-op — and both need the same permission as DELETE. Restore undoes the tombstone, not the delete's side effects: cancelled fetch requests and resolved review tasks stay as they are, and a recruitment check cancelled with an applicant cannot be revived (its emailed link is permanently dead). Re-request whatever is still needed afterwards, and PATCH a restored worker with isActive: true to resume scheduled verification.

The exception is credential attachments. DELETE /credentials/{id}/attachments/{attachmentId} removes the file permanently and cannot be undone — there is nothing to restore and includeDeleted=true will not bring it back. See Bulk Import & Attachments before you call it.

Request tracing​

Every response carries a server-generated meta.requestId, echoed in the X-Request-Id response header. Quote it in support tickets — it's how Oho finds your call in its logs. If you send your own X-Request-Id (a UUID or 26-character ULID), the server keeps its own id as canonical and echoes yours back in a separate X-Client-Request-Id header so you can line the two systems up.

Errors​

Every error — on every resource endpoint, at every status — returns the same envelope: a single error object holding a code, a message, and the requestId of the failed call. There is no second shape: error is never a bare string.

{
"error": {
"code": "ALREADY_LINKED",
"message": "credential is already linked to applicant app_Qz7yT2mB; use /transfer to replace",
"requestId": "01JD8Y2Q4K7N3M0P5R8T9V2W6X"
}
}
  • code is stable and machine-readable — branch on it, together with the HTTP status.
  • message is human-readable detail and may be reworded between releases. Show it; don't parse it.
  • requestId is the same id as meta.requestId and the X-Request-Id header. Log it, and quote it in support tickets.
StatusMeaning
200OK
201Created (create endpoints; includes a Location header)
204No Content (successful delete)
400Validation error or unparseable filter/path value
401Missing or invalid token
403Authenticated, but not authorized for that entity type
404Resource not found
409Conflict with stored state (code: ALREADY_LINKED, OWNER_MISMATCH, DUPLICATE_ID)
429Rate limited — sets a Retry-After header (see Rate limiting)

Two things sit outside this envelope: the OAuth2 token endpoint, which returns the { "error": ..., "error_description": ... } shape the OAuth2 spec mandates (see Authentication), and 401, which the authentication layer raises before the request reaches the handler that builds the envelope.

For the full picture — conflict recovery, safe retries, idempotency, and matching async webhook deliveries back to a request — see Errors, Retries & Idempotency.

Rate limiting​

Every response from /openapi/** carries the current state of your rate-limit budget, so you can throttle before you hit the ceiling rather than reacting to a 429:

HeaderMeaning
X-RateLimit-LimitRequests per minute allowed for this caller on this tier.
X-RateLimit-RemainingRequests left in the current budget.
X-RateLimit-ResetSeconds (not a timestamp) until the budget is back to X-RateLimit-Limit.

All three are present on every response, success or failure — not just on a 429. On a 429 the response also carries Retry-After, in seconds; at that point X-RateLimit-Remaining is 0 and X-RateLimit-Reset counts down to the next single request rather than to a full budget.

There are two tiers, and X-RateLimit-Limit tells you which one you're on:

  • Standard — 3000 requests/minute. A ceiling on runaway clients, not a commercial quota; a normal integration should never see it.
  • Fan-out — 10 requests/minute on POST /webhooks/{id}/ping and POST /webhooks/{id}/redrive. These endpoints spend someone else's budget — a ping makes a live outbound call to your endpoint, a redrive replays up to 1000 deliveries — so they're metered far more tightly.

Budgets are counted per caller: by actor when you're authenticated, by client IP when you're not.

Counted per pod

The counters live in memory on each API pod, so a deployment running several replicas can allow somewhat more than these numbers in aggregate. Treat the limits as a safety net you shouldn't approach, not a precise meter to run against.

Retry guidance for a 429 — backoff, jitter, and which calls are safe to repeat — is in Errors, Retries & Idempotency.

Try it live​

Each endpoint in the API Reference has a built-in "Try it" panel, and a live Swagger UI is served at /openapi/swagger-ui/index.html (group openapi-v1).

Ready? Walk through a complete flow in the Workers & Credentials tutorial.