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

> Quatre événements KYC, signés avec HMAC-SHA256, livrés au moins une fois, jusqu'à 6 tentatives.

Au lieu d'interroger l'API en boucle, vous pouvez demander à Sahl d'envoyer un événement à votre serveur quand un appel se termine. Les webhooks sont facultatifs. Chaque événement KYC est envoyé après la validation en base de données de l'appel, en arrière-plan, donc la réponse de votre API n'attend jamais votre endpoint et n'échoue jamais à cause de lui.

## Enregistrer un endpoint

Dans la console, ouvrez **Settings**, puis **Webhooks**, puis **Add Webhook**.

1. Saisissez l'**Endpoint URL**. Elle doit être en `https` et se résoudre vers une adresse publique. Les adresses privées, de bouclage et de métadonnées cloud sont refusées, à l'enregistrement et de nouveau avant chaque envoi. Les redirections ne sont pas suivies.
2. Choisissez les événements (ci-dessous).
3. Enregistrez. Si vous n'avez pas fourni votre propre secret (16 caractères ou plus), Sahl en génère un, affiché une seule fois, sous la forme `whsec_` suivi de 48 caractères. Copiez-le.
4. Cliquez sur **Test** sur l'endpoint. Sahl envoie un événement `test.ping` à votre URL, signé comme une vraie livraison, et affiche le statut avec lequel votre serveur a répondu. La charge utile de test n'a pas la forme d'un événement KYC (voir [Ping de test](#ping-de-test)).

La gestion des endpoints exige le rôle d'administrateur du tenant ou de gestionnaire d'API. Les endpoints de webhook se gèrent dans la console, pas par l'API Partenaire.

## Événements

Seuls les appels qui portent une `reference` émettent des événements, car un événement nomme un dossier. Un appel sans référence ne stocke rien et n'émet rien.

| Événement | Envoyé après | Notes |
| - | - | - |
| `kyc.documents_read` | `POST /v1/kyc/extract` | |
| `kyc.case_verified` | `POST /v1/kyc/verify` | |
| `kyc.case_assessed` | `POST /v1/kyc/assess` | À la place de `case_verified`. `/assess` envoie un seul événement. |
| `kyc.eid_completed` | Le premier `GET /v1/kyc/eid/{key}` qui voit une vérification terminée | Les interrogations suivantes n'envoient rien. Deux interrogations en concurrence sur la toute première observation peuvent chacune en envoyer un. |

Les événements sont envoyés pour les deux environnements. La charge utile indique lequel.

## Charges utiles

Chaque charge utile a ces quatre clés.

| Clé | Type | Signification |
| - | - | - |
| `event` | string | Le nom de l'événement. |
| `event_id` | uuid | Un nouvel identifiant pour chaque événement. Un même événement livré de nouveau garde son `event_id`. |
| `occurred_at` | string | Heure ISO 8601 avec décalage, au moment où Sahl a construit l'événement. |
| `tenant_id` | uuid | L'identifiant de votre espace de travail. |

Les charges utiles contiennent des identifiants, votre `reference`, l'environnement et un résumé du verdict. Elles ne contiennent jamais de valeur de champ, de nom, de date de naissance ni de contenu de document : les livraisons sont stockées chez Sahl et envoyées à une URL que vous avez saisie.

### `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` contient les ids des vérifications échouées de sévérité `critical` ou `warning`, sans doublons.

