Skip to main content

Webhooks & Event Delivery

A copy-paste flow against the REST API for the full webhook lifecycle: subscribe to events, verify the signature on every delivery, rotate the signing secret, send a test event, read the delivery history, and replay failed deliveries. Each step links to its per-endpoint reference for the complete field and query-parameter list.

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.

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 — applicant has submitted all requested credentials."
},
{
"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.

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"]
}
}

Optional: authenticate to your endpoint

If your receiver needs Basic or bearer auth on top of the signature, reference a DataHub Secret URN — the credential itself never touches the webhook record. Create the secret first (via the GraphQL createSecret mutation), then point at it:

{
"delivery": {
"basicAuthUsername": "oho-webhook",
"basicAuthPasswordSecret": "urn:li:dataHubSecret:webhook_basic_auth_pw"
}
}

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 delivery UUID — equals deliveryId in the body; your idempotency key
X-Oho-Signaturet=<unix-millis>,v1=<hex-sha256>

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) )
import hmac, hashlib, os, time

OHO_WEBHOOK_SECRET = os.environ["OHO_WEBHOOK_SECRET"].encode()
MAX_AGE_MS = 5 * 60 * 1000 # reject stale deliveries (replay protection)

def verify(signature_header: str, raw_body: bytes) -> bool:
parts = dict(kv.strip().split("=", 1) for kv in signature_header.split(","))
t, provided = parts.get("t"), parts.get("v1")
if not t or not provided:
return False
if abs(int(time.time() * 1000) - int(t)) > MAX_AGE_MS:
return False
expected = hmac.new(OHO_WEBHOOK_SECRET, f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, provided) # constant-time

The body uses one envelope for every event; the per-event fields live under data:

{
"deliveryId": "0167d799-f51c-41a9-a777-58a65bb7d305",
"eventType": "credential.verified",
"emittedAt": "2026-06-29T02:23:35.496341557Z",
"entityUrn": "urn:li:credential:...",
"data": { "...": "event-specific" }
}
Get the details right

Three mistakes account for nearly every "signature won't validate":

  1. Hashing parsed/re-serialised JSON instead of the raw bytes — read the body before your framework deserialises it.
  2. Treating t as seconds — it is milliseconds.
  3. Comparing with == instead of a constant-time compare.

The Webhooks concept page has full Node.js and Flask receivers, the complete credential.verified payload, and a mistakes table.

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"
Roll the new secret with an overlap window

Rotation replaces the secret immediately; deliveries are signed with the new value from that point on. To avoid rejecting deliveries that were already in flight while you redeploy your verifier, accept both the old and new secret for at least a 24-hour overlap before retiring the old one.

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, capped at 60s between attempts); 5xx and network errors are retried, 4xx is treated as permanent. 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.

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