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.
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.
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"
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"]
}
}
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:
| Header | Value |
|---|---|
Content-Type | application/json; charset=utf-8 |
X-Oho-Event | The event type, e.g. credential.verified |
X-Oho-Delivery | The delivery UUID — equals deliveryId in the body; your idempotency key |
X-Oho-Signature | t=<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" }
}
Three mistakes account for nearly every "signature won't validate":
- Hashing parsed/re-serialised JSON instead of the raw bytes — read the body before your framework deserialises it.
- Treating
tas seconds — it is milliseconds. - 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"
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).
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-..."] }.
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:
- 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 concept page — full receiver code, payload field reference, and a step-by-step recovery runbook
- API Reference — every endpoint, field, and query parameter
- Workers & Credentials tutorial — the verify flow that emits
credential.verified