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.
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.
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.
- 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:
| Event | Category | Fires when |
|---|---|---|
credential.added | lifecycle | A credential was created (any type — verifiable or custom). |
credential.updated | lifecycle | A credential's stored fields were edited. |
credential.verified | transition | A credential reached a terminal verification outcome against the issuing registry. |
credential.linked | lifecycle | An owner (worker or applicant) was linked to a credential. |
credential.unlinked | lifecycle | An owner reference was removed from a credential. |
credential.transferred | lifecycle | A credential's owner moved from one holder to another (e.g. the applicant→worker hire flow). |
recruitmentCheck.completed | lifecycle | An applicant finished a recruitment check — every requested credential has a result. |
fetchRequest.completed | lifecycle | A worker finished a fetch request — every requested credential submitted. |
webhook.test | utility | A 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"
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.
The aspect invariants are enforced for every write path (OpenAPI, GraphQL, ingestion), so they hold
here too. Each returns 400:
namemissing →name is requireddelivery.urlnothttps://→ rejected by the validatoreventscontaining 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"]
}
}
| Field | Effect |
|---|---|
entityTypes | Only events on these entity URN types (e.g. ["credential"]). |
organisations | Only events scoped to these org URNs. |
ownerExternalIds | Only events for workers/applicants whose external id matches. |
jurisdictions | Only 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:
| Header | Value |
|---|---|
Content-Type | application/json; charset=utf-8 |
X-Oho-Event | The event type (e.g. credential.verified). |
X-Oho-Delivery | The same UUID as deliveryId in the body. Use as an idempotency key. |
X-Oho-Tenant | The 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-Signature | t=<unix-millis>,v1=<hex-sha256> — carries one or more v1= values (see below). |
Authorization | Basic <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 headers | Anything 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:
<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.- 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
| Key | Meaning |
|---|---|
t | Unix epoch in milliseconds at the moment Oho computed the signature. |
v1 | A 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
| Mistake | Result |
|---|---|
| Using parsed/re-serialised JSON instead of the raw body | Digest mismatch — every event rejected. |
Comparing strings with == instead of hmac.compare_digest / crypto.timingSafeEqual | Vulnerable to timing attacks. |
Treating t as seconds instead of milliseconds | Replay window check rejects fresh events. |
Forgetting the . separator between t and the body | Digest mismatch. |
Allowing requests with no X-Oho-Signature header through | Trivial spoofing. |
Reading only one v1 (e.g. a dict keyed by v1) instead of checking every entry | Half 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"
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).
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-..."] }.
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 */}
}
| Field | Description |
|---|---|
deliveryId | Stable 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. |
eventType | Same string as in your subscription. |
emittedAt | When the event was generated server-side (ISO 8601, nanosecond precision). |
entityUrn | The Oho URN of the entity the event is about. |
tenant | Which 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. |
data | Event-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:
| Field | Description |
|---|---|
success | true 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. |
eligibility | The primary compliance signal: MAY_ENGAGE, MAY_NOT_ENGAGE, REVIEW_REQUIRED. (IN_PROGRESS / ERROR never reach a webhook.) |
statusDetail | The reason behind the signal: VALID, EXPIRING_SOON, EXPIRED, NOT_CURRENT, REVOKED, NOT_FOUND, CONDITIONS_TO_REVIEW, PENDING_DECISION, etc. |
verifiedAt | When Oho recorded the outcome (ISO 8601). |
entityCreatedAt | When 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 / credentialId | The Oho URN of the credential claim and its bare id. |
credential.verifiedCredentialUrn | The URN of the verification result, paired with the claim. |
credential.credentialType | The canonical verifier code — vicwwc, nswwwc, qldblue, ahpra, etc. |
credential.type | The broader family — wwcc, teacher, health, etc. Absent when the family is unknown; fall back to credentialType. |
credential.jurisdiction | State / national code — VIC, NSW, AUS. |
credential.identifier | The card / registration number you submitted (distinct from registry.identifier, which is what the registry returned). |
credential.externalReferenceId | Optional. 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.holder | Structured name parts (firstName, middleName, lastName) as Oho has them on file. middleName present only when known. |
credential.issueDate / expiryDate | Optional, ISO 8601. Prefer the registry's value from this run; fall back to the stored credential value. Omitted if neither is known. |
credential.displayName | Optional. Human-readable label, e.g. WWCC - VIC. |
credential.category | Optional. High-level grouping: SCREENING, GOVERNMENT_ID, LICENSE, CERTIFICATION. |
credential.status | Optional. Card lifecycle state — ACTIVE, EXPIRED, REVOKED, SUSPENDED, PENDING, REVIEW_REQUIRED — reflecting the just-computed outcome. Distinct from eligibility (the compliance signal). |
credential.issuingAuthority | Optional. Who issued the credential (e.g. VicRoads). Distinct from registry.authority (who Oho verified it against), which may differ. |
credential.countryOfIssue | Optional. Issuing country — mainly for passport / travel-document (VEVO) credentials. |
credential.customFieldValues | Optional. Admin-defined custom fields on the credential's type, as {label, value} entries. Present only for custom types that define them. |
registry | What 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. |
owners | Object 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.). |
request | Optional 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. |
changes | Field-level diff against the prior verification (e.g. MAY_ENGAGE → EXPIRED). 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 arrayEarlier 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.) withacceptedAt/acceptedByand, when signed on the capture form,signedAt.bans— a single summary object: overall ban-checkstatus(CLEAR/REVIEW_REQUIRED/MATCH_FOUND/CHECK_FAILED/PENDING), anymatchedVerifiedBanUrns, and theregistriesCheckedthe 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:
| Value | Meaning | Action |
|---|---|---|
ACTIVE | Live, delivering | Skip to Step 3 |
DISABLED | You paused it | Re-enable (Step 2) |
AUTO_DISABLED | Oho disabled it — ~50 consecutive failures | Fix 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_PERMANENTif 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
| Endpoint | Effect |
|---|---|
POST /webhooks | Create (returns secret once). |
GET /webhooks | List 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}/enable | Set status to ACTIVE. |
POST /webhooks/{id}/disable | Set status to DISABLED. |
POST /webhooks/{id}/rotate | Issue a new signing secret. |
POST /webhooks/{id}/ping | Fire a synthetic webhook.test event. |
GET /webhooks/{id}/deliveries | Delivery history. |
POST /webhooks/{id}/redrive | Replay failed deliveries. |
GET /webhooks/events | Live event catalogue. |
Every endpoint requires a Bearer token authorized for the webhook entity — see
Authentication & Tokens.
Recommended setup
- Create the subscription with
events: ["credential.verified", "recruitmentCheck.completed", "fetchRequest.completed"]— covers the high-value lifecycle events. - Store the signing secret in your platform's secret manager. Treat it like a database password.
- Read raw bytes before parsing in your handler.
- Verify the signature with
timingSafeEqual/hmac.compare_digest. - Reject deliveries older than 5 minutes using the
tvalue. - Dedupe on
deliveryIdbefore doing any side-effectful work — retries are normal. - Return 200 quickly, then process asynchronously if the work is heavy. The 15-second timeout is generous but not unlimited.
- Fire
POST /webhooks/{id}/pingas 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:
- A configured webhook signing secret — set
nccWebhookSecretRefunder Settings → Police Checks. Without it, deliveries are refused with503. - 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