Skip to content

Webhooks

When a verification reaches a terminal status, thibit POSTs the result to your configured webhook URL. Configure it per project in the dashboard (Integrate → Webhook) or with PUT /v1/projects/{project_id}/webhook. The signing secret (whsec_…) is minted when the webhook is first set and returned exactly once in that response — store it then. (regenerate=true mints a fresh one; clearing the URL keeps the secret so re-enabling reuses it.)

Events

EventFired when
verification.completedThe engine produced an assessment.
verification.failedThe engine errored terminally.
verification.reviewedA teammate recorded a customer disposition on a Case.
verification.kyb_completedA KYB roll-up resolved — KYB is not in this release; production rejects type: "kyb", so this event does not fire today.

Payload

The default payload is a PII-free envelope — enough to act on, nothing to leak:

{
"event": "verification.completed",
"id": "",
"type": "kyc",
"status": "completed",
"assessment": "no_material_concerns",
"advisory": true,
"confidence": 0.92,
"summary_reasons": [],
"evidence_available": true
}
  • assessment is one of no_material_concerns | manual_review_recommended | material_inconsistency | assessment_incomplete — advisory; the decision is yours.
  • summary_reasons is a short, PII-free list of reason strings.
  • evidence_available tells you a full result exists — fetch it with GET /v1/verifications/{id} over your authenticated API connection.
  • The full result object is embedded only if your project has explicitly opted in to PII in webhooks; by default it is never included.

verification.reviewed additionally carries the team’s outcome, distinct from the assessment: "customer_disposition" and "review": { "state", "at" }, where the state is no_customer_concerns_recorded | customer_review_continues | customer_concern_recorded.

Headers

HeaderValue
X-Thibit-EventThe event name (e.g. verification.completed)
X-Thibit-TimestampUnix seconds when the delivery was signed
X-Thibit-SignatureHex HMAC-SHA256 (see below)

Verify the signature

The signature is HMAC_SHA256(secret, "{timestamp}.{raw_body}"), hex-encoded, where secret is the whsec_… value you received when the webhook was set and raw_body is the exact bytes of the request body. Compute it over the raw body — do not re-serialize the parsed JSON.

import hmac, hashlib
def verify(secret: str, timestamp: str, raw_body: bytes, signature: str) -> bool:
mac = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256)
return hmac.compare_digest(mac.hexdigest(), signature)
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, timestamp, rawBody, signature) {
const mac = createHmac("sha256", secret)
.update(`${timestamp}.`).update(rawBody).digest("hex");
return timingSafeEqual(Buffer.from(mac), Buffer.from(signature));
}

Delivery and retries

Delivery is best-effort: up to 3 attempts with short backoff (roughly 7 seconds end-to-end), then thibit logs the failure and moves on. A 2xx from your endpoint stops the retries. Because delivery is not guaranteed, treat the webhook as a trigger and keep a polling fallback for anything critical.

Receiver behavior

  • Verify the signature before trusting the payload, and reject stale timestamps.
  • Persist the event, then return 2xx quickly. Make handlers idempotent — retries can deliver the same event more than once.
  • Treat the webhook as the trigger; for the full evidence (and fresh state), fetch GET /v1/verifications/{id} before acting.
  • Reconcile by polling if a webhook is critical to your flow.