Webhook Operations
Running a webhook subscription once it's live: rotate the signing secret, read the delivery history, replay failed deliveries, and re-emit a completion that was never sent. The last section covers the separate inbound police-check webhooks that providers send to Oho.
If you haven't set up a subscription yet, start with Webhooks & Event Delivery — discover the events, subscribe, verify the signature and send a test ping. Everything below assumes that's done.
The examples assume $OHO_BASE, $OHO_TOKEN and $WHK (your webhook id) are set. Rotate, redrive
and replay need a token authorized for UPDATE on the webhook entity; reading the delivery history
needs READ — see Authentication & Tokens.
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"
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), 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.
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 — 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.
Replay failed deliveries (redrive)
After fixing a bug in your handler, replay the stored originals. Redrive de-dupes by deliveryId
and re-sends each once, unchanged — the same deliveryId, emittedAt and entityUrn — so your
idempotency check behaves exactly as it would for a live retry. It goes to this webhook only, never
to other webhooks subscribed to the same events. The webhook must be ACTIVE: redriving a disabled
one returns 409 (WEBHOOK_NOT_ACTIVE), so enable it first. 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.
Re-emit a completion that was never sent (replay)
Redrive replays deliveries that happened and failed. It cannot help when a delivery never happened at all — most often because the subscription was created after the check completed. Oho emits live, at the moment of completion, with no catch-up: connect a webhook today and every check that finished yesterday is permanently invisible to it.
replay-completion closes that gap. It rebuilds recruitmentCheck.completed from the check's
current state and emits it down the normal path, writing nothing — status, completedAt and
every aspect are left exactly as they were.
curl -sS -X POST "$OHO_BASE/recruitment-checks/chk_DRpRApsYR8-izLQ9/replay-completion" \
-H "Authorization: Bearer $OHO_TOKEN"
Add ?dryRun=true to see the payload without sending it.
For several checks at once, pass an explicit list — build it from GET /recruitment-checks with
whatever filters you need, so the set that fires is the set you chose:
curl -sS -X POST "$OHO_BASE/recruitment-checks/replay-completions" \
-H "Authorization: Bearer $OHO_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"checkIds": ["chk_DRpRApsYR8-izLQ9", "chk_KKfUEkPvR7USKsk6"],
"requireExternalCheckId": true,
"dryRun": false
}'
{
"data": [
{
"checkId": "chk_DRpRApsYR8-izLQ9",
"externalCheckId": "10208",
"status": "COMPLETED",
"outcome": "EMITTED"
},
{
"checkId": "chk_KKfUEkPvR7USKsk6",
"status": "COMPLETED",
"outcome": "SKIPPED_NO_EXTERNAL_CHECK_ID",
"message": "check has no externalCheckId"
}
],
"meta": { "requestId": "...", "total": 2 }
}
dryRun defaults to true on the bulk form: a bulk replay publishes into someone else's
system, so the safe call is the default and you opt in to sending. Duplicate ids are collapsed, the
batch is capped at 50, and an ineligible check is reported as a skipped row rather than failing the
rest. requireExternalCheckId skips checks with no ATS reference, which a receiver that addresses
its update by that id would reject anyway.
Both endpoints return 202 Accepted — the event has been handed to the dispatcher, which delivers asynchronously with its own retry. It is not confirmation that your endpoint received anything, and certainly not that a downstream system was updated. Read the delivery history for that.
Replayed payloads carry "replay": true so consumers that de-duplicate can tell a re-emission from
a first delivery — deliveryId differs on every attempt and carries no meaning. A check that has
not completed is rejected with 409 CHECK_NOT_COMPLETED; there is no completion to replay.
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 —
nccWebhookSecretRef, the name of the Oho Secret holding the per-tenant HMAC key, on your tenant's police-check settings. Without it, deliveries are refused with503. There is no self-serve field for it yet: inbound webhook delivery is deferred, so the NCC verification source preserves the value but does not expose it in the wizard. Ask Oho to set it. - 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 & Event Delivery — subscribe, verify signatures and send a test event
- Recovering a Broken Webhook Endpoint — the six-step runbook for after an outage
- Webhook Endpoint Reference — every subscription-management call in one table
- Webhook Payload Reference — the delivery envelope and every event type's
databody