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

# Webhooks KYC et KYB en temps réel : signature HMAC

> Recevez les événements de vérification, d'entité et de transaction de Didit en temps réel. Signature HMAC-SHA256 et nouvelles tentatives bornées.

> [!NOTE]
>
> L'activité d'authentification et la livraison des webhooks associée, qui dépend de la version, sont documentées dans [Événements d'authentification et webhooks](https://docs.didit.me/fr/authentication/events-and-webhooks.md). N'enregistrez pas de types d'événements d'authentification absents de cette liste auprès de l'API de destination actuelle.

Prompt d'intégration des webhooks
        Copier le prompt dans le presse-papiers



      
      

\<pre>

\`Add a Didit webhook endpoint to my application to receive real-time identity verification, entity, and transaction events.

\## What to Build
A POST endpoint (e.g., /api/webhooks/didit) that:
1\. Reads the raw request body (do NOT parse JSON before signature verification)
2\. Verifies the HMAC-SHA256 signature (X-Signature-V2 recommended)
3\. Validates the timestamp is fresh (within 5 minutes)
4\. Parses the JSON body and processes the event by webhook_type
5\. Returns 2xx ASAP and updates state idempotently

\## Signature Headers
\- X-Signature-V2: HMAC-SHA256 over the sorted, Unicode-preserved canonical JSON (recommended).
\- X-Signature: HMAC-SHA256 over the exact raw bytes (works only if your stack does not re-encode the body).
\- X-Signature-Simple: HMAC-SHA256 over "{timestamp}:{session_id}:{status}:{webhook_type}" (DEPRECATED last-resort fallback; authenticates the envelope only, NOT the decision body — if you verify with it, re-fetch the decision from GET /v3/session/{session_id}/decision/ instead of trusting the payload).
\- X-Timestamp: Unix epoch seconds when Didit dispatched the webhook.
\- X-Signature-V2-Previous, X-Signature-Previous, X-Signature-Simple-Previous: sent only for 24 hours after a destination's signing secret is rotated in the Business Console. Same signatures, made with the replaced secret. Never sent for the legacy application webhook_url.

Verification:
1\. abs(now - X-Timestamp) <= 300 seconds
2\. Recompute the signature using your destination secret
3\. Compare with constant-time comparison against the header and, when present, its -Previous header (accept a match on either), so verification keeps working while you deploy a rotated secret

\## Webhook Event Types (use exact values in destination.subscribed_events)
\- "status.updated": session or business session status changed
\- "data.updated": session or business-session verification data was edited
\- "user.status.updated": User entity status changed ("ACTIVE" / "FLAGGED" / "BLOCKED")
\- "user.data.updated": User entity profile or aggregate data changed
\- "business.status.updated": Business entity status changed ("ACTIVE" / "FLAGGED" / "BLOCKED")
\- "business.data.updated": Business entity profile or aggregate data changed
\- "activity.created": reserved for the activity timeline (currently only sent by Try Webhook tests)
\- "transaction.created": transaction created and initial verdict ready
\- "transaction.status.updated": transaction status changed
\- "travel_rule.status.updated": a Travel Rule exchange's status changed (see /transaction-monitoring/travel-rule)
\- "workflow.compliance_changed": the compliance status of a published workflow changed after a re-evaluation
\- "transaction_rule_suggestions.created": a transaction rule suggestion run proposed new rule changes
\- "investigator.report.ready": the Investigator wrote a report on an item
\- "investigator.decision.automated": the Investigator decided an item on its own
\- "investigator.decision.overridden": a person took another action than the Investigator's report, or overrode a decision it took on its own
\- "investigator.decision.contested": the end user contested a decision and asked for a human review
\- "investigator.question.raised": the Investigator asked the team a question about its policy
\- "investigator.automation.paused": automation stopped deciding, paused by a person or at the monthly spending limit
\- "investigator.rfi.answered": the end user answered an information request
\- "investigator.rfi.overdue": an information request passed its deadline unanswered
\- "investigator.drift.detected": a queue drifted and automation stepped down a tier
\- "investigator.sla.breached": a report waited past its queue's SLA without a decision
\- "investigator.qa.failed": a decision a person took failed the Investigator's quality review
\- "investigator.external.report.ready": the report on an item you sent from outside Didit is ready

There is no wildcard; include every event family you want to receive. You can create multiple destinations to fan out to different URLs.

\## Webhook Envelope (session events)
\{
"event_id": "uuid",
"session_id": "uuid",
"status": "Approved" | "Declined" | "In Review" | "In Progress" | "Not Started" | "Abandoned" | "Expired" | "Kyc Expired" | "Resubmitted" | "Awaiting User",
"webhook_type": "status.updated",
"created_at": 1627680000,
"timestamp": 1627680000,
"application_id": "uuid",
"environment": "live" | "sandbox",
"sandbox_scenario": "decline_aml_hit" | null,  // armed scenario slug on sandbox sessions; null on live sessions or an unarmed sandbox session
"workflow_id": "uuid",
"workflow_version": 1,
"vendor_data": "your-internal-user-id",
"metadata": { "user_type": "premium" },
"decision": { ... }  // only when Approved / Declined / In Review / Abandoned
}

Status values are exact, case-sensitive strings — note "Kyc Expired" uses a single capital K.

KYB / business-session payloads additionally include "business_session_id" and "session_kind": "business".

\## Decision Object (V3)
The decision mirrors the GET /v3/session/decision/ response. Per-feature data is in plural arrays so a workflow can include multiple instances of the same feature:
\- id_verifications[], nfc_verifications[], liveness_checks[], face_matches[]
\- aml_screenings[], poa_verifications[], phone_verifications[], email_verifications[]
\- ip_analyses[], database_validations[], questionnaire_responses
\- bank_verifications[]: one item per Bank Verification step. Its method is "open_banking" (bank sign-in), "account_lookup" (account check), "statement" or "microdeposits", or "none" when the person continued without a bank before any attempt was evaluated. continued_without_bank: true says the person continued without a bank, and continuation.reason says why.
\- reviews[]
\- signatures[]: only when the workflow has an E-signature step. One item per signer; status "signed" means the documents are signed. Download signed_file and evidence_file with your API key.

Each item carries a node_id so you can correlate it with the workflow graph.
User Verification decisions also carry one biometric_consent object (not an array): required, trigger, status (not_required | pending | given | declined), reason. status "pending" with reason "BIOMETRIC_CONSENT_UNSUPPORTED_SDK" means a native SDK below 4.10.0 captured a face without the consent step, so consent was not collected.

\## Database Update Logic
switch (status):
"Approved"     -> user.verified = true; store decision data
"Declined"     -> user.verification_status = "declined"; log decision.*.warnings
"In Review"    -> user.verification_status = "pending_review"
"In Progress"  -> user.verification_status = "in_progress"
"Resubmitted"  -> reopen the listed feature nodes per resubmit_info
"Abandoned"    -> optionally trigger reminder
"Awaiting User" -> KYB parent session waiting on party verifications; no action needed
"Expired" / "Kyc Expired" -> mark session expired; optionally create a new one

\## Retry Policy
On 5xx, 404, timeout, or connection failure Didit retries up to 2 times:
\- 1st retry: ~1 minute after the initial failure
\- 2nd retry: ~4 minutes after the 1st retry
After that the delivery is dropped. Each delivery attempt is logged separately in the console's Deliveries tab.
Each attempt in a destination's delivery log has an attempt number: 1 for the first delivery of an event, then one more than the attempt it repeats for each retry or resend.
Automatic retries go to the URL the first attempt used; a manual resend goes to the destination's current URL.
Retries and resends are signed again with the destination's current secret and a fresh timestamp.

\## Error Handling
\- Return 2xx as fast as possible; do heavy work asynchronously.
\- Log the raw body for debugging signature failures.
\- Treat duplicate webhooks idempotently (key on event_id, or session_id + status + webhook_type).
\- Delivery order is not guaranteed: never let an older event overwrite newer state. Compare created_at, or re-fetch the decision from GET /v3/session/{session_id}/decision/.
\- HTTPS endpoints only. Webhook receivers behind Cloudflare must whitelist 18.203.201.92.

\## Environment Variable
DIDIT_WEBHOOK_SECRET -- from the Business Console (per-destination secret_shared_key).

\## Testing
Send test webhooks from the Business Console (API & Webhooks > Try Webhook) before going live. The console covers approved, declined, in-review, KYB, entity, activity, and transaction scenarios.

\## Docs
\- Webhooks guide: https://docs.didit.me/integration/webhooks
\- Webhook destinations API: https://docs.didit.me/management-api/webhook/list-destinations
\- Verification statuses: https://docs.didit.me/integration/verification-statuses
\- Decision schema: https://docs.didit.me/sessions-api/retrieve-session

\## Important
\- ALWAYS verify the signature before processing any webhook
\- Use constant-time comparison to prevent timing attacks
\- Store and HMAC the raw JSON string -- never re-stringify after parsing
\- Return 2xx quickly; do heavy processing asynchronously if needed`

\</pre>



      
      

Coller dans

## Vue d'ensembleLes webhooks permettent à Didit de vous transmettre des **mises à jour en temps réel** dès qu'une session de vérification, une session d'entreprise, un utilisateur fournisseur, une entreprise fournisseur ou une transaction change d'état. En abonnant une ou plusieurs destinations aux événements qui vous intéressent, vous pouvez réagir dès qu'un résultat est prêt, généralement quelques secondes après que l'utilisateur a terminé son parcours.Les webhooks constituent le mode d'intégration **recommandé**. L'interrogation de [`GET /v3/session/{id}/decision/`](https://docs.didit.me/fr/sessions-api/retrieve-session) reste prise en charge comme solution de secours, mais elle est plus lente, génère davantage de requêtes et ne remonte pas les événements tels que `data.updated`, `transaction.status.updated` ni les changements au niveau des entités, qui ne sont émis que par webhooks. Considérez la livraison des webhooks comme la source de vérité et n'interrogez l'API qu'au démarrage à froid ou pour une réconciliation.Ce chapitre de la leçon d'intégration de Didit Academy reprend à la présentation des webhooks.La leçon Academy consacrée à l'intégration aborde les webhooks à partir de 3:12 : l'ajout d'une destination, le choix des événements et le secret de signature que vous utiliserez pour effectuer la vérification plus bas.[Vue d'ensemble des webhooks - Didit Academy](https://www.youtube.com/embed/fX03-WEu_EI?start=192\&rel=0\&playsinline=1)> [!NOTE]
>
> **Utilisateurs de Cloudflare / WAF.** Didit envoie les webhooks depuis l'adresse IP statique `18.203.201.92` avec `User-Agent: DiditWebhook/2.0 +https://didit.me`. Si votre edge bloque les clients inconnus, autorisez cette IP dans **Security > WAF > Tools > IP Access Rules** et choisissez **Autoriser** pour le nom d'hôte de réception.## Types d'événementsUtilisez la valeur exacte dans le tableau `subscribed_events` d'une destination. Il n'existe pas de caractère générique : listez chaque famille que vous souhaitez recevoir. Vous pouvez répartir les événements sur plusieurs destinations.| Événement | Ressource | Quand il se déclenche |
| - | - | - |
| `status.updated` | Session KYC ou session d'entreprise KYB | Le statut d'une session change (Non démarré → En cours → Approuvé / Refusé / En cours d'examen / Abandonné / Soumis à nouveau). Les payloads KYB incluent `session_kind: "business"` et `business_session_id`. |
| `data.updated` | Session KYC ou session d'entreprise KYB | Les données de vérification sont modifiées après la création (corrections manuelles des réviseurs sur KYC, POA, NFC, registre KYB, documents KYB, etc.). |
| `user.status.updated` | Entité utilisateur | Le statut d'un utilisateur consolidé varie entre `ACTIVE`, `FLAGGED` et `BLOCKED` (action dans la console, action d'une règle, liste de blocage). Inclut `previous_status`. |
| `user.data.updated` | Entité utilisateur | Le profil, les compteurs, les métadonnées ou les identifiants approuvés d'un utilisateur consolidé changent. Inclut `changed_fields` et une mappe `changes` contenant les valeurs `previous` et `current`. |
| `business.status.updated` | Entité entreprise | Le statut d'une entreprise consolidée varie entre `ACTIVE`, `FLAGGED` et `BLOCKED` (action dans la console, action d'une règle, liste de blocage). Inclut `previous_status`. |
| `business.data.updated` | Entité entreprise | Le profil, les données d'immatriculation, les compteurs, les métadonnées ou les champs de vérification agrégés d'une entreprise consolidée changent. Inclut `changed_fields` et `changes`. |
| `activity.created` | Chronologie d'activité | Réservé au flux de la chronologie d'activité. La valeur est acceptée dans `subscribed_events` et prise en charge par le testeur **Try Webhook**, mais Didit n'envoie pas actuellement `activity.created` pour le trafic en production. |
| `transaction.created` | Surveillance des transactions | Une transaction est créée et son premier verdict des règles est prêt. Inclut `transaction_id`, `txn_id`, `status`, `score`, `severity`, `amount`, `currency`, `direction` et `action_required`. |
| `transaction.status.updated` | Surveillance des transactions | Le statut d'une transaction change après l'évaluation des règles, une action d'un analyste, une remédiation, des correspondances avec des listes de blocage, des mises à jour du fournisseur, ou une action via l'API ou la console. Un événement est aussi envoyé une seule fois, que le statut ait changé ou non, lorsqu'un filtrage crypto relancé en arrière-plan se termine ([hachages de transaction non encore indexés](https://docs.didit.me/fr/transaction-monitoring/aml-screening.md#hachages-de-transaction-non-encore-indexés)). |
| `travel_rule.status.updated` | Travel Rule | Le statut d'un échange [Travel Rule](https://docs.didit.me/fr/transaction-monitoring/travel-rule.md) change : à la création (y compris la transition `NOT_APPLICABLE` sous le seuil), lors de la confirmation de propriété, à la réponse de récupération par e-mail, à la fin ou à l'annulation, ou à l'expiration du délai. Il se déclenche pour les virements sortants et pour les virements entrants : la contrepartie entrante sur le rail `INTERNAL`, les enregistrements sunrise et les demandes entrantes `TRP`. Pour une transaction créée par Didit, ces événements sont envoyés après son `transaction.created`. Inclut `transaction_id`, `txn_id`, `travel_rule_status`, `rail` et `direction`. |
| `workflow.compliance_changed` | Conformité du workflow | Le statut de conformité d'un workflow publié change après une réévaluation. Un événement par workflow. Contient `workflow_id`, `workflow_version`, `workflow_label`, `transition` (`from` et `to`), `severity` et `kb_version`, et ne contient pas de `status`. |
| `transaction_rule_suggestions.created` | Surveillance des transactions | Une exécution de suggestions de règles a proposé de nouvelles modifications de règles, et les paramètres de suggestions de règles ont activé les notifications webhook. Contient `run_id`, `open_count` et `suggestions[]`, et ne contient pas de `status`. |
| `investigator.report.ready` | Investigator | Investigator a rédigé un rapport sur un élément. |
| `investigator.decision.automated` | Investigator | Investigator a statué seul sur un élément. |
| `investigator.decision.overridden` | Investigator | Une personne a pris une autre mesure que le rapport d'Investigator, ou a annulé une décision qu'il avait prise seul. |
| `investigator.decision.contested` | Investigator | L'utilisateur final a contesté une décision et demandé un examen humain. |
| `investigator.question.raised` | Investigator | Investigator a posé à l'équipe une question sur sa politique. |
| `investigator.automation.paused` | Investigator | L'automatisation a cessé de décider, suspendue par une personne ou en raison du plafond de dépenses mensuel. |
| `investigator.rfi.answered` | Investigator | L'utilisateur final a répondu à une demande d'informations. |
| `investigator.rfi.overdue` | Investigator | Une demande d'informations a dépassé sa date limite sans réponse. |
| `investigator.drift.detected` | Investigator | Une file d'attente a dérivé, et l'automatisation est passée à un niveau inférieur. |
| `investigator.sla.breached` | Investigator | Un rapport a attendu au-delà du SLA de sa file d'attente sans décision. |
| `investigator.qa.failed` | Investigator | Une décision prise par une personne n'a pas passé le contrôle qualité d'Investigator. |
| `investigator.external.report.ready` | Investigator | Le rapport sur un élément que vous avez envoyé depuis l'extérieur de Didit est prêt. |
| `auth.signup` | Authentification | Une personne s'inscrit via [Authentification](https://docs.didit.me/fr/authentication/events-and-webhooks.md). Inclut `auth_event` et le résumé `authentication` de la personne. |
| `auth.login` | Authentification | Une personne se connecte. Inclut `auth_event` et le résumé `authentication` de la personne. |
| `auth.login_failed` | Authentification | Une tentative de connexion échoue. `vendor_data` vaut `null` lorsque la tentative ne correspond à aucune personne connue. Inclut `auth_event` et le résumé `authentication` de la personne. |
| `auth.password_reset_requested` | Authentification | Une personne demande une réinitialisation de mot de passe. Inclut `auth_event` et le résumé `authentication` de la personne. |
| `auth.password_reset_completed` | Authentification | Une personne termine une réinitialisation de mot de passe. Inclut `auth_event` et le résumé `authentication` de la personne. |
| `auth.mfa_changed` | Authentification | Une personne ajoute ou modifie un authentificateur. Inclut `auth_event` et le résumé `authentication` de la personne. |
| `auth.new_device` | Authentification | Une personne se connecte depuis un appareil qu'elle n'a jamais utilisé. Inclut `auth_event` et le résumé `authentication` de la personne. |
| `auth.account_locked` | Authentification | Une politique de sécurité verrouille le compte, ou votre équipe bloque la personne. Inclut `auth_event` et le résumé `authentication` de la personne. |Les valeurs de ce tableau constituent l'ensemble complet des événements auxquels vous pouvez vous abonner : il n'existe aucun `session.status.updated`, `kyc.completed` ni événement similaire. La seule autre livraison effectuée par Didit est le [rappel `kyb.registry_search.resolved` non signé](#callback-de-recherche-dans-le-registre-kyb), qui est envoyé à l'URL `webhook_url` que vous transmettez à l'endpoint [`POST /v3/kyb/search/`](https://docs.didit.me/fr/standalone-apis/kyb-registry) pour chaque requête, et jamais à une destination de webhook.> [!TIP]
>
> Le menu Business Console > API & Webhooks > **Try Webhook** envoie des payloads complets pour chacun de ces événements (approuvé, refusé, en cours d'examen, noms Unicode, KYC/KYB, entité, activité et transaction). Utilisez-le pour valider votre endpoint avant la mise en production.## Structure du payload### EnvelopeTous les webhooks partagent la même enveloppe. Les champs sont toujours sérialisés avec `sort_keys=True` et des séparateurs compacts (`,` / `:`) afin que le même payload produise la même signature des deux côtés.| Champ | Type | Notes |
| - | - | - |
| `event_id` | `uuid` | Identifiant stable de l'événement. Le **même** `event_id` est envoyé à chaque destination vers laquelle l'événement est diffusé et il est réutilisé lors des nouvelles tentatives. Utilisez-le comme clé d'idempotence. |
| `webhook_type` | string | L'un des [types d'événements](#types-d’événements) ci-dessus. |
| `timestamp` | int (secondes Unix) | Moment où Didit a envoyé le webhook. Utilisé par la vérification de signature. Actualisé à chaque nouvelle tentative. |
| `created_at` | int (secondes Unix) | Moment de la dernière mise à jour de l'enregistrement sous-jacent. |
| `application_id` | `uuid` | L'application propriétaire de la destination. |
| `environment` | `"live"` \| `"sandbox"` | Indique si l'événement provient d'une session ou d'une application de production ou de sandbox. |
| `status` | string | Présent sur chaque événement de session, d'entité et de transaction (y compris `*.data.updated`) ; les événements `investigator.*` ne contiennent aucun `status` (voir le payload de l'Investigator ci-dessous). Les événements de session utilisent les [libellés de statut de vérification](https://docs.didit.me/fr/integration/verification-statuses.md) exacts (par exemple `"Approved"`, `"Kyc Expired"`) ; les événements d'entité utilisent `ACTIVE` / `FLAGGED` / `BLOCKED` ; les événements de transaction utilisent `APPROVED` / `IN_REVIEW` / `DECLINED` / `AWAITING_USER`. |Pour les événements de **session** (`status.updated`, `data.updated` sur les sessions classiques et les sessions d'entreprise), l'enveloppe contient en plus :| Champ | Type | Notes |
| - | - | - |
| `session_id` | `uuid` | L'UUID de la session de vérification de l'utilisateur ou de la session d'entreprise. |
| `business_session_id` | `uuid` | Uniquement pour le KYB. Égal à `session_id`. |
| `session_kind` | `"business"` | Uniquement pour les événements KYB ; permet à un gestionnaire générique de traiter ces cas à part. |
| `workflow_id` | `uuid` | Workflow utilisé pour créer la session, lorsqu'il est défini. |
| `workflow_version` | int | Version du workflow. |
| `vendor_data` | string | Votre identifiant interne, lorsqu'il est fourni à la création de la session. |
| `vendor_business_id` | string | Uniquement pour les sessions KYB, lorsqu'il est fourni. |
| `sandbox_scenario` | string \| null | Présent sur chaque événement de session (KYC et KYB). Slug du scénario [sandbox](https://docs.didit.me/fr/integration/sandbox-testing.md) armé, par exemple `"decline_aml_hit"`, sur une session sandbox dont un scénario est armé ; `null` sur une session de production, ou sur une session sandbox sans scénario armé. Absent des webhooks d'entité et de transaction. |
| `metadata` | object | Métadonnées que vous avez associées à la création de la session, sans les clés internes de déduplication. |
| `trigger` | string | Facultatif. Raison de l'envoi du webhook lorsque le flux de vérification lui-même ne l'a pas produit : `"manual_review"` (modifications d'un réviseur depuis la console), `"manual_step_update"` (un réviseur a modifié le statut d'une fonctionnalité), `"ongoing_monitoring"` (actualisation de la surveillance AML continue), `"webhook_reconciliation"` (un `status.updated` renvoyé parce que le premier webhook terminal n'a jamais été créé ; vous pouvez donc recevoir un second événement pour une décision déjà reçue : dédoublonnez sur `session_id` et `status`), `"manual_webhook_recovery"` (webhook récupéré manuellement), `"kyb_registry_change"` (le registre des sociétés a signalé une modification), `"console_registry_edit"`, `"console_key_people_skip"` et `"console_key_people_regenerate"` (modifications effectuées dans la Console sur une session d'entreprise), et `"biometric_retention"` (un `data.updated` envoyé lorsque les données biométriques recueillies en direct de la session sont supprimées selon le calendrier). Cette liste peut s'enrichir : traitez toute valeur inconnue comme informative. |
| `decision` | object | Présent lorsque `status` vaut `Approved`, `Declined`, `In Review` ou `Abandoned`. Reprend la [décision de session V3](https://docs.didit.me/fr/sessions-api/retrieve-session.md). |
| `resubmit_info` | `{ nodes_to_resubmit, reasons }` | Présent lorsque `status` vaut `Resubmitted` (sans `decision`). |
| `decided_by` | string | Uniquement sur `status.updated` lorsque le statut est `Approved`, `Declined` ou `Resubmitted` et que l'application utilise l'Investigator. `"investigator"` lorsque l'Investigator a pris la décision seul, `"human"` lorsqu'un utilisateur de la console l'a prise (y compris en acceptant le rapport de l'Investigator). Omis lorsqu'aucun réviseur n'a enregistré la décision, lorsqu'une clé API l'a prise, ou lorsque l'Investigator n'est pas utilisé. |### Exemple d'enveloppe de session```json theme={null}
{
  "event_id": "9c0c8b8a-1111-4222-9333-444444444444",
  "webhook_type": "status.updated",
  "timestamp": 1774970000,
  "created_at": 1774969994,
  "application_id": "11111111-2222-3333-4444-555555555555",
  "environment": "live",
  "sandbox_scenario": null,
  "session_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
  "status": "Approved",
  "workflow_id": "66666666-7777-8888-9999-000000000000",
  "workflow_version": 4,
  "vendor_data": "user_42",
  "metadata": { "tier": "premium" },
  "decision": { "...": "see /sessions-api/retrieve-session" }
}
```> [!TIP]
>
> L'objet `decision` correspond au même schéma que celui renvoyé par [`GET /v3/session/{id}/decision/`](https://docs.didit.me/fr/sessions-api/retrieve-session). Pour la référence canonique des champs par fonctionnalité (chaque propriété de `id_verifications[]`, `aml_screenings[]`, etc.), consultez [Modèles de données](https://docs.didit.me/fr/reference/data-models.md). Nous ne dupliquons pas ici les tableaux de champs par fonctionnalité, afin que le schéma ne soit défini qu'à un seul endroit.### Tableaux pluriels V3 (important)Dans `decision`, chaque résultat par fonctionnalité se présente sous la forme d’un tableau afin qu'un même workflow puisse inclure plusieurs instances de la même fonctionnalité (par exemple, plusieurs contrôles d'identité sur différents documents) :```text theme={null}
id_verifications[]        nfc_verifications[]
liveness_checks[]         face_matches[]
phone_verifications[]     email_verifications[]
poa_verifications[]       aml_screenings[]
ip_analyses[]             database_validations[]
questionnaire_responses   reviews[]
bank_verifications[]
```Chaque élément contient un `node_id` correspondant au graphe du workflow, ainsi qu'un `status` propre à la fonctionnalité. **Ne lisez pas** les anciens champs singuliers du format V2 (`id_verification`, `nfc`, `liveness`, `face_match`, `phone`, `email`, `poa`, `aml`, `ip_analysis`, `database_validation`) : ils n'apparaissent que si une destination est explicitement configurée avec `webhook_version: "v2"`.Les résultats de **Signature électronique** se trouvent dans `signatures[]`, présents uniquement lorsque le workflow comporte une étape de signature électronique : un élément par signataire, avec le signataire, la confirmation par code, le certificat, l'horodatage, les empreintes et les chemins de téléchargement de chaque document, ainsi que le PDF de preuve. Il n'existe pas d'événement de signature distinct : `status.updated` vous indique quand les lire. Voir le [rapport de signature électronique](https://docs.didit.me/fr/core-technology/electronic-signature/report-electronic-signature.md).Les résultats de **Vérification bancaire** se trouvent dans `bank_verifications[]`, un élément par étape de vérification bancaire. La valeur `method` de chaque élément indique comment le compte a été vérifié : `open_banking` (connexion à la banque), `account_lookup` (contrôle du compte), ou un repli sur `statement` ou `microdeposits`, et `null` tant qu'aucun compte n'a été évalué. Lorsque la personne a choisi **Continuer sans votre banque**, `continued_without_bank` vaut `true` et `continuation` indique la raison. `method` vaut alors `none`, sauf si une tentative a été évaluée auparavant : voir [Lorsque la personne ne peut pas vérifier sa banque](https://docs.didit.me/fr/core-technology/bank-verification/fallbacks.md#le-lire-dans-le-résultat). Un compte vérifié par contrôle du compte ne donne accès à aucune donnée financière ; `financial_data.not_collected` liste donc les soldes, transactions ou revenus que l'étape demandait. Voir [Méthodes de vérification bancaire](https://docs.didit.me/fr/core-technology/bank-verification/verification-methods.md#lire-la-méthode-dans-le-résultat).### Bloc InvestigatorUne `decision` V3 pour une session utilisateur ou entreprise contient aussi `investigator` : le dernier examen [Investigator](https://docs.didit.me/fr/console/investigator.md) de la session, ou `null` si la session n'en a jamais eu.| Champ | Type | Signification |
| - | - | - |
| `run_id` | uuid | L'examen. Consultez le rapport complet à `GET /v3/investigator/runs/{run_id}/`. |
| `status` | string | `running`, `completed` ou `failed`. |
| `stage` | string | `shadow` ou `live` lorsque l'examen a été exécuté. |
| `queue` | string | `kyc`, `kyb`, `aml`, `transactions` ou `cases`. |
| `is_open` | boolean | L'examen attend encore une décision. |
| `recommendation` | string \| null | `approve`, `decline`, `escalate` ou `request`. |
| `recommendation_detail` | string \| null | Une ligne sur l'action recommandée. |
| `confidence` | string \| null | `high`, `moderate` ou `low`. |
| `summary` | string \| null | Le résumé du rapport. |
| `ready_to_accept` | boolean | Le rapport peut être accepté tel quel. |
| `prepared_at` | datetime \| null | Moment où le rapport était prêt. |
| `automated` | boolean | L'Investigator a décidé seul, même si quelqu'un a ensuite remplacé sa décision. |
| `decided_by` | string \| null | `investigator` tant que sa propre décision est maintenue, `member` sinon, `null` tant qu'aucune décision n'a été prise. |
| `decision` | object \| null | Comment l'élément a quitté la file d'attente : `outcome`, `action`, `final_status`, `agreed`, `decided_at`, `billed` et la personne qui a décidé. |Les fonctionnalités de collecte de documents, **Documents KYB** et **Document AI**, n'ont pas de tableau dédié contenant plusieurs éléments. Elles apparaissent dans la liste `features[]` de la décision sous la forme `{ "feature": "DOCUMENT_AI", "node_id": "…" }` (une entrée par nœud). Voir [Document AI](https://docs.didit.me/fr/core-technology/document-ai/overview.md).Les décisions de Vérification des utilisateurs contiennent aussi un objet unique `biometric_consent` : il indique si la session exigeait un consentement biométrique explicite, le `trigger`, le `status` de la réponse et ses preuves. `status: "pending"` avec `reason: "BIOMETRIC_CONSENT_UNSUPPORTED_SDK"` signifie qu'un SDK natif antérieur à la version 4.10.0 a capturé un visage sans l'étape de consentement. Voir [Consentement biométrique](https://docs.didit.me/fr/getting-started/biometric-consent.md).### Clés de méthode de vérification d'identitéChaque élément `id_verifications[]` indique la [méthode de vérification d'identité](https://docs.didit.me/fr/core-technology/id-verification/verification-methods.md) qui l'a produit. Un gestionnaire peut ainsi traiter différemment les preuves réellement reçues :| Clé | Valeur |
| - | - |
| `verification_method` | `document`, `id_lookup` ou `wallet` |
| `assurance` | `documentary`, `data_match` ou `cryptographic` |
| `wallet_provider` | Identifiant du portefeuille du catalogue lorsqu'un portefeuille a produit la vérification, sinon `null` |
| `id_lookup` | Preuves du registre lorsque la méthode était `id_lookup`, sinon `null` |
| `wallet_verification` | Preuves d'identifiants lorsque la méthode était `wallet`, sinon `null` |
| `fallback_from` | Raison pour laquelle une méthode autre que le document n'a pas clos l'étape d'identité : la méthode, la raison, et si l'utilisateur a basculé vers la capture de document ou a été refusé |Les gestionnaires existants continuent de fonctionner sans modification : une session avec document définit `verification_method` sur `document` et laisse les autres champs à `null`. Les types champ par champ figurent dans [Modèles de données](https://docs.didit.me/fr/reference/data-models.md#vérification-d’identité).#### Session de portefeuille approuvée (`status.updated`)```json theme={null}
{
  "event_id": "3a0c1b2c-4444-4555-8666-777777777777",
  "webhook_type": "status.updated",
  "timestamp": 1774970000,
  "created_at": 1774969994,
  "application_id": "11111111-2222-3333-4444-555555555555",
  "environment": "live",
  "session_id": "cccccccc-dddd-eeee-ffff-000000000000",
  "status": "Approved",
  "vendor_data": "user_77",
  "decision": {
    "session_id": "cccccccc-dddd-eeee-ffff-000000000000",
    "status": "Approved",
    "features": ["ID_VERIFICATION"],
    "id_verifications": [
      {
        "node_id": "id_verification_1",
        "status": "Approved",
        "issuing_state": "DNK",
        "first_name": "Freja",
        "last_name": "Nielsen",
        "full_name": "Freja Nielsen",
        "date_of_birth": "1988-03-02",
        "verification_method": "wallet",
        "assurance": "cryptographic",
        "wallet_provider": "mitid",
        "fallback_from": null,
        "id_lookup": null,
        "wallet_verification": {
          "provider": "mitid",
          "provider_name": "MitID",
          "issuing_authority": "Danish Agency for Digital Government",
          "issuing_country": "DNK",
          "credential_type": "person_identification",
          "level_of_assurance": "substantial",
          "verified_at": "2026-09-04T09:12:44Z",
          "signature_valid": true,
          "attributes": {
            "full_name": "Freja Nielsen",
            "date_of_birth": "1988-03-02",
            "cpr_alias": "b0f1c2d3-e4f5-4678-9abc-def012345678"
          },
          "portrait": null,
          "face_match_score": null
        },
        "warnings": []
      }
    ],
    "reviews": [],
    "created_at": "2026-09-04T09:12:20.443172Z"
  }
}
```### Payloads des événements d'entité```json theme={null}
{
  "event_id": "uuid",
  "webhook_type": "user.status.updated",
  "timestamp": 1774970000,
  "created_at": 1774969994,
  "application_id": "uuid",
  "environment": "live",
  "vendor_user_id": "uuid",
  "vendor_data": "user_42",
  "status": "BLOCKED",
  "previous_status": "ACTIVE"
}
```Les statuts d'entité sont `ACTIVE`, `FLAGGED` ou `BLOCKED`. `user.data.updated` et `business.data.updated` conservent le champ `status` et ajoutent un tableau `changed_fields` ainsi qu'une table `changes` au format `{ field: { previous, current } }`. Les événements `business.*` utilisent `vendor_business_id` à la place de `vendor_user_id`.### Payloads des événements de transactionLes webhooks de transaction empruntent la même infrastructure de diffusion et utilisent la même enveloppe, mais identifient la transaction au lieu de la session :```json theme={null}
{
  "event_id": "uuid",
  "webhook_type": "transaction.created",
  "timestamp": 1774970000,
  "created_at": 1774970000,
  "application_id": "uuid",
  "environment": "live",
  "transaction_id": "uuid",
  "txn_id": "finance0001",
  "status": "APPROVED",
  "score": 0,
  "severity": "UNKNOWN",
  "amount": "1200.00",
  "currency": "EUR",
  "direction": "OUTBOUND",
  "action_required": null
}
````action_required` vaut `null`, ou correspond à l'action en attente de l'utilisateur (`type` `wallet_ownership` ou `verification_session`, avec son `url`) décrite dans [Actions requises de l'utilisateur](https://docs.didit.me/fr/transaction-monitoring/sdk-transaction-submission.md#actions-requises-de-l’utilisateur).Les nouvelles transactions ont `APPROVED` par défaut. Si une règle, une liste de blocage ou un workflow modifie le statut pendant la création, le payload initial `transaction.created` reflète ce statut final. `transaction.status.updated` a la même structure, avec le `status` mis à jour. Les statuts de transaction sont `APPROVED`, `IN_REVIEW`, `DECLINED` ou `AWAITING_USER` ; `severity` vaut `UNKNOWN` / `LOW` / `MEDIUM` / `HIGH` / `CRITICAL` et `direction` vaut `INBOUND` / `OUTBOUND`. `transaction.created` et `transaction.status.updated` ne sont envoyés **que** pour les applications en production. Les échanges Travel Rule en sandbox émettent bien `travel_rule.status.updated` avec `environment: sandbox`.### Callback de recherche dans le registre KYBLe flux asynchrone [`POST /v3/kyb/search/`](https://docs.didit.me/fr/standalone-apis/kyb-registry) envoie un payload supplémentaire qui n'est **pas** un événement de destination : lorsque vous transmettez un `webhook_url` à la requête de recherche, Didit envoie un callback `kyb.registry_search.resolved` à cette URL dès que le registre a répondu. Il diffère des webhooks de destination sur trois points :- Il est **non signé** : aucun en-tête `X-Signature*`. Didit le signale à la place avec `X-Didit-Unsigned-Callback: true`. Pour le valider, comparez `request_id` à l'identifiant renvoyé par la requête de recherche.
- Le nom de l'événement figure dans `event_type` (et non dans `webhook_type`), et il n'y a ni `application_id` ni `environment`.
- `timestamp` et `created_at` sont des entiers correspondant à l'époque Unix, comme pour les autres webhooks, mais contrairement au `created_at` ISO de l'endpoint de recherche.```json theme={null}
{
  "event_id": "uuid",
  "event_type": "kyb.registry_search.resolved",
  "request_id": "uuid",
  "vendor_data": "business-123",
  "metadata": { "source": "onboarding" },
  "search_status": "resolved",
  "search_resolved": true,
  "kyb_registry": { "companies": ["..."], "pagination": { "...": "..." } },
  "timestamp": 1774970000,
  "created_at": 1774970000
}
```Le callback suit la même [politique de nouvelles tentatives](#politique-de-nouvelles-tentatives) que les webhooks de destination : chaque nouvelle tentative actualise `timestamp` et reste non signée. Consultez l'[API du registre KYB](https://docs.didit.me/fr/standalone-apis/kyb-registry.md) pour le schéma complet de la liste de candidats.## Vérification de la signatureChaque webhook est signé avec HMAC-SHA256 à l'aide du `secret_shared_key` de la destination. Trois en-têtes de signature sont envoyés afin que vous puissiez choisir celui qui fonctionne avec votre environnement technique :| En-tête | Ce qui est signé | Compatible avec les middlewares | Quand l'utiliser |
| - | - | - | - |
| `X-Signature-V2` | JSON compact trié, Unicode préservé (`ensure_ascii=False`) | Oui | **Recommandé** : fonctionne même si votre framework réencode le corps. |
| `X-Signature` | Octets bruts exacts de la requête telle que Didit l'a transmise (`sort_keys`, `ensure_ascii=True`) | Non | Uniquement lorsque vous pouvez lire le corps brut avant qu'un analyseur n'y touche. |
| `X-Signature-Simple` **(déprécié)** | `"{timestamp}:{session_id}:{status}:{webhook_type}"` | Oui | Solution de repli en dernier recours, qui authentifie **uniquement l'enveloppe** : elle n'authentifie **pas** `decision`. Lisez l'avis de dépréciation ci-dessous avant de l'utiliser. |En-têtes toujours envoyés par Didit :| En-tête | Exemple | Signification |
| - | - | - |
| `Content-Type` | `application/json` | Le corps est au format JSON. |
| `User-Agent` | `DiditWebhook/2.0 +https://didit.me` | Identifie le worker de webhooks de Didit. |
| `X-Timestamp` | `1774970000` | Nombre de secondes écoulées depuis l'époque Unix au moment de l'envoi du webhook. Rejetez la requête si `abs(now - X-Timestamp) > 300`. |
| `X-Signature` / `X-Signature-V2` / `X-Signature-Simple` | Empreintes hexadécimales | HMAC-SHA256 du corps ou de la chaîne canonique décrite ci-dessus. |Pendant 24 heures après la rotation du secret de signature d'une destination, les livraisons transportent aussi `X-Signature-Previous`, `X-Signature-V2-Previous` et `X-Signature-Simple-Previous` (voir [Rotation du secret de signature](#rotation-du-secret-de-signature)).### Rotation du secret de signatureVous effectuez la rotation du secret de signature d'une destination dans la Business Console.
Pendant 24 heures après une rotation, chaque livraison vers cette destination porte trois en-têtes supplémentaires, calculés avec le secret qui a été remplacé :| En-tête | Même signature que | Signé avec |
| - | - | - |
| `X-Signature-V2-Previous` | `X-Signature-V2` | Le secret remplacé |
| `X-Signature-Previous` | `X-Signature` | Le secret remplacé |
| `X-Signature-Simple-Previous` | `X-Signature-Simple` (déprécié) | Le secret remplacé |Un gestionnaire qui accepte une correspondance sur un en-tête de signature ou sur son en-tête `-Previous` continue de vérifier pendant le déploiement du nouveau secret : l'ancien secret correspond à l'en-tête `-Previous`, et le nouveau à l'en-tête habituel.
Les exemples de code ci-dessous procèdent ainsi.
Déployez le nouveau secret dans les 24 heures, car après ce délai, seul le nouveau secret permet la vérification.
`previous_secret_expires_at` sur [`GET /v3/webhook/destinations/{destination_uuid}/`](https://docs.didit.me/fr/management-api/webhook/get-destination) indique la fin des 24 heures.Les nouvelles tentatives et les renvois sont signés à nouveau avec le secret actuel de la destination au moment de leur envoi, et ne portent les en-têtes `-Previous` que pendant les 24 heures. Effectuer une nouvelle rotation dans les 24 heures retire immédiatement le plus ancien secret : seul le secret qui était en vigueur jusqu'à cette nouvelle rotation continue de permettre la vérification via les en-têtes `-Previous`, pendant 24 heures à compter de la nouvelle rotation.Les en-têtes `-Previous` n'existent que pour les destinations de webhook.
Le `secret_shared_key` au niveau de l'application, qui signe l'ancien `webhook_url`, ne prévoit aucune période de chevauchement : sa rotation avec `rotate_secret_key` sur [`PATCH /v3/webhook/`](https://docs.didit.me/fr/sessions-api/management-api#configuration-du-webhook) invalide immédiatement l'ancien secret.### `X-Signature-Simple` est déprécié> [!WARNING]
>
> **`X-Signature-Simple` authentifie l'enveloppe, et non le payload.** Sa chaîne canonique ne couvre que `timestamp`, `session_id`, `status` et `webhook_type`. Elle ne couvre **pas** `decision`, ni `resubmit_info`, `metadata`, `vendor_data` ou `event_id`. Une requête dont `decision` a été réécrit, alors que ces quatre autres champs sont restés intacts, passe encore la vérification de `X-Signature-Simple`.
>
> Elle est encore plus faible sur les événements qui ne contiennent aucun `session_id`. Les événements de transaction (`transaction_id`, `txn_id`, `amount`, `score`) et les événements d'entité (`vendor_user_id`, `vendor_business_id`) signent `"{timestamp}::{status}:{webhook_type}"`, qui n'identifie pas **quelle** transaction ou entité l'événement concerne : deux transactions différentes signalées avec le même statut dans la même seconde portent la même valeur de `X-Signature-Simple`.
> Les événements `travel_rule.status.updated`, `workflow.compliance_changed` et `transaction_rule_suggestions.created` ne contiennent pas non plus de `status`. Ils signent donc `"{timestamp}:::{webhook_type}"`, par exemple `"{timestamp}:::travel_rule.status.updated"`.
>
> Si `X-Signature-Simple` est le seul en-tête que vous vérifiez, considérez tout ce qui se trouve en dehors de ces quatre champs comme **non fiable**, et récupérez l'enregistrement depuis l'API : [`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/fr/sessions-api/retrieve-session) pour une session, [`GET /v3/transactions/{transaction_id}/`](https://docs.didit.me/fr/management-api/transactions/get) pour une transaction. L'API fait foi.Il s'agit d'une contrainte de compatibilité, et non d'un défaut que nous pouvons corriger sur place. La chaîne canonique à quatre champs est un contrat sur le format transmis : chaque récepteur qui vérifie `X-Signature-Simple` recalcule exactement ces quatre champs. Modifier ce que l'en-tête signe casserait donc silencieusement tous ces récepteurs sur le chemin de vérification. Couvrir le corps exige aussi une forme canonique spécifiée du JSON, que les deux parties reproduisent octet par octet, et Didit en fournit déjà une avec `X-Signature-V2`. Plutôt que d'ajouter une seconde variante couvrant le corps, avec ses propres règles de canonicalisation, l'en-tête reste inchangé et est déprécié au profit des variantes existantes qui couvrent le payload complet.Concrètement, cela signifie :- **Rien ne change au niveau du transport.** `X-Signature-Simple` continue d'être envoyé à chaque livraison, calculé de la même manière. Les vérificateurs existants continuent de fonctionner, et nous annoncerons toute suppression bien à l'avance.
- **Les nouvelles intégrations doivent utiliser `X-Signature-V2`.** Cette signature est compatible avec les middlewares *et* couvre le payload entier.
- **Si vous ne pouvez pas abandonner `X-Signature-Simple`**, vérifiez-le, puis récupérez l'enregistrement depuis l'API plutôt que de le lire dans le corps du webhook.### Pourquoi trois variantesCertains frameworks web (analyseurs de corps Express, middlewares Django, passerelles API) réencodent silencieusement le JSON avant que votre gestionnaire ne le lise. Si le corps d'origine contenait `"José"` et que votre middleware le réécrit en `"José"`, les octets changent alors que les données sont identiques, et `X-Signature` échoue. `X-Signature-V2` est calculé à partir d'une forme JSON canonique (clés triées, séparateurs compacts, **Unicode non échappé**) que presque tous les middlewares reproduisent de manière déterministe. Quant à `X-Signature-Simple`, déprécié, il se rabat sur la signature d'une courte chaîne indépendante de l'analyseur lorsque même cela échoue. Comme il ne signe que cette courte chaîne et jamais le corps, il ne peut pas garantir l'intégrité du corps.### Ordre de vérification recommandé1) Essayez d'abord **`X-Signature-V2`**. Réencodez `JSON.parse(body)` avec les clés triées et Unicode préservé, puis comparez `HMAC-SHA256(secret, canonical)` à l'en-tête avec `timingSafeEqual`.
2) Si la V2 échoue et que vous pouvez lire les octets bruts, essayez **`X-Signature`** avec `HMAC-SHA256(secret, rawBody)`.
3) Ce n'est qu'en cas d'échec des deux que vous revenez à **`X-Signature-Simple`**, obsolète, avec `HMAC-SHA256(secret, "{timestamp}:{session_id}:{status}:{webhook_type}")`. Cette méthode n'authentifie que l'enveloppe : considérez le `decision` du corps comme non fiable et récupérez-le de nouveau via [`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/fr/sessions-api/retrieve-session) avant d'agir.À chaque étape, lorsque l'en-tête `-Previous` correspondant est présent, acceptez une correspondance avec l'un ou l'autre des deux en-têtes (voir [Rotation du secret de signature](#rotation-du-secret-de-signature)).Dans tous les cas, **rejetez** toute requête datant de plus de **5 minutes** (`abs(now - X-Timestamp) > 300`) afin de vous protéger contre les rejeux.## Politique de nouvelles tentativesSi votre endpoint répond par un `5xx` ou un `404`, Didit effectue jusqu'à **2 nouvelles tentatives** avec un backoff exponentiel :- **1er réessai** : environ **1 minute** après l'échec initial.
- **2e réessai** : environ **4 minutes** après le 1er réessai.Après le second réessai, la livraison est abandonnée. Chaque tentative de livraison (initiale et réessais) est consignée comme une entrée distincte dans la Business Console, sous l'onglet **Livraisons** de la destination, afin que vous puissiez rejouer ou inspecter n'importe quelle tentative.Chaque tentative du journal de livraison d'une destination porte un numéro `attempt` : `1` pour la première livraison d'un événement, puis un numéro supérieur de un à celui de la tentative répétée, pour chaque réessai ou renvoi.
Les réessais automatiques sont envoyés à l'URL de la première tentative, même si vous avez modifié depuis le `url` de la destination.
Un renvoi manuel est envoyé à l'URL actuelle de la destination : corriger l'URL corrige donc aussi le renvoi.Autres règles de livraison à connaître :- Le délai d'attente de la requête HTTP sortante est de **5 secondes**.
- Didit bloque les livraisons vers des URL privées ou localhost, par mesure de protection contre le SSRF, et ne suit jamais les redirections. Utilisez un endpoint HTTPS public qui répond directement.
- Une `url` de destination contenant une barre oblique inverse brute, un caractère de contrôle, ou plus d'un `@` dans l'autorité est rejetée comme malformée : une telle URL ne peut pas être créée, et une URL existante ne peut ni être relivrée ni être rejouée. Utilisez une URL simple `https://host/path`, avec au plus une section d'informations utilisateur (pour les URL de webhook de type authentification de base).
- Un `2xx` est considéré comme un succès. Les délais d'attente et les échecs de connexion sont réessayables (ils apparaissent comme `504` ou `503` dans le journal de livraison). Les réponses `3xx` et `4xx`, autres que `404`, ne sont **pas** réessayées.
- Lors d'un réessai, Didit recalcule les signatures avec un nouvel `X-Timestamp`, de sorte que le couple horodatage-signature soit toujours cohérent.
- Les réessais et les renvois sont signés avec le secret de signature actuel de la destination au moment de leur envoi.### Ordre de livraisonDidit ne garantit pas l'ordre dans lequel les webhooks vous parviennent.
Chaque livraison est mise en file d'attente séparément, et un réessai arrive plusieurs minutes après la première tentative : un `status.updated` pour `Approved` peut donc vous parvenir avant un événement antérieur pour `In Progress`.Concevez votre récepteur en conséquence :- Traitez un webhook comme le signal qu'un élément a changé. Lorsque l'ordre compte, appelez [`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/fr/sessions-api/retrieve-session) et agissez selon le `status` actuel.
- Ne laissez jamais un événement plus ancien écraser un état plus récent : comparez le `created_at` de l'événement avec le dernier que vous avez enregistré pour cet enregistrement, et ignorez l'événement s'il est plus ancien.
- Dédupliquez à l'aide de `event_id`, qui reste identique entre les réessais.Didit met en file d'attente les événements d'un transfert Travel Rule dans un ordre défini, décrit dans [Changement de statut d'un échange Travel Rule](#statut-d’échange-travel-rule-modifié-travel_rule-status-updated), mais un réessai peut toujours modifier l'ordre d'arrivée.## Configurer une destinationVous pouvez gérer les destinations depuis la **Business Console > API & Webhooks** ou via l'API de gestion.**1. Créer la destination**Appelez [`POST /v3/webhook/destinations/`](https://docs.didit.me/fr/management-api/webhook/create-destination) (ou cliquez sur **Ajouter une destination** dans la console) avec un `label`, une `url` publique, un `webhook_version` (`"v3"`) et un tableau `subscribed_events` contenant chaque famille d'événements que cet endpoint doit recevoir. Au moins un événement est requis, et aucun caractère générique n'est accepté.**2. Stocker le secret\_shared\_key**La réponse à la création renvoie le `secret_shared_key` de la destination. Conservez-le avec vos autres secrets serveur : il appartient à cette seule destination et sert d'entrée aux trois variantes de signature HMAC-SHA256. [`GET /v3/webhook/destinations/{destination_uuid}/`](https://docs.didit.me/fr/management-api/webhook/get-destination) le renvoie à nouveau aux appelants disposant d'une clé API et aux utilisateurs de la console qui peuvent consulter les webhooks.**3. Implémenter l'endpoint**Exposez une route `POST` HTTPS publique, lisez le corps brut, vérifiez la signature (en privilégiant `X-Signature-V2`), contrôlez la fenêtre temporelle de l'horodatage, puis traitez les événements en fonction de `webhook_type`. Renvoyez un `2xx` dès que le traitement est mis en file d'attente : effectuez les traitements lourds de manière asynchrone.**4. Confirmer la réception**Surveillez l'onglet **Livraisons** de la destination. Un webhook fonctionnel renvoie `200` en une seconde ou deux. Un `5xx` ou un `404` déclenche la politique de nouvelles tentatives de Didit ; les livraisons échouant de manière persistante sont abandonnées après le second réessai.**5. Itérer avec Try Webhook**Utilisez les scénarios de la console [**Try Webhook**](#tests) pour faire passer votre endpoint par des payloads approuvés, refusés, en cours d'examen, KYB, entité, activité et transaction, avant d'activer le trafic de production.![Fonctionnalité de test des webhooks dans la Business Console](https://mintcdn.com/didit-0f962782/z6T2GHM4Zh-iSj-K/images/test-webhook.png?fit=max\&auto=format\&n=z6T2GHM4Zh-iSj-K\&q=85\&s=2787ef3ed7a8016798f951e5ae898e4e)Envoyez des webhooks de test directement depuis la Business Console pour valider votre endpoint avant la mise en production.Vous pouvez créer autant de destinations que nécessaire. Un schéma courant consiste à utiliser **une destination par consommateur** : par exemple, les événements KYC vers votre service d'authentification, les événements KYB vers votre service des opérations de conformité, et les transactions vers votre file de détection de fraude. Chaque destination dispose de son propre secret, de sa propre version et de sa propre liste d'abonnements : la rotation d'un secret ou un déploiement défectueux chez un consommateur n'affecte donc pas les autres.## Exemples de codePour garantir la sécurité de votre endpoint de webhook, vérifiez l'authenticité des requêtes entrantes à l'aide du `secret_shared_key` de la destination de webhook.> [!WARNING]
>
> **Utilisez `X-Signature-V2`.** C'est la variante la plus résistante au ré-encodage par les middlewares, et elle authentifie intégralement le corps (y compris `decision`).### Node.js / Express (X-Signature-V2 recommandé)```javascript theme={null}
const express = require("express");
const crypto = require("crypto");

const app = express();
const PORT = process.env.PORT || 1337;

// Per-destination secret from POST /v3/webhook/destinations/
const WEBHOOK_SECRET_KEY = process.env.WEBHOOK_SECRET_KEY || "YOUR_WEBHOOK_SECRET_KEY";

app.use(express.json());

/**
 * Match Didit's float normalisation: whole-valued floats are serialised as ints.
 */
function shortenFloats(data) {
  if (Array.isArray(data)) return data.map(shortenFloats);
  if (data !== null && typeof data === "object") {
    return Object.fromEntries(
      Object.entries(data).map(([key, value]) => [key, shortenFloats(value)])
    );
  }
  if (typeof data === "number" && !Number.isInteger(data) && data % 1 === 0) {
    return Math.trunc(data);
  }
  return data;
}

/** Sort object keys recursively before re-stringifying. */
function sortKeys(obj) {
  if (Array.isArray(obj)) return obj.map(sortKeys);
  if (obj !== null && typeof obj === "object") {
    return Object.keys(obj).sort().reduce((acc, key) => {
      acc[key] = sortKeys(obj[key]);
      return acc;
    }, {});
  }
  return obj;
}

function verifySignatureV2(jsonBody, signatureHeader, timestampHeader, secret) {
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestampHeader, 10)) > 300) return false;

  // Reproduce Didit's canonical JSON: sorted keys, compact, Unicode preserved.
  const canonical = JSON.stringify(sortKeys(shortenFloats(jsonBody)));
  const expected = crypto.createHmac("sha256", secret).update(canonical, "utf8").digest("hex");

  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(signatureHeader, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

function verifySignatureSimple(jsonBody, signatureHeader, timestampHeader, secret) {
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestampHeader, 10)) > 300) return false;

  const canonical = [
    jsonBody.timestamp ?? "",
    jsonBody.session_id ?? "",
    jsonBody.status ?? "",
    jsonBody.webhook_type ?? "",
  ].join(":");
  const expected = crypto.createHmac("sha256", secret).update(canonical).digest("hex");

  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(signatureHeader, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

/**
 * The signature header and, for 24 hours after the destination's secret is
 * rotated, its "-Previous" header (the same signature made with the replaced secret).
 */
function signaturesFor(req, header) {
  return [req.get(header), req.get(`${header}-Previous`)].filter(Boolean);
}

app.post("/webhook", (req, res) => {
  const timestamp = req.get("X-Timestamp");
  const body = req.body;

  if (!timestamp || !WEBHOOK_SECRET_KEY) {
    return res.status(401).json({ message: "Missing required headers" });
  }

  // A match on either header is enough.
  const matches = (header, verify) =>
    signaturesFor(req, header).some((sig) => verify(body, sig, timestamp, WEBHOOK_SECRET_KEY));

  let verified = false;
  if (matches("X-Signature-V2", verifySignatureV2)) {
    verified = true;
  } else if (matches("X-Signature-Simple", verifySignatureSimple)) {
    // Deprecated fallback: Simple authenticates the envelope only. Do not trust
    // body.decision here — re-fetch it from GET /v3/session/{session_id}/decision/.
    verified = true;
  }
  if (!verified) return res.status(401).json({ message: "Invalid signature" });

  // Dispatch by event type. Return 2xx fast; do heavy work asynchronously.
  switch (body.webhook_type) {
    case "status.updated":
    case "data.updated":
      // session or business session
      break;
    case "user.status.updated":
    case "user.data.updated":
    case "business.status.updated":
    case "business.data.updated":
      // consolidated entity event
      break;
    case "activity.created":
      // audit timeline event
      break;
    case "transaction.created":
    case "transaction.status.updated":
      // transaction monitoring event
      break;
    case "travel_rule.status.updated":
      // travel rule exchange status event
      break;
  }
  return res.status(200).json({ ok: true });
});

app.listen(PORT, () => console.log(`Server on http://localhost:${PORT}`));
```Si vous pouvez lire les octets bruts avant qu'un parseur n'y touche, la variante d'origine `X-Signature` fonctionne de la même façon, mais elle signe le corps brut. Nous la présentons dans la section consacrée aux anciens formats, ci-dessous, par souci d'exhaustivité.### Python / FastAPI (X-Signature-V2 recommandé)```python theme={null}
from fastapi import FastAPI, Request, HTTPException
from time import time
import json
import hmac
import hashlib
import os

app = FastAPI()


def shorten_floats(data):
    """Match Didit: whole-valued floats serialise as ints."""
    if isinstance(data, dict):
        return {k: shorten_floats(v) for k, v in data.items()}
    if isinstance(data, list):
        return [shorten_floats(x) for x in data]
    if isinstance(data, float) and data.is_integer():
        return int(data)
    return data


def verify_signature_v2(body_json, signature_header, timestamp_header, secret):
    if abs(int(time()) - int(timestamp_header)) > 300:
        return False
    canonical = json.dumps(
        shorten_floats(body_json),
        sort_keys=True,
        separators=(",", ":"),
        ensure_ascii=False,   # unescaped Unicode -- matches Didit's V2 canonical form
    )
    expected = hmac.new(secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature_header, expected)


def verify_signature_simple(body_json, signature_header, timestamp_header, secret):
    if abs(int(time()) - int(timestamp_header)) > 300:
        return False
    canonical = ":".join([
        str(body_json.get("timestamp", "")),
        str(body_json.get("session_id", "")),
        str(body_json.get("status", "")),
        str(body_json.get("webhook_type", "")),
    ])
    expected = hmac.new(secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature_header, expected)


def signatures(request, header):
    """The signature header and, for 24 hours after the destination's secret is
    rotated, its -Previous header (the same signature made with the replaced secret)."""
    return [sig for sig in (request.headers.get(header), request.headers.get(f"{header}-previous")) if sig]


@app.post("/webhook")
async def handle_webhook(request: Request):
    body = json.loads((await request.body()).decode("utf-8"))
    secret = os.environ["WEBHOOK_SECRET_KEY"]

    ts = request.headers.get("x-timestamp")
    if not ts:
        raise HTTPException(status_code=401, detail="Missing X-Timestamp")

    # A match on either header is enough.
    if any(verify_signature_v2(body, sig, ts, secret) for sig in signatures(request, "x-signature-v2")):
        pass
    elif any(verify_signature_simple(body, sig, ts, secret) for sig in signatures(request, "x-signature-simple")):
        # Deprecated fallback: Simple authenticates the envelope only. Do not trust
        # body["decision"] here - re-fetch it from GET /v3/session/{session_id}/decision/.
        pass
    else:
        raise HTTPException(status_code=401, detail="Invalid signature")

    # Dispatch on body["webhook_type"]; return 2xx fast.
    return {"ok": True}
```### PHP / Laravel (X-Signature-V2 recommandé)Les valeurs JSON par défaut de PHP ne reproduisent **pas** la forme canonique. Quatre d'entre elles doivent être surchargées, et l'oubli d'une seule change les octets signés, de sorte que l'empreinte ne correspond jamais :| Valeur par défaut PHP | Ce qu'elle produit | Ce qu'il faut |
| - | - | - |
| `json_decode($raw, true)` (associatif) | les objets vides `{}` deviennent `[]` | décoder avec `$associative = false` pour que les objets JSON restent des `stdClass` |
| `json_encode` échappe les barres obliques | `https:\/\/media.didit.me` | `JSON_UNESCAPED_SLASHES` |
| `json_encode` échappe les caractères non ASCII | `Jos\u00e9` au lieu de `José` | `JSON_UNESCAPED_UNICODE` |
| `ksort` compare les clés ressemblant à des nombres comme des nombres | `"9"` est trié avant `"10"` | `ksort($props, SORT_STRING)` |Le piège des objets vides est celui qui fait le plus de dégâts. Les payloads de Didit contiennent couramment des dictionnaires vides, comme `risk_view.countries.risk_scores`, `risk_view.crimes.risk_scores` et `risk_view.custom_list`. Un aller-retour associatif casse donc la signature sur des sessions approuvées tout à fait ordinaires, et non dans un cas limite rare.```php theme={null}
<?php

use Illuminate\Http\Request;

class WebhookController extends Controller
{
    /**
     * Rebuild Didit's canonical JSON form: recursively sorted keys, JSON
     * objects preserved as objects, arrays left in order.
     */
    private function canonicalize($value)
    {
        if ($value instanceof \stdClass) {
            $props = get_object_vars($value);
            ksort($props, SORT_STRING); // code-point order, like Python's sort_keys
            $sorted = new \stdClass();
            foreach ($props as $key => $item) {
                $sorted->{$key} = $this->canonicalize($item);
            }
            return $sorted;
        }
        if (is_array($value)) { // JSON arrays keep their order
            return array_map(function ($item) { return $this->canonicalize($item); }, $value);
        }
        return $value;
    }

    private function verifySignatureV2(string $rawBody, string $sig, string $ts, string $secret): bool
    {
        if (abs(time() - (int) $ts) > 300) return false;

        // Decode to objects, not associative arrays: associative decoding turns
        // empty JSON objects ({}) into empty arrays ([]), and json_encode then
        // writes them back as [] -- different bytes than Didit signed.
        $decoded = json_decode($rawBody, false);
        if (json_last_error() !== JSON_ERROR_NONE) return false;

        $canonical = json_encode(
            $this->canonicalize($decoded),
            JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
        );
        return hash_equals(hash_hmac('sha256', $canonical, $secret), $sig);
    }

    /** X-Signature: HMAC over the exact bytes received -- never a re-serialised copy. */
    private function verifySignatureRaw(string $rawBody, string $sig, string $ts, string $secret): bool
    {
        if (abs(time() - (int) $ts) > 300) return false;
        return hash_equals(hash_hmac('sha256', $rawBody, $secret), $sig);
    }

    private function verifySignatureSimple(?array $body, string $sig, string $ts, string $secret): bool
    {
        if (abs(time() - (int) $ts) > 300) return false;
        $canonical = implode(':', [
            $body['timestamp'] ?? '',
            $body['session_id'] ?? '',
            $body['status'] ?? '',
            $body['webhook_type'] ?? '',
        ]);
        return hash_equals(hash_hmac('sha256', $canonical, $secret), $sig);
    }

    public function handle(Request $request)
    {
        $secret = env('WEBHOOK_SECRET_KEY');
        $ts = $request->header('x-timestamp');
        if (!$ts || !$secret) {
            return response()->json(['message' => 'Missing required headers'], 401);
        }

        // getContent() hands you the exact bytes Didit transmitted, before any parser runs.
        $rawBody = $request->getContent();
        $body = json_decode($rawBody, true); // associative copy, for dispatch only

        // Check the signature header and, for 24 hours after the destination's secret is
        // rotated, its -Previous header (the same signature made with the replaced secret).
        // A match on either header is enough.
        $matches = function (string $header, callable $verify) use ($request): bool {
            foreach ([$header, $header . '-previous'] as $name) {
                $sig = $request->header($name);
                if ($sig && $verify($sig)) {
                    return true;
                }
            }
            return false;
        };

        $verified = $matches('x-signature-v2', fn ($sig) => $this->verifySignatureV2($rawBody, $sig, $ts, $secret))
            || $matches('x-signature', fn ($sig) => $this->verifySignatureRaw($rawBody, $sig, $ts, $secret))
            // Deprecated fallback: Simple authenticates the envelope only. Do not trust
            // the decision body here - re-fetch it from GET /v3/session/{session_id}/decision/.
            || $matches('x-signature-simple', fn ($sig) => $this->verifySignatureSimple($body, $sig, $ts, $secret));
        if (!$verified) {
            return response()->json(['message' => 'Invalid signature'], 401);
        }

        // Dispatch on $body['webhook_type']; return 2xx fast.
        return response()->json(['ok' => true]);
    }
}
```Deux autres points méritent d'être connus :- **Les flottants à valeur entière n'exigent aucun traitement particulier en PHP.** Didit les sérialise sous forme d'entiers (`100.0` → `100`), et `json_encode` de PHP écrit de la même manière un flottant entier. Aucune étape `shortenFloats` n'est donc requise, contrairement à l'exemple Node.js ci-dessus.
- **Conservez `serialize_precision` à `-1`** (valeur par défaut depuis PHP 7.1). Si votre hébergeur la fixe à `17`, `json_encode` écrit `87.42` sous la forme `87.420000000000002`, et chaque vérification `X-Signature-V2` échoue.Si vous ne parvenez pas à faire correspondre la forme canonique, `verifySignatureRaw` ci-dessus est la solution de repli la plus simple : `$request->getContent()` de Laravel renvoie les octets intacts, donc `X-Signature` fonctionne tant qu'aucun élément placé devant PHP ne ré-encode le corps. C'est exactement le cas pour lequel `X-Signature-V2` est conçue, ce qui explique pourquoi V2 est essayée en premier.## TestsVous **n'avez pas** besoin d'exécuter une vérification complète pour tester votre endpoint.- Depuis **Business Console > API & Webhooks > Try Webhook**, choisissez un scénario (par exemple `approved_full_features`, `declined_face_mismatch`, `in_review_aml_hit`, `business_session_approved`, `transaction_created`, `activity_created`, `user_status_updated`) et envoyez un webhook complet à l'URL de votre destination.
- Chaque scénario est signé avec les mêmes trois en-têtes de signature et le même encodage canonique que le trafic de production, y compris des données de test Unicode (`approved_kyc_with_unicode`), afin que vous puissiez vérifier que `X-Signature-V2` fonctionne à travers votre middleware. Les livraisons de test contiennent des données d'exemple et sont marquées par un en-tête supplémentaire `X-Didit-Test-Webhook: true` (ainsi que par un suffixe `(Test)` dans le `User-Agent`), afin qu'elles n'affectent pas l'état de production.
- Rejouez n'importe quelle livraison historique depuis l'onglet **Livraisons** d'une destination si vous devez diagnostiquer une régression.Pour les tests de fumée de bout en bout, lancez une vérification réelle via un workflow de test dans votre propre application : les webhooks obtenus utilisent la même enveloppe, les mêmes en-têtes et la même politique de nouvelles tentatives que les scénarios **Try Webhook**.## Exemples### Session KYC approuvée (`status.updated`)```json theme={null}
{
  "event_id": "9c0c8b8a-1111-4222-9333-444444444444",
  "webhook_type": "status.updated",
  "timestamp": 1774970000,
  "created_at": 1774969994,
  "application_id": "11111111-2222-3333-4444-555555555555",
  "environment": "live",
  "sandbox_scenario": null,
  "session_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
  "status": "Approved",
  "workflow_id": "66666666-7777-8888-9999-000000000000",
  "workflow_version": 4,
  "vendor_data": "user_42",
  "metadata": { "tier": "premium" },
  "decision": {
    "session_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
    "session_number": 43762,
    "status": "Approved",
    "workflow_id": "66666666-7777-8888-9999-000000000000",
    "features": ["ID_VERIFICATION", "LIVENESS", "FACE_MATCH", "AML"],
    "vendor_data": "user_42",
    "id_verifications": [
      {
        "node_id": "id_verification_1",
        "status": "Approved",
        "document_type": "Identity Card",
        "document_number": "SAMPLE-DOC-12345",
        "first_name": "Jane",
        "last_name": "Doe",
        "date_of_birth": "1990-01-01",
        "issuing_state": "ESP",
        "verification_method": "document",
        "assurance": "documentary",
        "wallet_provider": null,
        "fallback_from": null,
        "id_lookup": null,
        "wallet_verification": null,
        "warnings": []
      }
    ],
    "liveness_checks": [
      { "node_id": "liveness_1", "status": "Approved", "method": "ACTIVE_3D", "score": 95.4, "warnings": [] }
    ],
    "face_matches": [
      { "node_id": "face_match_1", "status": "Approved", "score": 96.1, "warnings": [] }
    ],
    "aml_screenings": [
      { "node_id": "aml_1", "status": "Approved", "total_hits": 0, "entity_type": "person", "hits": [], "warnings": [] }
    ],
    "reviews": [],
    "created_at": "2026-05-17T08:54:25.443172Z"
  }
}
```### Session refusée avec avertissements (`status.updated`)```json theme={null}
{
  "event_id": "9c0c8b8a-1111-4222-9333-555555555555",
  "webhook_type": "status.updated",
  "timestamp": 1774970000,
  "created_at": 1774969994,
  "application_id": "11111111-2222-3333-4444-555555555555",
  "environment": "live",
  "sandbox_scenario": null,
  "session_id": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
  "status": "Declined",
  "vendor_data": "user_99",
  "decision": {
    "session_id": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
    "status": "Declined",
    "features": ["ID_VERIFICATION", "LIVENESS", "FACE_MATCH"],
    "id_verifications": [
      {
        "node_id": "id_verification_1",
        "status": "Declined",
        "document_type": "Passport",
        "expiration_date": "2024-01-01",
        "warnings": [
          {
            "feature": "ID_VERIFICATION",
            "risk": "DOCUMENT_EXPIRED",
            "additional_data": null,
            "log_type": "error",
            "short_description": "Document expired",
            "long_description": "The document's expiration date has passed, rendering it no longer valid for use.",
            "node_id": "id_verification_1"
          }
        ]
      }
    ],
    "liveness_checks": [{ "node_id": "liveness_1", "status": "Approved", "score": 92.0, "warnings": [] }],
    "face_matches": [
      {
        "node_id": "face_match_1",
        "status": "Declined",
        "score": 32.0,
        "warnings": [
          {
            "feature": "FACEMATCH",
            "risk": "LOW_FACE_MATCH_SIMILARITY",
            "additional_data": null,
            "log_type": "error",
            "short_description": "Low face match similarity",
            "long_description": "The facial features of the provided image don't closely match the reference image, suggesting a potential identity mismatch.",
            "node_id": "face_match_1"
          }
        ]
      }
    ],
    "reviews": []
  }
}
```### Session entreprise approuvée (`status.updated`, KYB)```json theme={null}
{
  "event_id": "uuid",
  "webhook_type": "status.updated",
  "timestamp": 1774970000,
  "created_at": 1774969994,
  "application_id": "uuid",
  "environment": "live",
  "sandbox_scenario": null,
  "session_id": "kyb-uuid",
  "business_session_id": "kyb-uuid",
  "session_kind": "business",
  "status": "Approved",
  "vendor_data": "biz_7",
  "vendor_business_id": "acme-inc",
  "decision": {
    "session_id": "kyb-uuid",
    "session_kind": "business",
    "status": "Approved",
    "features": ["REGISTRY_CHECK", "AML", "DOCUMENT_VERIFICATION"],
    "registry_checks": [{ "node_id": "registry_1", "status": "Approved", "company_name": "Acme Corp", "...": "..." }],
    "aml_screenings": [{ "node_id": "aml_1", "status": "Approved", "entity_type": "company", "total_hits": 0, "hits": [] }],
    "document_verifications": [{ "node_id": "doc_1", "status": "Approved", "document_type": "certificate_of_incorporation" }],
    "key_people_checks": [],
    "reviews": []
  }
}
```### Transaction créée (`transaction.created`)```json theme={null}
{
  "event_id": "uuid",
  "webhook_type": "transaction.created",
  "timestamp": 1774970000,
  "created_at": 1774970000,
  "application_id": "uuid",
  "environment": "live",
  "transaction_id": "uuid",
  "txn_id": "finance0001",
  "status": "APPROVED",
  "score": 0,
  "severity": "UNKNOWN",
  "amount": "1200.00",
  "currency": "EUR",
  "direction": "OUTBOUND",
  "action_required": null
}
```### Statut d'échange Travel Rule modifié (`travel_rule.status.updated`)```json theme={null}
{
  "event_id": "9c0c8b8a-1111-4222-9333-666666666666",
  "webhook_type": "travel_rule.status.updated",
  "timestamp": 1774970000,
  "created_at": 1774970000,
  "application_id": "11111111-2222-3333-4444-555555555555",
  "environment": "live",
  "transaction_id": "22222222-3333-4444-5555-666666666666",
  "txn_id": "trv-2026-07-05-001",
  "travel_rule_status": "COMPLETED",
  "rail": "INTERNAL",
  "direction": "OUTBOUND"
}
````travel_rule_status` prend l'une des 13 valeurs de statut d'échange décrites dans [Travel Rule](https://docs.didit.me/fr/transaction-monitoring/travel-rule.md#cycle-de-vie).
`rail` vaut `INTERNAL`, `TRP`, `GTR`, `TRUST`, `VERIFYVASP`, `SYGNA` ou `EMAIL` (chaîne vide tant qu'aucun rail n'est sélectionné, si aucun n'a pu être déterminé, et toujours pour les enregistrements sunrise), ou `TEST_VASP` pour un transfert sandbox auquel le [Didit Test VASP](https://docs.didit.me/fr/transaction-monitoring/travel-rule-sandbox.md) a répondu, et `direction` vaut `INBOUND` ou `OUTBOUND`.
Un événement sandbox contient `"environment": "sandbox"`, et un événement `TEST_VASP` contient aussi `test_vasp: true` et `test_vasp_scenario` (le scénario qui a répondu).Lorsque Didit crée une transaction pour un transfert Travel Rule (un appel [`POST /v3/transactions/`](https://docs.didit.me/fr/management-api/transactions/create), un [enregistrement sunrise après dépôt](https://docs.didit.me/fr/transaction-monitoring/travel-rule.md#enregistrement-a-posteriori-sunrise), une demande entrante `TRP` ou la portion entrante sur le rail `INTERNAL`), il envoie d'abord l'événement `transaction.created` pour cette transaction, puis les événements `travel_rule.status.updated` du transfert.
Une règle qui modifie le statut de la transaction pendant cette étape peut aussi envoyer `transaction.status.updated` avant `transaction.created`, et un enregistrement sunrise rattaché à une transaction de dépôt que vous avez déjà créée n'envoie aucun `transaction.created`.
Chaque webhook est livré et réessayé indépendamment, si bien qu'un événement réessayé peut arriver après un événement plus récent : utilisez `transaction_id` comme clé dans votre gestionnaire et relisez la transaction lorsque vous avez besoin de son état actuel.
Un transfert envoyé via TRUST ne reçoit jamais d'événement `COMPLETED`, car TRUST ne renvoie rien que Didit puisse lire : il reste à `AWAITING_COUNTERPARTY` jusqu'à l'échéance de confirmation, puis passe à `EXPIRED`. Via GTR, VerifyVASP et Sygna Bridge, Didit lit la réponse de la contrepartie sur le réseau et l'événement la suit (voir [Réseaux Travel Rule](https://docs.didit.me/fr/transaction-monitoring/travel-rule-networks.md)).### Conformité du workflow modifiée (`workflow.compliance_changed`)```json theme={null}
{
  "event_id": "9c0c8b8a-1111-4222-9333-777777777777",
  "webhook_type": "workflow.compliance_changed",
  "timestamp": 1774970000,
  "created_at": 1774970000,
  "application_id": "11111111-2222-3333-4444-555555555555",
  "environment": "live",
  "workflow_id": "22222222-3333-4444-5555-666666666666",
  "workflow_version": 4,
  "workflow_label": "KYC standard",
  "transition": { "from": "compliant", "to": "gaps_detected" },
  "severity": "actionable",
  "kb_version": "2026-10"
}
````transition.from` et `transition.to` prennent l'une des valeurs `compliant`, `gaps_detected`, `pending_change`, `unknown_coverage` ou `not_evaluated`.
`severity` vaut `actionable` lorsque des écarts ont été détectés, `indicative` pour un changement en attente et `informative` lorsque les écarts ont été résolus.
Didit envoie un événement par workflow modifié.### Suggestions de règles de transaction créées (`transaction_rule_suggestions.created`)```json theme={null}
{
  "event_id": "9c0c8b8a-1111-4222-9333-888888888888",
  "webhook_type": "transaction_rule_suggestions.created",
  "timestamp": 1774970000,
  "created_at": 1774970000,
  "application_id": "11111111-2222-3333-4444-555555555555",
  "environment": "live",
  "run_id": "33333333-4444-5555-6666-777777777777",
  "open_count": 3,
  "suggestions": [
    {
      "suggestion_id": "44444444-5555-6666-7777-888888888888",
      "kind": "TWEAK",
      "title": "Raise the review threshold on high-value transfers",
      "rule_id": "55555555-6666-7777-8888-999999999999",
      "expected_impact": {}
    }
  ]
}
````kind` vaut `ADD` pour une nouvelle règle, `TWEAK` pour la modification d'une règle existante et `DISABLE` pour désactiver une règle. `rule_id` vaut `null` pour `ADD`.
`open_count` correspond au nombre de suggestions encore ouvertes après l'exécution, nouvelles suggestions comprises.
Les paramètres des suggestions de règles activent ou désactivent ce webhook ; voir [Webhooks de transaction](https://docs.didit.me/fr/transaction-monitoring/webhooks.md).### Événements Investigator (`investigator.*`)Les douze événements `investigator.*` utilisent la même enveloppe (`event_id`, `webhook_type`, `timestamp`, `created_at`, `application_id`, `environment`) et les mêmes en-têtes de signature que tous les autres événements. Ils ne contiennent que des identifiants et des chiffres : jamais de nom, de motif saisi par un réviseur, de récit ni de contenu rédigé par l'utilisateur final. Ils n'ont pas de `session_id` ; l'élément concerné est indiqué dans `item`.```json theme={null}
{
  "event_id": "9c0c8b8a-1111-4222-9333-777777777777",
  "webhook_type": "investigator.decision.automated",
  "timestamp": 1774970000,
  "created_at": 1774970000,
  "application_id": "11111111-2222-3333-4444-555555555555",
  "environment": "live",
  "run_id": "33333333-4444-5555-6666-777777777777",
  "queue": "kyc",
  "item": { "kind": "session", "id": "cccccccc-dddd-eeee-ffff-000000000000" },
  "recommendation": "approve",
  "confidence": 0.93,
  "provenance": {
    "agent_version": "1",
    "engine": "a1b2c3d4",
    "policy_version": 4,
    "model_id": "a1b2c3d4"
  },
  "decided_by": "investigator",
  "outcome": "approved",
  "action": "approve",
  "final_status": "Approved",
  "agreed": true
}
```| Événement | Champs en dehors de l'enveloppe |
| - | - |
| `investigator.report.ready` | `run_id`, `queue`, `item`, `recommendation`, `confidence`, `provenance`, `ready_to_accept`, `questions` (nombre) |
| `investigator.decision.automated` | `run_id`, `queue`, `item`, `recommendation`, `confidence`, `provenance`, `decided_by` (`"investigator"`), `outcome`, `action`, `final_status`, `agreed` |
| `investigator.decision.overridden` | Identique à `investigator.decision.automated` avec `decided_by` `"human"`, plus `override_category`, `overrode` (`"report"` ou `"automated"`) et, lorsque `overrode` vaut `"automated"`, `previous_status` |
| `investigator.decision.contested` | `run_id`, `queue`, `item`, `recommendation`, `confidence`, `provenance`, `case_id`, `contested_at` |
| `investigator.question.raised` | `question_id`, `number`, `queue`, `situation`, `situation_label`, `origin`, `rule_action`, `run_id`, `item` |
| `investigator.automation.paused` | `reason` (`"manual"` ou `"spend_limit"`) ; pour `"spend_limit"`, également `month` (`YYYY-MM`) et `currency` |
| `investigator.rfi.answered` | `request_id`, `run_id`, `queue`, `item`, `status`, `deadline_at`, `answered_by`, `answered_at` |
| `investigator.rfi.overdue` | `request_id`, `run_id`, `queue`, `item`, `status`, `deadline_at` |
| `investigator.drift.detected` | `queue`, `reasons`, `recent_agreement`, `baseline_agreement`, `recent_decisions`, `baseline_decisions`, `drop_points`, `psi`, `from_tier`, `to_tier` |
| `investigator.sla.breached` | `run_id`, `queue`, `item`, `priority`, `sla_due_at` |
| `investigator.qa.failed` | `qa_id`, `run_id`, `queue`, `item`, `kind`, `overall`, `scores`, `sop_complies`, `missed_risks` (codes de risque), `action`, `decided_at` |
| `investigator.external.report.ready` | `item_id`, `external_id`, `queue` (`"external"`), `kind`, `status`, `recommendation`, `confidence`, `policy_action`, `provenance` (`engine`, `agent_version`, `policy_version`, `model_id`) |`provenance` indique la version de l'Investigator qui a rédigé le rapport :- `engine` est la version du moteur Investigator : un identifiant opaque de huit caractères. Il change à chaque modification du moteur ; deux rapports ayant la même valeur de `engine` ont donc été rédigés par le même moteur.
- `agent_version` est la version de l'agent.
- `policy_version` est la version de votre propre politique que le rapport a suivie.
- `model_id` est un alias obsolète qui porte la même valeur que `engine`. Utilisez plutôt `engine`.
- `prompt_hash` n'est plus envoyé.`item.kind` indique le type d'élément (une session, une session entreprise, une transaction ou un dossier) et `item.id` son identifiant. Les horodatages dont le nom se termine par `_at` sont des chaînes ISO 8601. Abonnez-vous à ces événements en listant les chaînes exactes dans `subscribed_events`, comme pour tout autre événement.### Statut de l'entité utilisateur modifié (`user.status.updated`)```json theme={null}
{
  "event_id": "uuid",
  "webhook_type": "user.status.updated",
  "timestamp": 1774970000,
  "created_at": 1774969980,
  "application_id": "uuid",
  "environment": "live",
  "vendor_user_id": "uuid",
  "vendor_data": "user_42",
  "status": "BLOCKED",
  "previous_status": "ACTIVE"
}
```### Session soumise à nouveau```json theme={null}
{
  "event_id": "uuid",
  "webhook_type": "status.updated",
  "timestamp": 1774970000,
  "created_at": 1774969980,
  "application_id": "uuid",
  "environment": "live",
  "sandbox_scenario": null,
  "session_id": "uuid",
  "status": "Resubmitted",
  "vendor_data": "user_42",
  "resubmit_info": {
    "nodes_to_resubmit": [
      { "node_id": "feature_poa", "feature": "PROOF_OF_ADDRESS" }
    ],
    "reasons": { "feature_poa": "Poor document quality" }
  }
}
```## Ancienne méthode X-Signature sur le corps brut`X-Signature` est toujours envoyé à chaque livraison ; il s'agit d'un HMAC-SHA256 calculé sur les **octets exacts** que Didit transmet (`json.dumps(..., sort_keys=True, separators=(",", ":"))`). Ne l'utilisez que si vous pouvez lire le corps brut avant que tout middleware ne le modifie.> [tab] Node.js
>
> ```javascript theme={null}
> const express = require("express");
> const bodyParser = require("body-parser");
> const crypto = require("crypto");
>
> const app = express();
> const PORT = process.env.PORT || 1337;
> const WEBHOOK_SECRET_KEY = process.env.WEBHOOK_SECRET_KEY || "YOUR_WEBHOOK_SECRET_KEY";
>
> app.use(bodyParser.json({
>   verify: (req, _res, buf, encoding) => {
>     if (buf && buf.length) req.rawBody = buf.toString(encoding || "utf8");
>   },
> }));
>
> app.post("/webhook", (req, res) => {
>   const signature = req.get("X-Signature");
>   const timestamp = req.get("X-Timestamp");
>   if (!signature || !timestamp || !req.rawBody) return res.status(401).end();
>
>   if (Math.abs(Math.floor(Date.now() / 1000) - parseInt(timestamp, 10)) > 300) {
>     return res.status(401).json({ message: "Stale timestamp" });
>   }
>
>   const expected = crypto.createHmac("sha256", WEBHOOK_SECRET_KEY).update(req.rawBody).digest("hex");
>   const a = Buffer.from(expected, "utf8");
>   const b = Buffer.from(signature, "utf8");
>   if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
>     return res.status(401).json({ message: "Invalid signature" });
>   }
>
>   const event = JSON.parse(req.rawBody);
>   // Handle event.webhook_type ...
>   return res.json({ ok: true });
> });
>
> app.listen(PORT);
> ```> [tab] Python
>
> ```python theme={null}
> from fastapi import FastAPI, Request, HTTPException
> from time import time
> import hmac
> import hashlib
> import os
>
> app = FastAPI()
>
>
> def verify_raw(body: str, signature: str, timestamp: str, secret: str) -> bool:
>     if abs(int(time()) - int(timestamp)) > 300:
>         return False
>     expected = hmac.new(secret.encode("utf-8"), body.encode("utf-8"), hashlib.sha256).hexdigest()
>     return hmac.compare_digest(signature, expected)
>
>
> @app.post("/webhook")
> async def handle(request: Request):
>     body = (await request.body()).decode("utf-8")
>     sig = request.headers.get("x-signature")
>     ts = request.headers.get("x-timestamp")
>     secret = os.environ["WEBHOOK_SECRET_KEY"]
>
>     if not sig or not ts or not verify_raw(body, sig, ts, secret):
>         raise HTTPException(status_code=401, detail="Unauthorized")
>     return {"ok": True}
> ```## Ancienne méthode : format de webhook V2> [!WARNING]
>
> Les champs ci-dessous ne s'appliquent que lorsqu'une destination est fixée à `webhook_version: "v2"`. Une version `"v1"` encore plus ancienne existe également (une troisième forme de décision, au singulier), pour les destinations créées avant la V2 : contactez le support avant de vous en servir. Les nouvelles intégrations doivent utiliser la **V3** (par défaut), documentée plus haut, qui utilise des tableaux au pluriel.| Aspect | V2 | V3 |
| - | - | - |
| En-têtes de signature | `X-Signature`, `X-Signature-V2`, `X-Signature-Simple` (les trois sont envoyés quelle que soit la version ; Simple est [obsolète](#x-signature-simple-est-déprécié)) | Identique |
| Noms des champs de décision | Objets au singulier (`id_verification`, `liveness`, `face_match`, ...) | Tableaux (`id_verifications`, `liveness_checks`, `face_matches`, ...) |
| Instances multiples par fonctionnalité | Non pris en charge | Pris en charge via des tableaux par fonctionnalité avec `node_id` |
| Scores AML | `aml.score`, `aml.hits[].match_score`, `aml.hits[].risk_score` | Mêmes valeurs sous `aml_screenings[]` |Les objets par fonctionnalité sont identiques dans les deux versions : une destination V2 reçoit exactement les champs de chaque résultat `match_score`, `risk_score`, `score_breakdown`, `risk_view` et `review_status`, ainsi que le `score` global, comme en V3 (voir [Rapport AML](https://docs.didit.me/fr/core-technology/aml-screening/report-aml-screening.md#trois-scores-pas-un-seul-verdict)). Fixer la version V2 change l'emplacement du rapport AML dans `decision`, pas son contenu.Si un consommateur en aval a encore besoin des structures V2, créez une destination de webhook distincte configurée sur `webhook_version: "v2"`, et gardez vos nouveaux consommateurs sur la V3.## Voir aussi- [API des destinations de webhook](https://docs.didit.me/fr/management-api/webhook/list-destinations.md) : lister, créer, mettre à jour et supprimer des destinations, et consulter les journaux de livraison.
- [Créer une destination de webhook](https://docs.didit.me/fr/management-api/webhook/create-destination.md) : schéma de la requête et de la réponse, y compris le `secret_shared_key` de la destination.
- [Modèles de données](https://docs.didit.me/fr/reference/data-models.md) : schéma canonique de chaque objet par fonctionnalité intégré dans `decision`.
- [Schéma de décision](https://docs.didit.me/fr/sessions-api/retrieve-session.md) : liste complète des champs de l'objet `decision` intégré dans les webhooks de session.
- [Statuts de vérification](https://docs.didit.me/fr/integration/verification-statuses.md) : valeurs exactes de `status` et transitions mentionnées plus haut.
- [Limites de débit](https://docs.didit.me/fr/integration/rate-limiting.md) : limites applicables lorsque vous interrogez `GET /v3/session/{id}/decision/` en solution de repli.


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