Webhooks
Webhooks are how Oho tells your own software about things that just happened — a credential being verified, a worker submitting a fetch request, a recruitment check completing. You give Oho a URL, subscribe to the events you care about, and Oho POSTs a signed JSON payload there every time one of those events fires.
Use this page when you're wiring Oho up to your HRIS, an internal compliance dashboard, a Slack or Teams bridge, or any other system that needs to react to credential changes without polling.
How it works
- You create a webhook subscription at
POST /openapi/v1/webhookswith a URL, a list of event types, and optional filters. - Oho generates a signing secret, returns it once in the response, and never shows it again.
- When a subscribed event fires, Oho POSTs a JSON payload to your URL with an HMAC-SHA256 signature in the
X-Oho-Signatureheader. - Your endpoint verifies the signature, dedupes on the delivery id, and returns
2xxwithin 15 seconds. - On any non-2xx response, network error, or timeout, Oho retries with exponential backoff up to 6 times. After 50 consecutive failures the subscription is auto-disabled.
- 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
Subscribing
Create a subscription with the events you want. The signing secret is returned once — store it immediately; you can't read it back later (only the last four characters are exposed on subsequent GETs).
curl -X POST https://api.example.com/openapi/v1/webhooks \
-H "Authorization: Bearer $OHO_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Compliance dashboard sync",
"url": "https://compliance.example.com/oho/webhook",
"events": ["credential.verified", "recruitmentCheck.completed"]
}'
Response (the only time you'll see signingSecret):
{
"data": {
"id": "whk_2bX9pK4mN1qR8sT3",
"name": "Compliance dashboard sync",
"url": "https://compliance.example.com/oho/webhook",
"events": ["credential.verified", "recruitmentCheck.completed"],
"status": "ACTIVE",
"signingSecret": "g6vN5kP3qR8sT4uV2wX1yZ0aB7cD9eF6hJ4kL5mN8pQ",
"signingSecretLastFour": "8pQ ",
"retryMaxAttempts": 6,
"retryBackoff": "EXPONENTIAL"
}
}
Save the signingSecret to your own secret store immediately. If you lose it, rotate it (POST /webhooks/{id}/rotate) — Oho generates a new one and invalidates the old.
Optional filters
You can narrow which events fire your webhook:
| Field | Effect |
|---|---|
filterEntityTypes | Only events on these entity URN types (e.g. ["credential", "credentialCheck"]). |
filterOrganisations | Only events scoped to these org URNs. |
filterOwnerExternalIds | Only events for workers/applicants whose external id matches. |
filterJurisdictions | Only events from credentials in these jurisdictions (e.g. ["VIC", "NSW"]). |
Optional HTTP Basic auth
If your endpoint requires Basic auth on top of the signature:
{
"basicAuthUsername": "oho-webhook",
"basicAuthPasswordSecret": "urn:li:dataHubSecret:webhook_basic_auth_pw"
}
The password lives in Oho's encrypted secret store, not the webhook record. Create it once via the secrets API, then reference it by URN.
Event catalogue
Subscribe to any of these by name. Wildcards work too — credential.* matches every credential event, * matches everything.
| Event | Category | Fires when |
|---|---|---|
credential.verified | transition | A credential has been verified against the issuing registry (success or failure). |
recruitmentCheck.completed | lifecycle | An applicant has finished a recruitment check — every requested credential submitted. |
fetchRequest.completed | lifecycle | A worker has finished a fetch request — every requested credential submitted. |
webhook.test | utility | A synthetic event you trigger yourself with POST /webhooks/{id}/ping, useful for end-to-end testing. |
Discover the live catalogue at GET /openapi/v1/webhooks/events — it returns the same list with descriptions and any new events that ship after this page was written.
Payload structure
Every payload uses the same envelope:
{
"deliveryId": "<uuid>",
"eventType": "credential.verified",
"emittedAt": "<iso-8601>",
"entityUrn": "urn:li:credential:...",
"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. |
data | Event-specific body. See examples below. |
Example: credential.verified
A real payload from a VIC WWC verification that came back as MAY_NOT_ENGAGE / NOT_FOUND:
{
"deliveryId": "0167d799-f51c-41a9-a777-58a65bb7d305",
"eventType": "credential.verified",
"emittedAt": "2026-06-09T02:23:35.496341557Z",
"entityUrn": "urn:li:credential:wwcc-vic-1234567A",
"data": {
"success": false,
"eligibility": "MAY_NOT_ENGAGE",
"statusDetail": "NOT_FOUND",
"verifiedAt": "2026-06-09T02:23:35.486984338Z",
"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",
"holder": {
"firstName": "Jane",
"lastName": "Smith"
}
},
"registry": {
"authority": "Services Victoria"
},
"owners": ["app_8d3c2e91"],
"changes": {}
}
}
What each data.* field carries:
| Field | Description |
|---|---|
success | true if the registry returned a positive result, false if anything else (not found, expired, suspended, registry error). Drive your business logic from eligibility and statusDetail, not from this. |
eligibility | The primary compliance signal: MAY_ENGAGE, MAY_NOT_ENGAGE, IN_PROGRESS, REVIEW_REQUIRED, ERROR. |
statusDetail | The reason behind the signal: VALID, EXPIRING_SOON, EXPIRED, NOT_CURRENT, REVOKED, NOT_FOUND, IN_PROGRESS, CONDITIONS_TO_REVIEW, PENDING_DECISION, ERROR. |
verifiedAt | When the registry actually answered (ISO 8601). |
credential.credentialUrn | The Oho URN of the credential claim. |
credential.verifiedCredentialUrn | The URN of the verification result, paired with the claim. |
credential.credentialType | The canonical type code — vicwwc, nswwwc, qldblue, ahpra, etc. |
credential.type | The broader family — wwcc, teacher, health, etc. |
credential.jurisdiction | State / national code — VIC, NSW, AUS. |
credential.identifier | The card / registration number you originally submitted. |
credential.holder | First and last name as Oho has them on file. |
registry.authority | Display-friendly name of the issuing body. |
owners | Array of owner external ids — applicant ids prefixed app_, worker ids prefixed wrk_. Use these to join the event back to a person in your HRIS. |
changes | If the verification flipped a status (e.g. MAY_ENGAGE → EXPIRED), the field-level diff lands here. Empty object when nothing changed. |
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.
Example: recruitmentCheck.completed
{
"deliveryId": "8a0d5e3f-c4a2-4b6e-9c7d-1f2e3a4b5c6d",
"eventType": "recruitmentCheck.completed",
"emittedAt": "2026-06-09T03:14:22.118Z",
"entityUrn": "urn:li:credentialCheck:chk_2bX9pK4mN1qR8sT3",
"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"
}
}
Example: webhook.test
Fired by POST /openapi/v1/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",
"data": {
"subscriptionId": "whk_2bX9pK4mN1qR8sT3",
"message": "Synthetic test event delivered by POST /webhooks/{id}/ping",
"deliveredAt": "2026-06-09T03:20:00Z"
}
}
Signature mechanism
Every delivery carries an HMAC-SHA256 signature in the X-Oho-Signature header. Verifying it is mandatory — without verification, anyone who learns your URL can forge events.
Header format
X-Oho-Signature: t=1717900215496,v1=4d3c2a1b0e9f8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a29180716050403020100
Two comma-separated key-value pairs:
| Key | Meaning |
|---|---|
t | Unix epoch in milliseconds at the moment Oho computed the signature. |
v1 | The signature itself — lowercase hex of an HMAC-SHA256 digest. |
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.
What gets signed
signed_payload = <t> + "." + <raw-request-body>
signature = HMAC-SHA256(signing_secret, signed_payload)
v1 = hex(signature)
Two critical details:
<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.
Verifying — Node.js
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=...`
const parts = Object.fromEntries(
signatureHeader.split(",").map((kv) => {
const [k, v] = kv.split("=");
return [k.trim(), v.trim()];
}),
);
const timestamp = parts.t;
const provided = parts.v1;
if (!timestamp || !provided) 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");
// Constant-time comparison — protects against timing attacks
const a = Buffer.from(expected, "hex");
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");
},
);
Verifying — 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
parts = dict(kv.strip().split("=", 1) for kv in signature_header.split(","))
timestamp = parts.get("t")
provided = parts.get("v1")
if not timestamp or not provided:
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()
return hmac.compare_digest(expected, provided)
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. |
Rotating the secret
curl -X POST https://api.example.com/openapi/v1/webhooks/whk_2bX9pK4mN1qR8sT3/rotate \
-H "Authorization: Bearer $OHO_TOKEN"
The response carries a fresh signingSecret. The old one is invalidated immediately — there is no overlap window. Rotate after a suspected secret leak, or on a scheduled cadence (quarterly is typical).
Headers Oho sends
Every delivery carries:
| 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-Signature | t=<unix-millis>,v1=<hex-sha256> — see above. |
Authorization | Basic <base64(user:pass)> — only if you configured Basic auth on the subscription. |
| Custom headers | Anything you added via customHeaders at create time (with five reserved names — content-type, authorization, x-oho-event, x-oho-delivery, x-oho-signature — silently dropped if you try). |
There is no User-Agent worth filtering on and no X-Oho-Correlation-Id header on the standard delivery path. Correlation comes from your own caller-supplied correlationId on the original request, which is echoed inside the data block.
Retries, backoff, and idempotency
When your endpoint returns anything other than a 2xx within 15 seconds, Oho retries.
- Backoff: exponential by default — 1s, 2s, 4s, 8s, 16s, 32s, capped at 60s. (
LINEARis also supported per subscription if you prefer a predictable cadence.) - Max attempts: 6 by default, configurable per subscription via
retryMaxAttempts(1–10). - Retried on: network errors, timeouts, 5xx responses.
- Not retried on: 4xx responses (treated as a permanent failure — Oho assumes you've decided you don't want this event).
- Same
deliveryIdon every retry: the UUID is stable across attempts. Your idempotency check must usedeliveryId, not a timestamp or content hash.
Auto-disable
After 50 consecutive failures on the same subscription, Oho flips its status to AUTO_DISABLED and stops delivering. The subscription stays configured — you can re-enable it via POST /webhooks/{id}/enable once your endpoint is healthy. This prevents a broken endpoint from generating thousands of pointless retries.
Delivery history and replay
Every attempt — successful or not — is recorded for at least 30 days.
# Last 200 deliveries on a subscription
curl "https://api.example.com/openapi/v1/webhooks/whk_2bX9pK4mN1qR8sT3/deliveries" \
-H "Authorization: Bearer $OHO_TOKEN"
The response lists the deliveryId, attempt number, outcome (DELIVERED, FAILED_RETRYABLE, FAILED_PERMANENT, EXHAUSTED), response status, latency, and a truncated excerpt of any error body.
To replay failed deliveries — for example, after fixing a bug in your handler:
curl -X POST https://api.example.com/openapi/v1/webhooks/whk_2bX9pK4mN1qR8sT3/redrive \
-H "Authorization: Bearer $OHO_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"outcomes": ["FAILED_PERMANENT", "EXHAUSTED"],
"since": "2026-06-08T00:00:00Z",
"until": "2026-06-09T00:00:00Z"
}'
Or replay specific deliveries by id:
{ "deliveryIds": ["0167d799-f51c-41a9-a777-58a65bb7d305"] }
Replays carry the same deliveryId and payload as the original attempt. Your dedup logic will skip them if you've already processed them — that's the point.
Deliveries whose original payload exceeded 64 KB are not replayable; the field gets payloadTruncated: true and you'll need to act on a fresh event.
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.
Lifecycle 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 with the Manage Features privilege.
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.
Next
- See what other lifecycle events look like by tail-watching: Notifications.
- Set up scheduled credential checks that will trigger these events: Ongoing checks.
- Browse the full REST surface: API Reference.