# 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. |

```shell
{
  "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.

Node.js:

```js
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);
    });
}
```

Python:

```python
import hashlib
import hmac
import time


def verify_secuveri_webhook(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    """raw_body: the request body exactly as received, before any JSON parsing."""
    pairs = [part.split("=", 1) for part in header.split(",") if "=" in part]
    pairs = [(key.strip(), value.strip()) for key, value in pairs]
    timestamp = next((value for key, value in pairs if key == "t"), "")
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance:
        return False
    expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, value) for key, value in pairs if key == "v1")
```

Ruby:

```ruby
require "openssl"

# raw_body: the request body exactly as received (request.raw_post in Rails).
def verify_secuveri_webhook(raw_body, header, secret, tolerance = 300)
  pairs = header.split(",").map { |part| part.split("=", 2).map(&:strip) }.select { |pair| pair.length == 2 }
  timestamp = pairs.find { |key, _| key == "t" }&.last
  return false unless timestamp&.match?(/\A\d+\z/)
  return false if (Time.now.to_i - timestamp.to_i).abs > tolerance

  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
  pairs.any? { |key, value| key == "v1" && secure_compare(expected, value) }
end

def secure_compare(a, b)
  return false unless a.bytesize == b.bytesize

  a.bytes.zip(b.bytes).reduce(0) { |acc, (x, y)| acc | (x ^ y) }.zero?
end
```

Java:

```java
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public final class SecuveriWebhook {
  /** rawBody: the request body exactly as received, before any JSON parsing. */
  public static boolean verify(byte[] rawBody, String header, String secret, long toleranceSeconds) throws Exception {
    String timestamp = null;
    List<String> signatures = new ArrayList<>();
    for (String part : header.split(",")) {
      int index = part.indexOf('=');
      if (index <= 0) continue;
      String key = part.substring(0, index).trim();
      String value = part.substring(index + 1).trim();
      if (key.equals("t") && timestamp == null) timestamp = value;
      if (key.equals("v1")) signatures.add(value);
    }
    if (timestamp == null || !timestamp.matches("\\d{1,12}")) return false;
    if (Math.abs(System.currentTimeMillis() / 1000 - Long.parseLong(timestamp)) > toleranceSeconds) return false;

    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
    mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
    byte[] expected = HexFormat.of().formatHex(mac.doFinal(rawBody)).getBytes(StandardCharsets.UTF_8);
    for (String signature : signatures) {
      if (MessageDigest.isEqual(expected, signature.getBytes(StandardCharsets.UTF_8))) return true;
    }
    return false;
  }
}
```

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.
