Skip to main content

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.

Before you start

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"
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), 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).

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.

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

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.

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.

202 means emitted, not delivered

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:

  1. 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 with 503. 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.
  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​