Webhooks
Add endpoints in Settings → Webhooks. Each one has its own signing secret (whsec_…) and receives a POST with a JSON body for these events:
| Event | When |
|---|---|
verification.completed | A verification reached a final status without needing a person. |
verification.review_required | A verification is waiting for a reviewer. |
verification.reviewed | A reviewer approved or rejected a verification. |
verification.expired | A session expired before the person finished. |
kyb_case.completed | A business case was approved or declined without a reviewer. |
kyb_case.review_required | A business case is waiting for a reviewer. |
kyb_case.reviewed | A reviewer approved or declined a business case. |
documents.verify.completed | A standalone document check ended. data.check_id is the id for GET /v1/documents/verify/{id}; data.status is COMPLETED or FAILED. |
proof_of_address.read.completed | A standalone proof-of-address read ended. data.check_id is the id for GET /v1/proof-of-address/read/{id}. |
webhook.test | You sent a test from the dashboard. data.test is true. |
JSON
{
"id": "0b8f4f5e-3c2a-4d1b-9e7f-6a5c4b3d2e1f",
"type": "verification.completed",
"created_at": "2026-10-23T09:15:02.481Z",
"organization_id": "<your organization id>",
"data": {
"verification_id": "VRF-7KQ2M9XA",
"session_id": "6f1c2e0a-8f43-4f7e-9a51-2b7c1d9e4a10",
"workflow_id": "<workflow id>",
"vendor_data": "customer-1042",
"metadata": null,
"status": "COMPLETE",
"verdict": "APPROVED"
}
}The body holds identifiers and the outcome only. Read the signed result for the reason and the checks.
Check the signature
secuveri-signature is t=<unix seconds>,v1=<hex>, where the hex is HMAC-SHA256 of <t>.<raw body> with your signing secret. Compute it over the body exactly as received, compare in constant time, and refuse timestamps more than 5 minutes old. During a secret change a header can carry two v1 values; accept either.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body exactly as received (a string or Buffer), before any JSON parsing.
export function verifySecuveriWebhook(rawBody, header, secret, toleranceSeconds = 300) {
const pairs = header.split(",").map((part) => part.split("=").map((value) => value.trim()));
const timestamp = Number(pairs.find(([key]) => key === "t")?.[1]);
if (!Number.isInteger(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();
return pairs
.filter(([key]) => key === "v1")
.some(([, signature]) => {
const received = Buffer.from(signature ?? "", "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
});
}When result signing is on, secuveri-jws also carries a detached ES256 JWS of the body (RFC 7515 appendix F) that the keys in /.well-known/jwks.json verify. Its header has iat.
Delivery
- Answer with any 2xx within 10 seconds. Anything else, a redirect or a timeout is retried.
- Retries come after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours: 7 attempts over about 21 hours.
- The same event can arrive more than once.
id(also insecuveri-event-id) stays the same; use it to drop duplicates. - An endpoint that has failed for 24 hours, over at least 5 attempts, is switched off. Turn it on again in Settings → Webhooks.
- Endpoints must be public https URLs. Private and local addresses are refused.