Skip to main content

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

  1. You create a webhook subscription at POST /openapi/v1/webhooks with a URL, a list of event types, and optional filters.
  2. Oho generates a signing secret, returns it once in the response, and never shows it again.
  3. When a subscribed event fires, Oho POSTs a JSON payload to your URL with an HMAC-SHA256 signature in the X-Oho-Signature header.
  4. Your endpoint verifies the signature, dedupes on the delivery id, and returns 2xx within 15 seconds.
  5. 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.
Quick answers
  • 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:

FieldEffect
filterEntityTypesOnly events on these entity URN types (e.g. ["credential", "credentialCheck"]).
filterOrganisationsOnly events scoped to these org URNs.
filterOwnerExternalIdsOnly events for workers/applicants whose external id matches.
filterJurisdictionsOnly 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.

EventCategoryFires when
credential.verifiedtransitionA credential has been verified against the issuing registry (success or failure).
recruitmentCheck.completedlifecycleAn applicant has finished a recruitment check — every requested credential submitted.
fetchRequest.completedlifecycleA worker has finished a fetch request — every requested credential submitted.
webhook.testutilityA 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 */
}
}
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.
dataEvent-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:

FieldDescription
successtrue 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.
eligibilityThe primary compliance signal: MAY_ENGAGE, MAY_NOT_ENGAGE, IN_PROGRESS, REVIEW_REQUIRED, ERROR.
statusDetailThe reason behind the signal: VALID, EXPIRING_SOON, EXPIRED, NOT_CURRENT, REVOKED, NOT_FOUND, IN_PROGRESS, CONDITIONS_TO_REVIEW, PENDING_DECISION, ERROR.
verifiedAtWhen the registry actually answered (ISO 8601).
credential.credentialUrnThe Oho URN of the credential claim.
credential.verifiedCredentialUrnThe URN of the verification result, paired with the claim.
credential.credentialTypeThe canonical type code — vicwwc, nswwwc, qldblue, ahpra, etc.
credential.typeThe broader family — wwcc, teacher, health, etc.
credential.jurisdictionState / national code — VIC, NSW, AUS.
credential.identifierThe card / registration number you originally submitted.
credential.holderFirst and last name as Oho has them on file.
registry.authorityDisplay-friendly name of the issuing body.
ownersArray 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.
changesIf the verification flipped a status (e.g. MAY_ENGAGEEXPIRED), 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:

KeyMeaning
tUnix epoch in milliseconds at the moment Oho computed the signature.
v1The 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:

  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.

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

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.

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:

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-Signaturet=<unix-millis>,v1=<hex-sha256> — see above.
AuthorizationBasic <base64(user:pass)> — only if you configured Basic auth on the subscription.
Custom headersAnything 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. (LINEAR is 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 deliveryId on every retry: the UUID is stable across attempts. Your idempotency check must use deliveryId, 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:

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.


Lifecycle 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 with the Manage Features privilege.


  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.

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.