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:

EventWhen
verification.completedA verification reached a final status without needing a person.
verification.review_requiredA verification is waiting for a reviewer.
verification.reviewedA reviewer approved or rejected a verification.
verification.expiredA session expired before the person finished.
kyb_case.completedA business case was approved or declined without a reviewer.
kyb_case.review_requiredA business case is waiting for a reviewer.
kyb_case.reviewedA reviewer approved or declined a business case.
documents.verify.completedA 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.completedA standalone proof-of-address read ended. data.check_id is the id for GET /v1/proof-of-address/read/{id}.
webhook.testYou 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 in secuveri-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.