Errors, Retries & Idempotency
Practical guidance for the unhappy path: how to read the error envelope, recover from 409
conflicts, retry safely without creating duplicates, trace a call with request IDs, and match an
asynchronous webhook delivery back to the request that triggered it. This page expands the error
table in API Basics into the detail you need for production integrations.
Read API Basics first — it covers base URLs, the Authorization header, IDs, and
the response envelope. The examples assume $OHO_BASE and $OHO_TOKEN are set. Ownership
operations (link, unlink, transfer) and verification are introduced in the
Workers & Credentials tutorial; this page is the reference for what
happens when they fail.
The error envelope
Across the resource endpoints these guides cover, errors share one envelope produced by a single
server-side handler. It carries an error field; message and code appear depending on which
kind of failure occurred. There are two shapes in practice.
Structured failures — conflicts, validation, and malformed JSON — put a stable category in
error and the human-readable detail in message. Conflicts additionally carry a machine-readable
code:
{
"error": "Conflict",
"message": "credential is already linked to applicant app_Qz7yT2mB; use /transfer to replace",
"code": "ALREADY_LINKED"
}
Simple failures — bad input, auth problems, not-found, rate limiting, and unexpected server
errors — put the detail directly in error, with no message or code:
{
"error": "identity.displayName is required"
}
The practical rule for clients:
erroris present on every error from this shared envelope (the lone exception is the leaner config-endpoint shape noted below). Treat it as a category or a short message — never parse it to drive logic.message, when present, is detail and may change between releases. Show it to humans; don't branch on its text.code, when present, is stable and machine-readable. Branch oncode(combined with the HTTP status), never on prose.
A couple of configuration endpoints (for example compliance-check creation) return a leaner
{ "code": ..., "message": ... } body without the error field. The code values are the same,
so branching on HTTP status plus code works consistently everywhere.
Status codes
| Status | Meaning | Envelope shape |
|---|---|---|
200 | OK | — |
201 | Created (includes a Location header) | — |
204 | No Content (successful soft delete) | — |
400 | Bad input — missing field, malformed body or JSON, unknown sort field, bad URN/format | error (detail), sometimes error + message |
401 | Missing or invalid token | error (detail) |
403 | Authenticated, but not authorized for that entity type / operation | error (detail) |
404 | Resource not found | error (detail) |
409 | Conflict with stored state | error ("Conflict") + message + code |
429 | Rate limit exceeded — sets a Retry-After header | error (detail) |
500 | Unexpected server error — detail is in Oho's logs | error ("Internal server error occurred") |
code is emitted on 409 conflicts. Everything you'd want to branch on programmatically is the
HTTP status plus code.
409 conflict recovery
A 409 means your request collided with the current stored state. It is not retryable as-is —
repeating the identical request returns the identical 409. Read the code, reconcile against
current state, and issue the corrected request. On the credential ownership endpoints, two codes
occur:
code | Where it comes from | What it means |
|---|---|---|
ALREADY_LINKED | POST /credentials/{id}/link | The owner slot you're linking already holds a different id. |
OWNER_MISMATCH | POST /credentials/{id}/transfer | The credential is not currently owned by the from you named. |
(A third conflict code, DUPLICATE_ID, is returned when creating a compliance check whose
code-as-id already exists — including one that's been soft-deleted. That endpoint uses the leaner
{ "code": ..., "message": ... } body described above.)
ALREADY_LINKED — recover with transfer
A credential holds at most one worker slot and one applicant slot. Linking an owner of a type
whose slot is empty succeeds; linking the same id that's already there is an idempotent no-op
(200); linking a different id into an occupied slot is the conflict:
{
"error": "Conflict",
"message": "credential is already linked to applicant app_Qz7yT2mB; use /transfer to replace",
"code": "ALREADY_LINKED"
}
The fix is not to retry link — it's to replace the existing owner with
transfer, naming the current occupant as from:
# 1. Read the current owners
curl -sS "$OHO_BASE/credentials/$CRED" -H "Authorization: Bearer $OHO_TOKEN"
# → attributes.owners.applicant == "app_Qz7yT2mB"
# 2. Transfer the slot to the new owner instead of linking
curl -sS -X POST "$OHO_BASE/credentials/$CRED/transfer" \
-H "Authorization: Bearer $OHO_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "from": { "type": "applicant", "id": "app_Qz7yT2mB" },
"to": { "type": "applicant", "id": "app_NEWowner1" } }'
OWNER_MISMATCH — re-read, then retry transfer
Transfer requires the credential to currently be owned by the from you specify. If it isn't —
because someone else already moved it, or you have a stale view — you get:
{
"error": "Conflict",
"message": "credential is not owned by applicant app_Qz7yT2mB; current value is wkr_V1StGXR8Z5jdHi6B",
"code": "OWNER_MISMATCH"
}
The message tells you the actual current owner. Re-read the credential to confirm, then
either retry with the correct from, or treat the move as already done if the credential is
already where you wanted it. This is the expected outcome of two callers racing the same transfer —
the loser sees OWNER_MISMATCH, reconciles, and moves on.
Bulk link and transfer: per-item conflicts, not a 409
The bulk endpoints — POST /credentials/link and
POST /credentials/transfer, with no {id} in the path — are best-effort
batches. One item's conflict does not fail the request: the call returns 200 and reports the
outcome per credential. There is no top-level 409.
{
"data": [
{ "credentialId": "cred_8x2Kf9aQ4mN7pLrT", "status": "transferred" },
{
"credentialId": "cred_bad1",
"status": "skipped",
"error": {
"code": "OWNER_MISMATCH",
"message": "not owned by applicant ..."
}
}
],
"meta": { "total": 2, "succeeded": 1, "failed": 1, "requestId": "..." }
}
Per-item status values:
status | Endpoint | Meaning |
|---|---|---|
linked | bulk link | A new owner reference was added. |
transferred | bulk transfer | The owner reference was moved. |
noop | bulk link | Already linked to the target — nothing to do (idempotent). |
skipped | both | Conflicted; see error.code (ALREADY_LINKED / OWNER_MISMATCH). |
Iterate data and act on each skipped item individually — do not blindly resubmit the whole
batch, or you'll re-process the items that already succeeded. Both bulk endpoints are capped at
500 credentials per request; omit credentialIds to operate on every credential currently
linked to from.
A credential must keep at least one owner
Unlinking is the one ownership call with a rule beyond the conflicts above: a credential can never
be left with zero owners. Unlinking the only remaining owner is rejected — to remove the last
owner, soft-delete the credential instead. Unlinking a slot that's
already empty is an idempotent no-op (200).
Request IDs vs. correlation IDs
Oho exposes two different ids, for two different jobs. Keep them straight.
requestId — per-call tracing
Every response carries a server-generated requestId in both the meta block and the
X-Request-Id response header. Quote it in support tickets — it's how Oho finds your exact call in
its logs.
{
"data": { "...": "..." },
"meta": { "requestId": "f3c1a0e2-9b6d-4c44-8b7a-3a2f1e0d9c8b" }
}
The server always mints its own requestId; it is never taken from your request. If you send
your own X-Request-Id header, it is not promoted to the canonical id — instead, when it's a
well-formed UUID or 26-character ULID, Oho echoes it back in a separate X-Client-Request-Id
response header so you can line your logs up against Oho's. A malformed inbound value is silently
ignored. So to join a call across both systems, send your own UUID as X-Request-Id and correlate
on X-Client-Request-Id in the response; use Oho's meta.requestId when talking to support.
correlationId — matching an async result to its request
Verification is asynchronous: the registry answers later, and the outcome arrives as a
credential.verified webhook. correlationId is the thread that ties that delivery back to the
call that started it. Unlike requestId, you can supply it, and it survives the asynchronous
gap.
There are two entry points:
-
POST /credentials/{id}/verify— supplycorrelationIdin the request body, or omit it and the server generates one. Either way it comes back in the response and is carried onto the webhook.curl -sS -X POST "$OHO_BASE/credentials/$CRED/verify" \-H "Authorization: Bearer $OHO_TOKEN" \-H "Content-Type: application/json" \-d '{ "correlationId": "5b1c8b8a-2c3d-4e5f-9a0b-1c2d3e4f5a6b", "forceRefresh": true }'200 — the verify response echoes your correlationId{"eligibility": "MAY_ENGAGE","statusDetail": "VALID","expiryDate": "2029-05-01","errorMessage": null,"correlationId": "5b1c8b8a-2c3d-4e5f-9a0b-1c2d3e4f5a6b","requestId": "..."} -
POST /credentials— creating a verifiable credential kicks off an automatic first verification. The server mints acorrelationIdand returns it inmeta.correlationId; the same id rides through to the resultingcredential.verifiedwebhook.{"data": { "id": "cred_8x2Kf9aQ4mN7pLrT", "...": "..." },"meta": {"requestId": "...","correlationId": "9b6d6c44-2a1e-4f0b-8b7a-3a2f1e0d9c8b"}}
"string"The Swagger "Try it" panel pre-fills request bodies with "string". The verify endpoint rejects
"string" as a correlationId so test clicks don't pollute your webhook stream — send a real
UUID or leave the field out.
Matching an async webhook delivery back to its request
Putting it together, the round trip for a verification you triggered:
- Trigger the verification (
POST /credentials/{id}/verify) or create a credential (POST /credentials). Capture thecorrelationIdfrom the response (response.correlationIdormeta.correlationId) and store it against the work item you're tracking. - Receive the
credential.verifiedwebhook later. Oho includes the samecorrelationIdin the delivery, so you can join it to the request from step 1. (See Webhooks & Event Delivery for the full payload and signature-verification model.) - Verify the signature on the delivery before trusting it, and dedupe on the delivery id
(
X-Oho-Delivery/deliveryId) before doing any side-effectful work — Oho retries failed deliveries and replays (redrives) reuse the same delivery id, so the same event can legitimately arrive more than once.
Two distinct idempotency keys are in play here, and they are not interchangeable:
| Key | Direction | Stable across | Use it to… |
|---|---|---|---|
correlationId | request → webhook | the async verification cycle | tie a webhook delivery to the request that caused it |
deliveryId | webhook → you | retries and redrives of one event | dedupe repeated deliveries of the same event |
So correlationId answers "which of my requests is this?" and deliveryId answers "have I already
processed this exact delivery?". A robust receiver uses both.
Retrying safely
When a call fails for a transient reason, retry it — but only the transient ones, and only in a way that can't double-apply a write.
Safe to retry (with backoff): 429 (honor the Retry-After header), any 5xx,
and network-level failures — timeouts, connection resets, DNS hiccups — where you never received a
response at all.
Do not retry as-is — fix the request or reconcile first: 400, 401, 403, 404 (the
request is wrong and will keep failing) and 409 (recover per
above). Hammering these wastes calls and can trip rate limits.
Backoff
For the retryable cases, use exponential backoff with jitter rather than a tight loop. For 429
specifically, the response sets a Retry-After header (in seconds) — wait at least that long
before the next attempt.
async function callWithRetry(doRequest, { maxAttempts = 5 } = {}) {
for (let attempt = 1; ; attempt++) {
const res = await doRequest();
if (res.status < 400) return res;
const retryable = res.status === 429 || res.status >= 500;
if (!retryable || attempt === maxAttempts) return res; // surface 4xx / give up
const retryAfter = Number(res.headers.get("retry-after")) || 0;
const backoff = Math.min(2 ** (attempt - 1) * 500, 30_000); // 0.5s,1s,2s,4s… capped 30s
const jitter = Math.random() * 250;
await new Promise((r) =>
setTimeout(r, Math.max(retryAfter * 1000, backoff) + jitter),
);
}
}
Idempotency: retrying writes without creating duplicates
The danger case is a write that timed out — you never got a response, so you don't know whether it landed. Whether a retry is safe depends on the operation.
Naturally idempotent — retry freely:
GET,PUT, andDELETE(soft delete) — repeating them converges on the same state. A secondDELETEof an already-deleted resource is a no-op.linkandtransfer— linking an owner that's already linked reportsnoop; transferring a credential that's already at the destination is a no-op. Re-running the same ownership move doesn't compound.
Not idempotent — POST creates (/workers, /credentials, /fetch-requests, …). The server
mints a fresh id on every call, so a blind retry of a create that secretly succeeded produces a
duplicate record. There is no Idempotency-Key header on the write API today. Instead, lean on
your own upstream identifier:
- Always set
source.externalId(your HRIS/ATS id) on creates. - Before retrying a create that timed out, check whether it already landed. For a worker,
look it up exactly with
GET /workers?externalId=…. Credentials have no by-external-id lookup of their own, so list the owner's credentials withGET /credentials?ownerExternalId=…and see whether the one you were creating is already present. If it is, the original write landed; adopt it instead of creating a second. - Otherwise, retry the create.
# Did the timed-out create actually land? Check before retrying.
curl -sS "$OHO_BASE/workers?externalId=WD-100482" -H "Authorization: Bearer $OHO_TOKEN"
# data: [] → safe to retry the POST
# data: [ { "id": "wkr_…" } ] → it succeeded; use that id, don't create again
On the receiving side, inbound webhook deliveries are made idempotent by deduping on the delivery id — see matching deliveries above.
Where to go next
- API Basics — base URLs, identifiers, response envelope, and the error summary
- Workers & Credentials Tutorial — the link / transfer / verify flows these errors come from
- Webhooks & Event Delivery — delivery payloads, signature verification, retries, and redrive
- API Reference — every endpoint, field, and status code