> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sahlfinancial.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Four KYC events, signed with HMAC-SHA256, delivered at least once, up to 6 attempts.

Instead of polling, you can have Sahl post an event to your server when a call finishes. Webhooks are optional. Every KYC event is sent after the call's database commit, in the background, so your API response never waits for your endpoint and never fails because of it.

## Register an endpoint

In the console open **Settings**, then **Webhooks**, then **Add Webhook**.

1. Enter the **Endpoint URL**. It must be `https` and resolve to a public address. Private, loopback and cloud metadata addresses are refused, both when you save and again before every send. Redirects are not followed.
2. Choose the events (below).
3. Save. If you did not supply your own secret (16 characters or more), Sahl generates one, shown once, in the form `whsec_` followed by 48 characters. Copy it.
4. Click **Test** on the endpoint. Sahl posts a `test.ping` event to your URL, signed like a real delivery, and shows the status your server answered. The test payload is not shaped like a KYC event (see [Test ping](#test-ping)).

Endpoint management needs the tenant admin or API manager role. Webhook endpoints are managed in the console, not through the Partner API.

## Events

Only calls that carry a `reference` emit events, because an event names a case. A call without a reference stores nothing and emits nothing.

| Event | Sent after | Notes |
| - | - | - |
| `kyc.documents_read` | `POST /v1/kyc/extract` | |
| `kyc.case_verified` | `POST /v1/kyc/verify` | |
| `kyc.case_assessed` | `POST /v1/kyc/assess` | Instead of `case_verified`. `/assess` sends one event. |
| `kyc.eid_completed` | The first `GET /v1/kyc/eid/{key}` that sees a finished check | Later polls send nothing. Two polls racing on the very first observation can both send one. |

Events are sent for both environments. The payload says which.

## Payloads

Every payload has these four keys.

| Key | Type | Meaning |
| - | - | - |
| `event` | string | The event name. |
| `event_id` | uuid | A new id for each event. The same event re-delivered keeps its `event_id`. |
| `occurred_at` | string | ISO 8601 time with offset, when Sahl built the event. |
| `tenant_id` | uuid | Your workspace id. |

Payloads carry ids, your `reference`, the environment and a verdict summary. They never carry a field value, a name, a date of birth or any document content: deliveries are stored on Sahl's side and sent to a URL you typed in.

### `kyc.documents_read`

```json theme={null}
{
  "event": "kyc.documents_read",
  "event_id": "0b6f1c1e-0000-4000-8000-000000000000",
  "occurred_at": "2026-10-07T12:00:00+00:00",
  "tenant_id": "00000000-0000-4000-8000-000000000000",
  "case_id": "00000000-0000-4000-8000-000000000001",
  "reference": "client-0001",
  "environment": "sandbox",
  "document_ids": ["22222222-2222-4222-8222-222222222222"],
  "reader_unavailable": false,
  "failed_checks": []
}
```

`failed_checks` holds the ids of failed checks with severity `critical` or `warning`, without duplicates.

### `kyc.case_verified` and `kyc.case_assessed`

```json theme={null}
{
  "event": "kyc.case_assessed",
  "event_id": "0b6f1c1e-0000-4000-8000-000000000002",
  "occurred_at": "2026-10-07T12:01:00+00:00",
  "tenant_id": "00000000-0000-4000-8000-000000000000",
  "case_id": "00000000-0000-4000-8000-000000000001",
  "reference": "client-0001",
  "environment": "sandbox",
  "verdict": {
    "status": "passed",
    "failed_checks": ["completeness"],
    "risk_level": "Low",
    "suitability": "Suitable"
  }
}
```

| `verdict` key | Meaning |
| - | - |
| `status` | `passed` or `failed`, the verdict's `passed`. |
| `failed_checks` | Ids of failed critical and warning checks. A screening id is cut to its kind (`screening:sanctions`, `screening:pep`) so no person's name leaves Sahl. |
| `risk_level` | `kyc.case_assessed` only. The compliance risk level: `Low`, `Medium` or `High`. |
| `suitability` | `kyc.case_assessed` only. The suitability sentence. |

### `kyc.eid_completed`

```json theme={null}
{
  "event": "kyc.eid_completed",
  "event_id": "0b6f1c1e-0000-4000-8000-000000000003",
  "occurred_at": "2026-10-07T12:30:00+00:00",
  "tenant_id": "00000000-0000-4000-8000-000000000000",
  "key": 123456,
  "reference": "client-0001",
  "complete": true,
  "passed": true,
  "failed_checks": []
}
```

`complete` is false when the request ended archived without the client finishing. `failed_checks` holds the failed `critical` eID check ids.

### Test ping

The **Test** button sends a different shape, signed the same way:

```json theme={null}
{
  "event_type": "test.ping",
  "timestamp": "2026-10-07T12:00:00+00:00",
  "data": { "message": "This is a test ping from Sahl", "webhook_id": "...", "tenant_id": "..." }
}
```

Its header `X-Sahl-Event` is `test.ping`. It has no `event` or `event_id` key, so route on the header, not on a key. It carries no `X-Sahl-Delivery` header, waits 10 seconds for your answer, and counts any status below 400 as a success (real deliveries need a 2xx and wait 30 seconds).

## Request headers

| Header | Value |
| - | - |
| `Content-Type` | `application/json` |
| `X-Sahl-Event` | The event name. |
| `X-Sahl-Delivery` | A unique id per delivery. Stays the same across retries of one delivery. |
| `X-Sahl-Timestamp` | Unix seconds when this attempt was signed. |
| `X-Sahl-Signature-V2` | `sha256=` plus the hex HMAC-SHA256 of `"<timestamp>.<raw body>"`. |
| `X-Sahl-Signature` | `sha256=` plus the hex HMAC-SHA256 of the raw body alone. Kept for old receivers. It cannot stop a replay. |

## Verify the signature

1. Read the raw body bytes before you parse the JSON. Re-serialised JSON does not match.
2. Compute `HMAC-SHA256(secret, timestamp + "." + body)` and compare with `X-Sahl-Signature-V2` in constant time.
3. Reject a timestamp more than a few minutes from your clock (the code below uses 5 minutes, which is your choice, not a Sahl rule). Each retry is signed again, so its timestamp is fresh.

<CodeGroup>
  ```javascript JavaScript theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";

  const TOLERANCE_SECONDS = 300;

  // rawBody: the request body exactly as received (Buffer or string).
  // headers: lowercase header names, as Node and Express give them.
  export function verifySahlWebhook(rawBody, headers, secret, nowSeconds = Math.floor(Date.now() / 1000)) {
    const timestamp = headers["x-sahl-timestamp"];
    const received = headers["x-sahl-signature-v2"];
    if (!/^\d+$/.test(timestamp ?? "") || !received) return false;
    if (Math.abs(nowSeconds - Number(timestamp)) > TOLERANCE_SECONDS) return false;
    const expected =
      "sha256=" + createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
    const a = Buffer.from(received);
    const b = Buffer.from(expected);
    return a.length === b.length && timingSafeEqual(a, b);
  }
  ```

  ```python Python theme={null}
  import hashlib, hmac, time

  TOLERANCE_SECONDS = 300


  def verify_sahl_webhook(raw_body: bytes, headers: dict, secret: str, now: float | None = None) -> bool:
      """raw_body is the request body exactly as received. headers keys are lowercase."""
      timestamp = headers.get("x-sahl-timestamp", "")
      received = headers.get("x-sahl-signature-v2", "")
      if not timestamp.isdigit() or not received:
          return False
      if abs((now or time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
          return False
      signed = timestamp.encode() + b"." + raw_body
      expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
      return hmac.compare_digest(received, expected)
  ```
</CodeGroup>

A receiver in Express. `express.raw` keeps the bytes.

```javascript theme={null}
import express from "express";
import { verifySahlWebhook } from "./verify.mjs";

const app = express();
const seen = new Set(); // use a database table in production

app.post("/sahl/webhook", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifySahlWebhook(req.body, req.headers, process.env.SAHL_WEBHOOK_SECRET)) {
    return res.status(401).end();
  }
  const event = JSON.parse(req.body.toString("utf8"));
  const id = req.headers["x-sahl-delivery"];
  if (!seen.has(id)) {
    seen.add(id);
    // handle event.event, event.reference, event.case_id ...
  }
  res.status(204).end(); // any 2xx counts as delivered
});

app.listen(3000);
```

Both functions above were run against the code that signs real deliveries: a valid delivery verifies, a changed body fails, and a stale timestamp fails.

## Delivery and retries

| Item | Value |
| - | - |
| Success | Any HTTP status from 200 to 299. |
| Timeout | 30 seconds by default. |
| Redirects | Not followed. A 3xx is a failure. |
| Attempts | 6 in total, the first included. |
| Waits after a failed attempt | 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours. |
| After the 6th failure | The delivery stays `failed` for good. |
| Guarantee | At least once. The same event can arrive more than once. |
| Order | Not guaranteed. |

The first attempt is made right after the call. Later attempts are made by a retry job that runs every few minutes, so a retry can come slightly after its scheduled time. An endpoint that you switch off keeps its due deliveries and resumes them when you switch it back on.

Make your handler idempotent. De-duplicate on `X-Sahl-Delivery` (one id per delivery) or on `event_id` (one per event). Answer fast with a 2xx and do the work after.

The console shows every delivery of an endpoint with its HTTP status and attempt count, and keeps up to 2,000 characters of your response body.

## Troubleshooting

| Symptom | Cause |
| - | - |
| Signature never matches | You verified parsed JSON, not the raw bytes. Or the secret is not the endpoint's secret. |
| Signature matches only sometimes | Your clock is off by more than your tolerance. |
| No event arrives | The call had no `reference`; the endpoint is not subscribed to that event; the endpoint is switched off. |
| Console says "Refused, not sent" | Your URL resolves to a private or metadata address, or is not https. |
| An event arrived twice | Normal. De-duplicate. |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.