Skip to main content

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.

Before you start

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:

409 — a conflict, with a machine-readable code
{
"error": {
"code": "ALREADY_LINKED",
"message": "credential is already linked to applicant app_Qz7yT2mB; use /transfer to replace",
"requestId": "01JD8Y2Q4K7N3M0P5R8T9V2W6X"
}
}
400 — a validation failure, in exactly the same envelope
{
"error": {
"code": "INVALID_ARGUMENT",
"message": "identity.displayName is required",
"requestId": "01JD8Y30TB5C9E2G4J6L8N0Q2S"
}
}

The practical rule for clients:

  • error.code is stable and machine-readable. Branch on code (combined with the HTTP status), never on prose.
  • error.message is detail and may be reworded between releases. Show it to humans; don't branch on its text.
  • error.requestId is the server-generated id for the failed call — the same value as the X-Request-Id response 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.

Per-item errors inside batch responses

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.

StatusMeaningTypical error.code
200OK—
201Created (includes a Location header)—
204No Content (successful soft delete)—
400Bad input — missing field, malformed body or JSON, unknown sort field, bad URN/formatINVALID_ARGUMENT, BAD_REQUEST, INVALID_URN
401Missing or invalid token— (see note below)
403Authenticated, but not authorized for that entity type / operationFORBIDDEN
404Resource not foundNOT_FOUND
409Conflict with stored stateALREADY_LINKED, OWNER_MISMATCH, DUPLICATE_ID
422Well-formed and authorized, but violates a business ruleUNPROCESSABLE
429Rate limit exceeded — sets a Retry-After headerTOO_MANY_REQUESTS
500Unexpected server error — detail is in Oho's logsINTERNAL

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:

codeWhere it comes fromWhat it means
ALREADY_LINKEDPOST /credentials/{id}/linkThe owner slot you're linking already holds a different id.
OWNER_MISMATCHPOST /credentials/{id}/transferThe 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:

409 ALREADY_LINKED
{
"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:

409 OWNER_MISMATCH
{
"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.

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.

200 — bulk transfer with one conflicting item
{
"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:

statusEndpointMeaning
linkedbulk linkA new owner reference was added.
transferredbulk transferThe owner reference was moved.
noopbulk linkAlready linked to the target — nothing to do (idempotent).
skippedbothConflicted; 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 — supply correlationId in 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 a correlationId and returns it in meta.correlationId; the same id rides through to the resulting credential.verified webhook.

    {
    "data": { "id": "cred_8x2Kf9aQ4mN7pLrT", "...": "..." },
    "meta": {
    "requestId": "...",
    "correlationId": "9b6d6c44-2a1e-4f0b-8b7a-3a2f1e0d9c8b"
    }
    }
Don't supply the literal "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:

  1. Trigger the verification (POST /credentials/{id}/verify) or create a credential (POST /credentials). Capture the correlationId from the response (response.correlationId or meta.correlationId) and store it against the work item you're tracking.
  2. Receive the credential.verified webhook later. Oho includes the same correlationId in 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.)
  3. 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:

KeyDirectionStable acrossUse it to…
correlationIdrequest → webhookthe async verification cycletie a webhook delivery to the request that caused it
deliveryIdwebhook → youretries and redrives of one eventdedupe 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, and DELETE (soft delete) — repeating them converges on the same state. A second DELETE of an already-deleted resource is a no-op.
  • link and transfer — linking an owner that's already linked reports noop; 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:

  1. Always set source.externalId (your HRIS/ATS id) on creates.
  2. 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 with GET /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.
  3. 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​