### `kyc.case_verified` et `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"
  }
}
```

| Clé de `verdict` | Signification |
| - | - |
| `status` | `passed` ou `failed`, le `passed` du verdict. |
| `failed_checks` | Ids des vérifications critiques et d'avertissement échouées. Un id de filtrage est réduit à son type (`screening:sanctions`, `screening:pep`) pour qu'aucun nom de personne ne quitte Sahl. |
| `risk_level` | `kyc.case_assessed` seulement. Le niveau de risque de conformité : `Low`, `Medium` ou `High`. |
| `suitability` | `kyc.case_assessed` seulement. La phrase d'adéquation. |

### `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` vaut false quand la demande s'est terminée archivée sans que le client ait fini. `failed_checks` contient les ids des vérifications eID `critical` échouées.

### Ping de test

Le bouton **Test** envoie une autre forme, signée de la même façon :

```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": "..." }
}
```

Son en-tête `X-Sahl-Event` vaut `test.ping`. Il n'a ni clé `event` ni clé `event_id`, donc aiguillez sur l'en-tête, pas sur une clé.

## En-têtes de requête

| En-tête | Valeur |
| - | - |
| `Content-Type` | `application/json` |
| `X-Sahl-Event` | Le nom de l'événement. |
| `X-Sahl-Delivery` | Un identifiant unique par livraison. Reste identique entre les nouvelles tentatives d'une même livraison. |
| `X-Sahl-Timestamp` | Secondes Unix au moment où cette tentative a été signée. |
| `X-Sahl-Signature-V2` | `sha256=` suivi du HMAC-SHA256 hexadécimal de `"<timestamp>.<raw body>"`. |
| `X-Sahl-Signature` | `sha256=` suivi du HMAC-SHA256 hexadécimal du corps brut seul. Conservé pour les anciens récepteurs. Il ne peut pas empêcher un rejeu. |

## Vérifier la signature

1. Lisez les octets bruts du corps avant d'analyser le JSON. Un JSON resérialisé ne correspond pas.
2. Calculez `HMAC-SHA256(secret, timestamp + "." + body)` et comparez avec `X-Sahl-Signature-V2` en temps constant.
3. Rejetez un horodatage éloigné de plus de quelques minutes de votre horloge (le code ci-dessous utilise 5 minutes, ce qui est votre choix et non une règle Sahl). Chaque nouvelle tentative est signée à nouveau, donc son horodatage est récent.

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

Un récepteur avec Express. `express.raw` conserve les octets.

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

Les deux fonctions ci-dessus ont été exécutées contre le code qui signe les vraies livraisons : une livraison valide est vérifiée, un corps modifié échoue, et un horodatage périmé échoue.

## Livraison et nouvelles tentatives

| Élément | Valeur |
| - | - |
| Succès | Tout statut HTTP de 200 à 299. |
| Délai d'attente | 30 secondes par défaut. |
| Redirections | Non suivies. Une 3xx est un échec. |
| Tentatives | 6 au total, la première comprise. |
| Attentes après une tentative échouée | 1 minute, 5 minutes, 30 minutes, 2 heures, 6 heures. |
| Après le 6e échec | La livraison reste `failed` définitivement. |
| Garantie | Au moins une fois. Le même événement peut arriver plus d'une fois. |
| Ordre | Non garanti. |

La première tentative est faite juste après l'appel. Les tentatives suivantes sont faites par une tâche de reprise qui s'exécute toutes les quelques minutes, donc une nouvelle tentative peut arriver légèrement après l'heure prévue. Un endpoint que vous désactivez conserve ses livraisons dues et les reprend quand vous le réactivez.

Rendez votre gestionnaire idempotent. Dédupliquez sur `X-Sahl-Delivery` (un id par livraison) ou sur `event_id` (un par événement). Répondez vite avec une 2xx et faites le travail ensuite.

La console affiche chaque livraison d'un endpoint avec son statut HTTP et son nombre de tentatives, et conserve jusqu'à 2 000 caractères du corps de votre réponse.

## Dépannage

| Symptôme | Cause |
| - | - |
| La signature ne correspond jamais | Vous avez vérifié le JSON analysé, pas les octets bruts. Ou le secret n'est pas celui de l'endpoint. |
| La signature ne correspond que parfois | Votre horloge est décalée de plus que votre tolérance. |
| Aucun événement n'arrive | L'appel n'avait pas de `reference` ; l'endpoint n'est pas abonné à cet événement ; l'endpoint est désactivé. |
| La console affiche « Refused, not sent » | Votre URL se résout vers une adresse privée ou de métadonnées, ou n'est pas en https. |
| Un événement est arrivé deux fois | Normal. Dédupliquez. |


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