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, 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:

409 — a conflict, with 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:

400 — validation detail surfaced in the error field
{
"error": "identity.displayName is required"
}

The practical rule for clients:

  • error is 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 on code (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

StatusMeaningEnvelope shape
200OK
201Created (includes a Location header)
204No Content (successful soft delete)
400Bad input — missing field, malformed body or JSON, unknown sort field, bad URN/formaterror (detail), sometimes error + message
401Missing or invalid tokenerror (detail)
403Authenticated, but not authorized for that entity type / operationerror (detail)
404Resource not founderror (detail)
409Conflict with stored stateerror ("Conflict") + message + code
429Rate limit exceeded — sets a Retry-After headererror (detail)
500Unexpected server error — detail is in Oho's logserror ("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:

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. 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:

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

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

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.

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 idempotentPOST 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