Skip to main content

Webhooks & Event Delivery

The complete technical reference for Oho's outbound webhooks. It walks the full lifecycle against the REST API — subscribe to events, understand the payload, verify the signature on every delivery, rotate the signing secret, send a test event, read the delivery history, and replay or reconcile what failed — and includes the receiver code, payload field reference, and recovery runbook your endpoint needs. Each step links to its per-endpoint reference for the complete field and query-parameter list.

For a plain-language overview of what webhooks are and when Oho sends them, see the Webhooks concept page.

Before you start

Read API Basics first — it covers base URLs, the Authorization header, IDs, the response envelope, and error shapes that every step here relies on. The examples assume $OHO_BASE and $OHO_TOKEN are set. Calls to /webhooks need a token authorized for the webhook entity (READ to list/get, CREATE to subscribe, UPDATE for rotate/ping/enable/disable/redrive, DELETE to remove) — see Authentication & Tokens.

Two different "webhooks" in Oho

This guide is about outbound webhooks — Oho POSTing events to your endpoint. There is a separate inbound surface under the police-check-webhooks tag where police-check providers POST status updates back to Oho. They share a name and a signing scheme but point in opposite directions; the inbound side is covered in its own section at the end.

Quick reference
  • Endpoint base: /openapi/v1/webhooks
  • URN: urn:li:webhook:whk_<id>
  • Signature header: X-Oho-Signature: t=<unix-millis>,v1=<hex-sha256>
  • Request timeout per attempt: 15 seconds
  • Retry policy: exponential, max 6 attempts, capped at 60s between attempts
  • Auto-disable threshold: 50 consecutive failures

1. Discover the event catalogue

Before subscribing, list the event types you can listen for. The catalogue is the source of truth — subscriptions to anything not in it are rejected at create time.

curl -sS "$OHO_BASE/webhooks/events" -H "Authorization: Bearer $OHO_TOKEN"
{
"data": [
{
"type": "credential.added",
"entityType": "credential",
"category": "lifecycle",
"description": "A Credential was created (any type — verifiable or custom)."
},
{
"type": "credential.updated",
"entityType": "credential",
"category": "lifecycle",
"description": "A Credential's stored fields were edited..."
},
{
"type": "credential.verified",
"entityType": "credential",
"category": "transition",
"description": "A Credential successfully verified against the issuing registry."
},
{
"type": "recruitmentCheck.completed",
"entityType": "recruitmentCheck",
"category": "lifecycle",
"description": "Recruitment check finished — every requested verifiable has a result. Payload carries credentials[] and policeChecks[] (submitted verifiables with their outcome), exemptions[] and declarations[] (the applicant's active records), and a bans summary (ban-check status + registries consented to)."
},
{
"type": "fetchRequest.completed",
"entityType": "fetchRequest",
"category": "lifecycle",
"description": "Fetch request finished — worker has submitted all requested credentials."
},
{
"type": "webhook.test",
"entityType": "webhook",
"category": "utility",
"description": "Synthetic test event delivered by POST /webhooks/{id}/ping."
}
],
"meta": { "requestId": "..." }
}

Subscriptions accept exact event types (e.g. credential.verified) and wildcards: <resource>.* (every event for a resource), *.<action> (one action across resources), or * (firehose). Wildcards are validated against this catalogue at subscription time — a pattern that matches nothing is rejected. Full reference: List subscribable event types.

The events you can subscribe to:

EventCategoryFires when
credential.addedlifecycleA credential was created (any type — verifiable or custom).
credential.updatedlifecycleA credential's stored fields were edited.
credential.verifiedtransitionA credential reached a terminal verification outcome against the issuing registry.
credential.linkedlifecycleAn owner (worker or applicant) was linked to a credential.
credential.unlinkedlifecycleAn owner reference was removed from a credential.
credential.transferredlifecycleA credential's owner moved from one holder to another (e.g. the applicant→worker hire flow).
recruitmentCheck.completedlifecycleAn applicant finished a recruitment check — every requested credential has a result.
fetchRequest.completedlifecycleA worker finished a fetch request — every requested credential submitted.
webhook.testutilityA synthetic event you trigger with POST /webhooks/{id}/ping — useful for end-to-end testing.

2. Subscribe (create a webhook)

name is the only required top-level field, but a useful subscription also needs a delivery.url and at least one events entry. The server generates a synthetic whk_ ID and a fresh signing secret, returns 201 with a Location header, and includes the plaintext signing secret exactly once under data.attributes.delivery.signingSecret. Save it now — there is no API to read it back. See Create webhook subscription for every field.

