Skip to main content

Webhooks & Event Delivery

Set up Oho's outbound webhooks end to end: discover the event catalogue, subscribe, verify the signature on every delivery, send a test event, rotate the signing secret, and read or replay the delivery history — with the receiver code for Node and Python. Each step links to its per-endpoint reference for the complete field and query-parameter list.

Two companions carry the parts you reach for separately: the Webhook Payload Reference for the envelope and every event type's fields, and Recovering a Broken Webhook Endpoint for the runbook to follow after an outage.

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.
worker.addedlifecycleA worker was added to the tenant.
webhook.testutilityA synthetic event you trigger with POST /webhooks/{id}/ping — useful for end-to-end testing.
webhook.disabledlifecycleOho auto-disabled a subscription after 50 consecutive delivery failures.
webhook.disabled needs a separate subscription

Only ACTIVE subscriptions receive events, and the subscription that just tripped the threshold is AUTO_DISABLED by the time the event is emitted — so it never hears about its own disabling. Point a different endpoint at it, one you monitor separately:

{
"name": "webhook health",
"delivery": { "url": "https://ops.example.com/oho" },
"events": ["webhook.disabled"]
}

Payload fields are in the Payload Reference.

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 Webhook Payload Reference 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 delivery uses a common envelope, and each event type carries its own data body. Both are documented in full on their own page:

→ Webhook Payload Reference — the envelope fields, and a field-by-field breakdown of credential.verified, credential.added / credential.updated, recruitmentCheck.completed, the ownership events, webhook.test, and webhook.disabled.

Recovering from a broken endpoint​

Had an outage — a deploy that broke verification for an hour, an expired cert, a database that went away? There's a six-step runbook for finding what you missed, replaying what's recoverable, and reconciling the rest by reading current state:

→ Recovering a Broken Webhook Endpoint

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​