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, every error shares one envelope, produced by
a single server-side handler. error is always an object, and it always carries the same three
fields — there is no second shape, and no status at which error collapses to a bare string:
{
"error": {
"code": "ALREADY_LINKED",
"message": "credential is already linked to applicant app_Qz7yT2mB; use /transfer to replace",
"requestId": "01JD8Y2Q4K7N3M0P5R8T9V2W6X"
}
}
{
"error": {
"code": "INVALID_ARGUMENT",
"message": "identity.displayName is required",
"requestId": "01JD8Y30TB5C9E2G4J6L8N0Q2S"
}
}
The practical rule for clients:
error.codeis stable and machine-readable. Branch oncode(combined with the HTTP status), never on prose.error.messageis detail and may be reworded between releases. Show it to humans; don't branch on its text.error.requestIdis the server-generated id for the failed call — the same value as theX-Request-Idresponse header. Log it, and quote it in support tickets.
Because the shape never varies, one parser handles every failure: read error.code, fall back to
the HTTP status if you don't recognise it, and surface error.message to the operator.
Best-effort batch endpoints return 200 and report failures per item rather than as a top-level
error. The credential bulk ownership endpoints reuse the same { code, message } object under each
item's error key (see below); the older
bulk fetch-request endpoint reports each failure as a plain error string alongside
"success": false (see Bulk Import & Attachments). Neither is
the top-level envelope — don't parse them with the same code path.
Status codes
Every error below returns the envelope above — only the code changes.
| Status | Meaning | Typical error.code |
|---|---|---|
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 | INVALID_ARGUMENT, BAD_REQUEST, INVALID_URN |
401 | Missing or invalid token | — (see note below) |
403 | Authenticated, but not authorized for that entity type / operation | FORBIDDEN |
404 | Resource not found | NOT_FOUND |
409 | Conflict with stored state | ALREADY_LINKED, OWNER_MISMATCH, DUPLICATE_ID |
422 | Well-formed and authorized, but violates a business rule | UNPROCESSABLE |
429 | Rate limit exceeded — sets a Retry-After header | TOO_MANY_REQUESTS |
500 | Unexpected server error — detail is in Oho's logs | INTERNAL |
Everything you'd want to branch on programmatically is the HTTP status plus error.code.
A 401 is the exception worth knowing about: it is raised by the authentication layer before the
request reaches a controller, so it doesn't pass through the handler that builds the envelope and
carries no error.code. Treat the 401 status itself as the signal — it always means the same
thing (the token is missing, malformed, or expired) and is never worth branching further on. See
401 vs 403.
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. It arrives in the same envelope
as every other error.)
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": {
"code": "ALREADY_LINKED",
"message": "credential is already linked to applicant app_Qz7yT2mB; use /transfer to replace",
"requestId": "01JD8Y2Q4K7N3M0P5R8T9V2W6X"
}
}
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": {
"code": "OWNER_MISMATCH",
"message": "credential is not owned by applicant app_Qz7yT2mB; current value is wkr_V1StGXR8Z5jdHi6B",
"requestId": "01JD8Y30TB5C9E2G4J6L8N0Q2S"
}
}
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.
Staying under the limit
Reacting to a 429 is the last resort. Every response — not just a rejection — carries
X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset, so a high-volume client can
slow down before it trips the ceiling: when X-RateLimit-Remaining runs low, pause for
X-RateLimit-Reset seconds instead of pressing on into a 429.
The headers, the two rate tiers, and how callers are counted are documented in API Basics → Rate limiting.
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