curl -sS -X POST "$OHO_BASE/webhooks" \
-H "Authorization: Bearer $OHO_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Compliance dashboard sync",
"description": "Owned by #compliance-eng — JIRA OHO-1234",
"delivery": { "url": "https://compliance.example.com/oho/webhook" },
"events": ["credential.verified", "recruitmentCheck.completed"],
"retry": { "maxAttempts": 6, "backoff": "EXPONENTIAL" }
}'
{
"data": {
"id": "whk_2bX9pK4mN1qR8sT3",
"type": "webhook",
"attributes": {
"name": "Compliance dashboard sync",
"description": "Owned by #compliance-eng — JIRA OHO-1234",
"delivery": {
"url": "https://compliance.example.com/oho/webhook",
"status": "ACTIVE",
"signingSecret": "g6vN5kP3qR8sT4uV2wX1yZ0aB7cD9eF6hJ4kL5mN8pQ",
"signingSecretLastFour": "N8pQ",
"signingSecretRotatedAt": "2026-06-29T02:14:07Z"
},
"events": ["credential.verified", "recruitmentCheck.completed"],
"retry": { "maxAttempts": 6, "backoff": "EXPONENTIAL" },
"audit": {
"createdAt": "2026-06-29T02:14:07Z",
"createdBy": "urn:li:corpuser:..."
}
}
},
"meta": { "requestId": "..." }
}
export WHK="whk_2bX9pK4mN1qR8sT3"
export OHO_WEBHOOK_SECRET="g6vN5kP3qR8sT4uV2wX1yZ0aB7cD9eF6hJ4kL5mN8pQ"
Store the secret immediately

On every subsequent read the secret is scrubbed — only signingSecretLastFour comes back. If you lose it, your only recovery is rotation, which invalidates the old value. Put it in your secret manager before moving on.

Validation errors

The aspect invariants are enforced for every write path (OpenAPI, GraphQL, ingestion), so they hold here too. Each returns 400:

  • name missing → name is required
  • delivery.url not https:// → rejected by the validator
  • events containing a type or wildcard not in the catalogue → rejected
  • Basic auth (delivery.basicAuthUsername + delivery.basicAuthPasswordSecret) and bearer auth (delivery.bearerTokenSecret) supplied together — they are mutually exclusive

Optional: narrow what fires

Add a filters block so only the events you care about reach your endpoint:

{
"filters": {
"entityTypes": ["credential"],
"organisations": ["urn:li:organization:..."],
"ownerExternalIds": ["WD-100482"],
"jurisdictions": ["VIC", "NSW"]
}
}
FieldEffect
entityTypesOnly events on these entity URN types (e.g. ["credential"]).
organisationsOnly events scoped to these org URNs.
ownerExternalIdsOnly events for workers/applicants whose external id matches.
jurisdictionsOnly events from credentials in these jurisdictions (e.g. ["VIC", "NSW"]).

Optional: authenticate to your endpoint

If your receiver needs Basic or bearer auth on top of the signature, reference an Oho secret by name — the credential itself never touches the webhook record. Create the secret first in Settings → Secrets (Oho stores it encrypted server-side), then point at it by its ref name:

{
"delivery": {
"basicAuthUsername": "oho-webhook",
"basicAuthPasswordSecret": "webhook-basic-auth-pw"
}
}

Bearer token (a single token held in Oho's secret store):

{
"delivery": {
"bearerTokenSecret": "webhook-bearer-token"
}
}

In both cases Oho resolves and decrypts the secret at delivery time; the token/password is never stored on the subscription or returned by the API. Read responses surface only the secret ref name plus a read-only basicAuthConfigured / bearerTokenConfigured flag. Basic and Bearer are mutually exclusive — both set the Authorization header, so supplying both in one request is rejected with 400. Selecting one mode clears the other's fields; an edit that touches neither leaves the configured auth untouched.

Manage the subscription afterwards with Get, List, Partially update (merge-patch), Replace, and Soft delete. Note: delivery.signingSecret is rejected on PATCH/PUT — use /rotate.

3. Verify the signature on every delivery

Every delivery carries an HMAC-SHA256 signature in the X-Oho-Signature header. Verifying it is mandatory — without it, anyone who learns your URL can forge events.

Each request from Oho includes:

HeaderValue
Content-Typeapplication/json; charset=utf-8
X-Oho-EventThe event type (e.g. credential.verified).
X-Oho-DeliveryThe same UUID as deliveryId in the body. Use as an idempotency key.
X-Oho-TenantThe Oho deployment tenant slug (e.g. xref-poc) — the same value as tenant.id in the body. Unsigned, so use it only for edge routing; trust the signed body's tenant.id for anything security-relevant.
X-Oho-Signaturet=<unix-millis>,v1=<hex-sha256> — carries one or more v1= values (see below).
AuthorizationBasic <base64(user:pass)> if you configured Basic auth, or Bearer <token> if you configured a bearer token — only one can be set (see endpoint authentication).
Custom headersAnything you added via customHeaders at create time (with six reserved names — content-type, authorization, x-oho-event, x-oho-delivery, x-oho-tenant, x-oho-signature — silently dropped if you try).

The signed payload is the timestamp, a literal ., then the raw request body bytes:

signed_payload = "<t>" + "." + <raw-request-body>
v1 = hex( HMAC-SHA256(signing_secret, signed_payload) )

Two details trip people up most often:

  1. <raw-request-body> is the body as bytes — exactly what arrived over the wire, before any JSON parsing or pretty-printing. If your framework re-serialises the JSON before handing it to your handler, the recomputed signature will not match. Read the raw body before parsing.
  2. The timestamp goes inside the signed payload, not as a separate field. Don't try to verify just the body — you'll get a different digest.

Header format

X-Oho-Signature: t=1717900215496,v1=4d3c2a1b0e9f8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a29180716050403020100
KeyMeaning
tUnix epoch in milliseconds at the moment Oho computed the signature.
v1A signature — lowercase hex of an HMAC-SHA256 digest. The header may carry more than one v1 entry.

The v1 prefix is a versioning hook. If Oho ever rolls a stronger algorithm we'll add v2=… alongside v1 so both old and new receivers keep working during transition.

More than one v1: during a secret rotation overlap window, Oho signs each delivery with both the new and the previous secret and sends both signatures — e.g. t=...,v1=<new>,v1=<old>. Collect every v1 value and treat verification as a pass if any of them matches your configured secret. Parsing the header into a map keyed by v1 is a bug — it discards all but one signature and will reject half of your deliveries mid-rotation.

Verify — Node.js (Express)

import crypto from "node:crypto";

const OHO_WEBHOOK_SECRET = process.env.OHO_WEBHOOK_SECRET;
const MAX_AGE_MS = 5 * 60 * 1000; // 5-minute replay window

function verifyOhoSignature(signatureHeader, rawBody) {
if (!signatureHeader) return false;

// Parse `t=...,v1=...` — collect ALL v1 entries (there may be two during a rotation overlap).
let timestamp;
const signatures = [];
for (const kv of signatureHeader.split(",")) {
const idx = kv.indexOf("=");
const k = kv.slice(0, idx).trim();
const v = kv.slice(idx + 1).trim();
if (k === "t") timestamp = v;
else if (k === "v1") signatures.push(v);
}
if (!timestamp || signatures.length === 0) return false;

// Reject deliveries that are too old (replay protection)
if (Math.abs(Date.now() - Number(timestamp)) > MAX_AGE_MS) return false;

// Recompute the digest
const expected = crypto
.createHmac("sha256", OHO_WEBHOOK_SECRET)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");

// Constant-time comparison against every offered signature — a match on any one passes.
return signatures.some((provided) => {
const b = Buffer.from(provided, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
}

Express handler tying it together — note the use of express.raw() so we get the bytes exactly as Oho signed them:

import express from "express";

const app = express();

app.post(
"/oho/webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.headers["x-oho-signature"];
const rawBody = req.body.toString("utf8");

if (!verifyOhoSignature(sig, rawBody)) {
return res.status(401).send("invalid signature");
}

const event = JSON.parse(rawBody);

// Idempotency — drop if we've seen this deliveryId before
if (alreadyProcessed(event.deliveryId)) {
return res.status(200).send("ok (duplicate)");
}

handleEvent(event);
res.status(200).send("ok");
},
);

Verify — Python (Flask)

import hmac
import hashlib
import os
import time

from flask import Flask, request, abort

OHO_WEBHOOK_SECRET = os.environ["OHO_WEBHOOK_SECRET"].encode()
MAX_AGE_MS = 5 * 60 * 1000

def verify_oho_signature(signature_header: str, raw_body: bytes) -> bool:
if not signature_header:
return False

# Collect ALL v1 entries — a rotation overlap sends two (new + old secret).
timestamp = None
signatures = []
for kv in signature_header.split(","):
key, _, value = kv.strip().partition("=")
if key == "t":
timestamp = value
elif key == "v1":
signatures.append(value)
if not timestamp or not signatures:
return False

if abs(int(time.time() * 1000) - int(timestamp)) > MAX_AGE_MS:
return False

signed_payload = f"{timestamp}.".encode() + raw_body
expected = hmac.new(OHO_WEBHOOK_SECRET, signed_payload, hashlib.sha256).hexdigest()
# A match on any offered signature is a pass (constant-time compare).
return any(hmac.compare_digest(expected, provided) for provided in signatures)

app = Flask(__name__)

@app.post("/oho/webhook")
def handle():
raw = request.get_data() # bytes, exactly as received
if not verify_oho_signature(request.headers.get("X-Oho-Signature", ""), raw):
abort(401)
# ... process event ...
return ("ok", 200)

Common signature-verification mistakes

MistakeResult
Using parsed/re-serialised JSON instead of the raw bodyDigest mismatch — every event rejected.
Comparing strings with == instead of hmac.compare_digest / crypto.timingSafeEqualVulnerable to timing attacks.
Treating t as seconds instead of millisecondsReplay window check rejects fresh events.
Forgetting the . separator between t and the bodyDigest mismatch.
Allowing requests with no X-Oho-Signature header throughTrivial spoofing.
Reading only one v1 (e.g. a dict keyed by v1) instead of checking every entryHalf your deliveries rejected mid-rotation.

The body uses one envelope for every event; the per-event fields live under data — see the payload reference below for the full structure and field-by-field breakdown:

{
"deliveryId": "0167d799-f51c-41a9-a777-58a65bb7d305",
"eventType": "credential.verified",
"emittedAt": "2026-06-29T02:23:35.496341557Z",
"entityUrn": "urn:li:credential:...",
"tenant": { "id": "xref-poc", "url": "https://xref-poc.weareoho.com" },
"data": { "...": "event-specific" }
}

4. Send a test event (ping)

Before you depend on real traffic, fire a synthetic webhook.test straight at your URL. Ping is synchronous and deliberately bypasses the event-pattern filter and the active-status check, so it works on a brand-new or even disabled subscription. See Send a test event.

curl -sS -X POST "$OHO_BASE/webhooks/$WHK/ping" -H "Authorization: Bearer $OHO_TOKEN"
{
"data": {
"delivered": true,
"statusCode": 200,
"message": null,
"deliveredAt": "2026-06-29T03:20:00Z"
},
"meta": { "requestId": "..." }
}

A delivered: false with a statusCode/message tells you exactly why the round-trip failed — ideal as a smoke test on every deploy of your receiver.

5. Rotate the signing secret

Rotate after a suspected leak, on a scheduled cadence, or if you lost the original. The new secret is returned once, in the same place as on create (data.attributes.delivery.signingSecret). See Rotate the signing secret.

curl -sS -X POST "$OHO_BASE/webhooks/$WHK/rotate" -H "Authorization: Bearer $OHO_TOKEN"
Rotation has a built-in overlap window

Oho keeps the previous secret alive for a 24-hour overlap window (configurable per deployment) after a rotation. During the window every delivery is signed with both secrets — the header carries two v1= signatures (t=...,v1=<new>,v1=<old>), so a receiver still verifying with the old secret keeps working while you roll to the new one. After the window Oho signs with the new secret only.

To roll safely: rotate, save the new secret, deploy it to your receiver within the window, then let the window elapse. As long as your verifier checks every v1 entry (see the verify example above), there is no delivery gap in either direction. You can see whether an overlap is currently active on the subscription — the read response exposes delivery.previousSigningSecretLastFour and delivery.previousSigningSecretExpiresAt while the window is open.

6. Read the delivery history

Every attempt — success or failure — is recorded to a per-subscription timeseries, newest-first. Use it for diagnostics and to find the deliveryId of a failed attempt to replay. Default cap is 200 entries, max 1000. See List delivery history.

# Most recent attempts
curl -sS "$OHO_BASE/webhooks/$WHK/deliveries" -H "Authorization: Bearer $OHO_TOKEN"

# Only what's gone permanently wrong in a window
curl -sS "$OHO_BASE/webhooks/$WHK/deliveries?outcome=EXHAUSTED&startTimeMillis=1717804800000&limit=1000" \
-H "Authorization: Bearer $OHO_TOKEN"
{
"data": [
{
"eventType": "credential.verified",
"deliveryId": "0167d799-f51c-41a9-a777-58a65bb7d305",
"attempt": 6,
"outcome": "EXHAUSTED",
"statusCode": 503,
"latencyMs": 142,
"timestampMillis": 1717900215496,
"emittedAt": "2026-06-09T02:23:35Z",
"errorMessage": "Receiver returned HTTP 503",
"payloadTruncated": false
}
],
"meta": { "pageSize": 200, "total": 1, "requestId": "..." }
}

outcome is one of DELIVERED, FAILED_RETRYABLE, FAILED_PERMANENT, or EXHAUSTED (a retryable failure that used up every attempt).

How delivery, retries, and auto-disable work

Each attempt times out after 15 seconds. Failures retry per the subscription's policy (exponential by default — 1s, 2s, 4s, 8s, 16s, 32s, capped at 60s between attempts; LINEAR is also supported). Network errors and 5xx are retried; 4xx is treated as permanent (Oho assumes you've decided you don't want this event). Max attempts is 6 by default, configurable per subscription via retry.maxAttempts (1–10). Every retry re-uses the same deliveryId, so your idempotency check must key on deliveryId, not a timestamp or content hash. After 50 consecutive failures the subscription flips to AUTO_DISABLED and stops delivering — re-enable it with Enable a subscription once your endpoint is healthy. Disable pauses it manually.

7. Replay failed deliveries (redrive)

After fixing a bug in your handler, replay the stored originals. Redrive de-dupes by deliveryId and re-emits each once with its original stored payload — so the replayed event carries the same deliveryId, and your idempotency check behaves exactly as it would for a live retry. Filter by time window and/or outcomes, or target specific deliveryIds. See Redrive failed deliveries.

curl -sS -X POST "$OHO_BASE/webhooks/$WHK/redrive" \
-H "Authorization: Bearer $OHO_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"startTimeMillis": 1717804800000,
"endTimeMillis": 1717891200000,
"outcomes": ["EXHAUSTED", "FAILED_RETRYABLE"]
}'
{
"data": {
"matched": 120,
"dispatched": 118,
"skippedTruncated": 1,
"skippedNoPayload": 1,
"deliveryIds": ["0167d799-f51c-41a9-a777-58a65bb7d305", "..."]
},
"meta": { "requestId": "..." }
}

Target a single attempt instead with { "deliveryIds": ["0167d799-..."] }.

What can't be replayed

Deliveries whose original payload exceeded the 64 KB store cap come back as payloadTruncated: true and are counted under skippedTruncated — there's nothing stored to resend, so reconcile those by reading current state (e.g. GET /credentials?updatedAfter=...). Entries with no stored payload at all are counted under skippedNoPayload.

Payload reference

Every payload uses the same envelope:

{
"deliveryId": "<uuid>",
"eventType": "credential.verified",
"emittedAt": "<iso-8601>",
"entityUrn": "urn:li:credential:...",
"tenant": { "id": "xref-poc", "url": "https://xref-poc.weareoho.com" },
"data": {/* event-specific */}
}
FieldDescription
deliveryIdStable per-event UUID. Sent in the X-Oho-Delivery header too. Use it as your idempotency key — retries of the same event re-use the same id.
eventTypeSame string as in your subscription.
emittedAtWhen the event was generated server-side (ISO 8601, nanosecond precision).
entityUrnThe Oho URN of the entity the event is about.
tenantWhich Oho deployment sent this webhook: tenant.id is the deployment slug (e.g. xref-poc), tenant.url its base URL. Present on every event; also sent (id only) in the X-Oho-Tenant header. Route on it when one endpoint receives events from more than one Oho instance.
dataEvent-specific body. See examples below.

credential.verified

credential.verified fires on every terminal verification outcome — success (MAY_ENGAGE), failure (MAY_NOT_ENGAGE), or REVIEW_REQUIRED. It does not fire for IN_PROGRESS (not terminal) or ERROR (transient — retries are expected, so emitting would create noise).

A payload from a successful VIC WWC verification (MAY_ENGAGE / VALID):

{
"deliveryId": "0167d799-f51c-41a9-a777-58a65bb7d305",
"eventType": "credential.verified",
"emittedAt": "2026-06-09T02:23:35.496341557Z",
"entityUrn": "urn:li:credential:wwcc-vic-1234567A",
"tenant": { "id": "xref-poc", "url": "https://xref-poc.weareoho.com" },
"data": {
"success": true,
"eligibility": "MAY_ENGAGE",
"statusDetail": "VALID",
"verifiedAt": "2026-06-09T02:23:35.486984338Z",
"entityCreatedAt": "2024-01-15T03:22:10.114Z",
"credential": {
"credentialUrn": "urn:li:credential:wwcc-vic-1234567A",
"credentialId": "wwcc-vic-1234567A",
"verifiedCredentialUrn": "urn:li:verifiedCredential:vicwwc-VIC-1234567A",
"credentialType": "vicwwc",
"type": "wwcc",
"jurisdiction": "VIC",
"identifier": "1234567A",
"externalReferenceId": "HRIS-99213",
"holder": {
"firstName": "Jane",
"middleName": "Mary",
"lastName": "Smith"
},
"issueDate": "2024-01-15",
"expiryDate": "2029-01-15",
"displayName": "WWCC - VIC",
"category": "SCREENING",
"status": "ACTIVE",
"issuingAuthority": "Services Victoria",
"countryOfIssue": "Australia",
"customFieldValues": [{ "label": "Cost centre", "value": "CC-4471" }]
},
"registry": {
"authority": "Services Victoria",
"expiryDate": "2029-01-15",
"cardType": "Employee",
"holderName": "SMITH, Jane Mary",
"identifier": "1234567A",
"flags": { "conditions": "NONE" }
},
"owners": {
"applicants": [
{
"urn": "urn:li:applicant:app_8d3c2e91",
"id": "app_8d3c2e91",
"externalId": "ATS-55021"
}
],
"workers": []
},
"request": {
"source": "api",
"correlationId": "req_7f3a9c22"
},
"changes": {}
}
}

What each data.* field carries:

FieldDescription
successtrue only when eligibility is MAY_ENGAGE; false for every other outcome, including REVIEW_REQUIRED. A convenience flag — drive real business logic from eligibility and statusDetail.
eligibilityThe primary compliance signal: MAY_ENGAGE, MAY_NOT_ENGAGE, REVIEW_REQUIRED. (IN_PROGRESS / ERROR never reach a webhook.)
statusDetailThe reason behind the signal: VALID, EXPIRING_SOON, EXPIRED, NOT_CURRENT, REVOKED, NOT_FOUND, CONDITIONS_TO_REVIEW, PENDING_DECISION, etc.
verifiedAtWhen Oho recorded the outcome (ISO 8601).
entityCreatedAtWhen the credential record was first created in Oho (ISO 8601). Stamped on the credential at creation and persisted, so it is stable and present on every credential.* event (verified, added, updated, and the ownership events). Records created before this field existed are backfilled on their next create-time write or verification — from the first-write timestamp when available, otherwise the earliest date otherwise known on the record.
credential.credentialUrn / credentialIdThe Oho URN of the credential claim and its bare id.
credential.verifiedCredentialUrnThe URN of the verification result, paired with the claim.
credential.credentialTypeThe canonical verifier code — vicwwc, nswwwc, qldblue, ahpra, etc.
credential.typeThe broader family — wwcc, teacher, health, etc. Absent when the family is unknown; fall back to credentialType.
credential.jurisdictionState / national code — VIC, NSW, AUS.
credential.identifierThe card / registration number you submitted (distinct from registry.identifier, which is what the registry returned).
credential.externalReferenceIdOptional. Your own ID for this credential in your upstream system, if you supplied one. Lets you match back without keeping an Oho-ID map.
credential.holderStructured name parts (firstName, middleName, lastName) as Oho has them on file. middleName present only when known.
credential.issueDate / expiryDateOptional, ISO 8601. Prefer the registry's value from this run; fall back to the stored credential value. Omitted if neither is known.
credential.displayNameOptional. Human-readable label, e.g. WWCC - VIC.
credential.categoryOptional. High-level grouping: SCREENING, GOVERNMENT_ID, LICENSE, CERTIFICATION.
credential.statusOptional. Card lifecycle state — ACTIVE, EXPIRED, REVOKED, SUSPENDED, PENDING, REVIEW_REQUIRED — reflecting the just-computed outcome. Distinct from eligibility (the compliance signal).
credential.issuingAuthorityOptional. Who issued the credential (e.g. VicRoads). Distinct from registry.authority (who Oho verified it against), which may differ.
credential.countryOfIssueOptional. Issuing country — mainly for passport / travel-document (VEVO) credentials.
credential.customFieldValuesOptional. Admin-defined custom fields on the credential's type, as {label, value} entries. Present only for custom types that define them.
registryWhat the issuing authority returned. authority is the display name; expiryDate, cardType, holderName, identifier, and a free-form flags map are each present only when the registry supplied them.
ownersObject grouped by owner type: owners.applicants and owners.workers. Both keys are always present (empty array when none). Each entry carries the full urn, the bare id (app_… / wkr_…), and — best-effort — externalId, the owner's id in your own system (Workday Employee_ID, ATS reference, etc.).
requestOptional block. source (e.g. api, manual_ui, auto_verify, ongoing, ingestion) and the caller's correlationId, echoed so you can match the delivery back to the originating request.
changesField-level diff against the prior verification (e.g. MAY_ENGAGEEXPIRED). The key is omitted entirely on the first-ever verification (no prior to compare); present-but-empty ({}) on a re-verify where nothing changed.
owners is an object, not an array

Earlier drafts showed owners as a flat array of ids (["app_8d3c2e91"]). The real payload is the grouped { "applicants": [...], "workers": [...] } object above. Index owners.applicants / owners.workers.

The exact set of fields under credential, registry, and changes can vary by credential type — different registries return different metadata. Treat unknown fields as forward-compatible additions.

recruitmentCheck.completed

{
"deliveryId": "8a0d5e3f-c4a2-4b6e-9c7d-1f2e3a4b5c6d",
"eventType": "recruitmentCheck.completed",
"emittedAt": "2026-06-09T03:14:22.118Z",
"entityUrn": "urn:li:credentialCheck:chk_2bX9pK4mN1qR8sT3",
"tenant": { "id": "xref-poc", "url": "https://xref-poc.weareoho.com" },
"data": {
"checkId": "chk_2bX9pK4mN1qR8sT3",
"checkUrn": "urn:li:credentialCheck:chk_2bX9pK4mN1qR8sT3",
"applicantUrn": "urn:li:applicant:app_8d3c2e91",
"screeningPackageCode": "oho0001",
"status": "COMPLETED",
"submittedAt": "2026-06-09T03:14:21.802Z",
"completedAt": "2026-06-09T03:14:22.118Z",
"credentials": [
{
"credentialId": "cred_5fA1bC2d",
"credentialUrn": "urn:li:credential:cred_5fA1bC2d",
"credentialType": "WWCC_NSW",
"displayName": "WWCC - NSW",
"status": "ACTIVE",
"eligibility": "MAY_ENGAGE",
"statusDetail": "VALID",
"verificationAuthority": "Service NSW",
"lastVerifiedDate": "2026-06-09T03:14:21.900Z",
"expiryDate": "2029-01-15",
"referenceNumber": "WWC1234567E",
"jurisdiction": "NSW"
}
],
"policeChecks": [
{
"policeCheckId": "pc_9zY8xW7v",
"policeCheckUrn": "urn:li:policeCheck:pc_9zY8xW7v",
"displayName": "NPC - Jane Doe - 2026-06-09",
"kind": "NPC",
"provider": "NCC",
"externalId": "ncc_abc123",
"expiryDate": "2029-06-09",
"decision": "SUITABLE",
"result": "NDCO",
"checkStatus": "complete",
"resultDate": "2026-06-09",
"reviewRequired": false,
"pending": false
}
],
"exemptions": [
{
"exemptionId": "exm_3kL8dF2a",
"exemptionUrn": "urn:li:exemption:exm_3kL8dF2a",
"exemptionType": "BLUE_CARD_EXEMPTION",
"exemptsFromCredentialType": "BLUE_CARD",
"displayName": "QLD Blue Card Exemption",
"status": "ACTIVE",
"eligibility": "MAY_ENGAGE",
"statusDetail": "VALID",
"verificationAuthority": "Blue Card Services",
"lastVerifiedDate": "2026-06-09T03:14:21.960Z",
"referenceNumber": "1986805/1",
"expiryDate": "2027-01-15",
"jurisdiction": "QLD",
"grantingAuthority": "Blue Card Services"
}
],
"declarations": [
{
"declarationId": "decl_6mP2qR9t",
"declarationUrn": "urn:li:declaration:decl_6mP2qR9t",
"declarationCode": "CUSTOM_decl0001",
"displayName": "Code of Conduct Acknowledgement",
"status": "ACTIVE",
"acceptedAt": "2026-06-09T03:14:15Z",
"acceptedBy": "urn:li:applicant:app_8d3c2e91",
"signedAt": "2026-06-09T03:14:16Z"
}
],
"bans": {
"status": "CLEAR",
"lastCheckedAt": "2026-06-09T03:14:22.000Z",
"matchedVerifiedBanUrns": [],
"weakSignalMatchedUrns": [],
"registriesChecked": ["AGED_CARE_QUALITY"],
"consentRecordedAt": "2026-06-09T03:14:21.802Z"
}
}
}

The payload groups results by type:

  • credentials[] — each verifiable credential the applicant submitted, with its latest verification outcome (eligibility, statusDetail, verificationAuthority, lastVerifiedDate).
  • policeChecks[] — National/International Police Check results (decision, result, reviewRequired).
  • exemptions[] — the applicant's active exemptions (dispensations from holding a normally-required credential), with the same verification-outcome fields as credentials.
  • declarations[] — the applicant's active declaration acceptances (Code of Conduct, Right to Work, etc.) with acceptedAt / acceptedBy and, when signed on the capture form, signedAt.
  • bans — a single summary object: overall ban-check status (CLEAR / REVIEW_REQUIRED / MATCH_FOUND / CHECK_FAILED / PENDING), any matchedVerifiedBanUrns, and the registriesChecked the applicant consented to on this check.

Exemptions and declarations link to the applicant rather than to a specific check, so they reflect the applicant's active records at completion time. Each array (or the bans object) is omitted entirely when there's nothing of that type, and individual fields are omitted when unset — treat them all as forward-compatible additions.

Ownership events: credential.linked / credential.unlinked / credential.transferred

These fire when a credential's ownership changes via the POST /credentials/{id}/link, /unlink, and /transfer endpoints (and their bulk variants). They share the exact credential / owners / request shape of every other credential.* event, and add a changes block describing the owner-slot diff in the same {from, to} form as credential.verified. The owners block always reflects the post-change state.

credential.transferred for the canonical applicant→worker hire flow — the applicant slot was cleared and the worker slot set:

{
"deliveryId": "3f1b2c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"eventType": "credential.transferred",
"emittedAt": "2026-06-09T04:05:12.220Z",
"entityUrn": "urn:li:credential:cred_5fA1bC2d",
"tenant": { "id": "xref-poc", "url": "https://xref-poc.weareoho.com" },
"data": {
"entityCreatedAt": "2024-01-15T03:22:10.114Z",
"credential": {
"credentialUrn": "urn:li:credential:cred_5fA1bC2d",
"credentialId": "cred_5fA1bC2d",
"displayName": "WWCC - NSW",
"credentialType": "nswwwc",
"type": "wwcc",
"jurisdiction": "NSW",
"identifier": "WWC1234567E",
"isVerifiable": true,
"status": "ACTIVE"
},
"owners": {
"applicants": [],
"workers": [
{
"urn": "urn:li:worker:wrk_9a1b",
"id": "wrk_9a1b",
"externalId": "EMP-4471"
}
]
},
"request": { "source": "api" },
"changes": {
"applicantUrn": { "from": "urn:li:applicant:app_8d3c2e91", "to": null },
"workerUrn": { "from": null, "to": "urn:li:worker:wrk_9a1b" }
}
}
}

For credential.linked the changes block carries only the slot that was set ("from": null); for credential.unlinked, only the slot that was cleared ("to": null).

webhook.test

Fired by POST /webhooks/{id}/ping — use it to validate your endpoint end-to-end without waiting for a real event.

{
"deliveryId": "c4a2-...",
"eventType": "webhook.test",
"emittedAt": "2026-06-09T03:20:00Z",
"entityUrn": "urn:li:webhook:whk_2bX9pK4mN1qR8sT3",
"tenant": { "id": "xref-poc", "url": "https://xref-poc.weareoho.com" },
"data": {
"subscriptionId": "whk_2bX9pK4mN1qR8sT3",
"message": "Synthetic test event delivered by POST /webhooks/{id}/ping",
"deliveredAt": "2026-06-09T03:20:00Z"
}
}

Recovering from a broken endpoint

When your endpoint has had downtime — a deploy that broke verification for an hour, a database outage, a cert that expired silently — follow this six-step runbook to find what you missed, replay what's recoverable, and reconcile the rest by reading current state.

Step 1 — Is the subscription still active?

GET /openapi/v1/webhooks/{webhookId}

Read data.attributes.delivery.status:

ValueMeaningAction
ACTIVELive, deliveringSkip to Step 3
DISABLEDYou paused itRe-enable (Step 2)
AUTO_DISABLEDOho disabled it — ~50 consecutive failuresFix the endpoint, then re-enable (Step 2)

AUTO_DISABLED is the only signal that Oho stopped delivering on you. There's no failure-count field exposed — status is the flag.

Step 2 — Confirm reachable, then re-enable

Only if DISABLED / AUTO_DISABLED. Test first so you don't immediately trip the threshold again:

POST /openapi/v1/webhooks/{webhookId}/ping
→ { "delivered": true, "statusCode": 200, ... }

If delivered: true, re-enable:

POST /openapi/v1/webhooks/{webhookId}/enable
→ data.attributes.delivery.status == "ACTIVE"

Live delivery resumes from here. Past misses are recovered in Steps 3–5.

Step 3 — Find when you last received a good event

There's no stored "last success" field — compute it from history. List deliveries (returned newest-first) filtered to successes:

GET /openapi/v1/webhooks/{webhookId}/deliveries?outcome=DELIVERED&limit=1

Take data[0].timestampMillis → that's your last-good watermark T_last.

If the list is empty, you've never received one — use your own last-known-good time or skip straight to the full reconcile in Step 6.

Step 4 — List what failed since the watermark

GET /openapi/v1/webhooks/{webhookId}/deliveries
?outcome=EXHAUSTED
&startTimeMillis={T_last}
&endTimeMillis={now}
&limit=1000

Each row (DeliveryDto) carries: deliveryId, eventType, attempt, outcome, statusCode, timestampMillis, emittedAt, errorMessage, payloadTruncated.

  • Note any rows with payloadTruncated: true — those can't be replayed; Step 6 handles them.
  • Repeat with outcome=FAILED_PERMANENT if you also want events your endpoint 4xx-rejected.

Step 5 — Replay (redrive)

Re-send the stored originals for that window:

POST /openapi/v1/webhooks/{webhookId}/redrive
{
"startTimeMillis": {T_last},
"endTimeMillis": {now},
"outcomes": ["EXHAUSTED", "FAILED_RETRYABLE"]
}

The response tells you the split:

{
"matched": 120,
"dispatched": 118,
"skippedTruncated": 1,
"skippedNoPayload": 1,
"deliveryIds": ["..."]
}

Replays arrive at your endpoint as normal signed deliveries. Verify the signature + dedupe on X-Oho-Delivery — the redriven event re-uses its original delivery id.

Step 6 — Reconcile the un-replayable remainder

For skippedTruncated + skippedNoPayload (and as a belt-and-braces sweep), read current state instead. Convert T_last (ms) to ISO-8601:

GET /openapi/v1/credentials?updatedAfter={T_last as ISO}&pageSize=100&sort=lastUpdated:desc

Page through; for each credential, overwrite your local copy from attributes.verification.eligibility / .statusDetail / .expiryDate. This closes any gap replay couldn't.

All webhook endpoints

EndpointEffect
POST /webhooksCreate (returns secret once).
GET /webhooksList subscriptions in your organisation.
GET /webhooks/{id}Read one (does not return secret — only last four characters).
PATCH /webhooks/{id}Merge-patch update (URL, events, filters, custom headers, retry policy).
PUT /webhooks/{id}Replace (preserves secret).
DELETE /webhooks/{id}Soft-delete.
POST /webhooks/{id}/enableSet status to ACTIVE.
POST /webhooks/{id}/disableSet status to DISABLED.
POST /webhooks/{id}/rotateIssue a new signing secret.
POST /webhooks/{id}/pingFire a synthetic webhook.test event.
GET /webhooks/{id}/deliveriesDelivery history.
POST /webhooks/{id}/redriveReplay failed deliveries.
GET /webhooks/eventsLive event catalogue.

Every endpoint requires a Bearer token authorized for the webhook entity — see Authentication & Tokens.

  1. Create the subscription with events: ["credential.verified", "recruitmentCheck.completed", "fetchRequest.completed"] — covers the high-value lifecycle events.
  2. Store the signing secret in your platform's secret manager. Treat it like a database password.
  3. Read raw bytes before parsing in your handler.
  4. Verify the signature with timingSafeEqual / hmac.compare_digest.
  5. Reject deliveries older than 5 minutes using the t value.
  6. Dedupe on deliveryId before doing any side-effectful work — retries are normal.
  7. Return 200 quickly, then process asynchronously if the work is heavy. The 15-second timeout is generous but not unlimited.
  8. Fire POST /webhooks/{id}/ping as a smoke test on every deploy — it exercises the full path including signature verification.

Inbound: police-check webhooks

The police-check-webhooks tag is a separate, inbound surface: police-check providers (NCC, PID) POST status updates to Oho at POST /openapi/v1/policechecks/webhook/{provider}. You don't call this endpoint — the provider does — but it's worth understanding because it feeds the credential.verified events your outbound subscription receives.

Oho verifies the provider's HMAC signature against the raw request bytes using a per-tenant signing secret, looks up the matching policeCheck by external ID, applies idempotency on the provider's event ID, and writes the status update. Unverifiable or unmatched deliveries are rejected (400/401/404); duplicates return 200 with status: "duplicate". See Receive a status webhook from a provider.

Two prerequisites must be in place before a provider can deliver:

  1. A configured webhook signing secret — set nccWebhookSecretRef under Settings → Police Checks. Without it, deliveries are refused with 503.
  2. An authorized provider connection — established through the NCC OAuth consent + token-exchange flow (/openapi/v1/oauth/ncc/authorize/callback), which persists an encrypted per-tenant refresh token. See the NCC police-check consent flow for the handshake and the NCC police check verification source for connecting it in the app.

Where to go next

  • Webhooks — a plain-language overview of what webhooks are and when Oho sends them (for non-technical readers)
  • API Reference — every endpoint, field, and query parameter
  • Workers & Credentials tutorial — the verify flow that emits credential.verified