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

# Webhook Didit: eventi KYC/KYB in tempo reale, firma HMAC

> Ricevi eventi di verifica, entità e transazioni da Didit in tempo reale. Firma HMAC-SHA256, destinazioni multiple e retry limitati.

> [!NOTE]
>
> Attività di autenticazione e relativa consegna dei webhook, che dipende dalla release, sono documentate in [Eventi di autenticazione e webhook](https://docs.didit.me/it/authentication/events-and-webhooks.md). Non registrare tipi di evento di autenticazione non elencati con l'API di destinazione attuale.

Prompt di integrazione webhook
        Copia il prompt negli appunti



      
      

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



      
      

Incolla in

## PanoramicaI webhook sono il modo in cui Didit ti invia **aggiornamenti in tempo reale** ogni volta che una sessione di verifica, una sessione aziendale, un utente del fornitore, un'azienda del fornitore o una transazione cambia stato. Associare una o più destinazioni agli eventi che ti interessano ti permette di reagire nel momento in cui un risultato è pronto, di norma entro pochi secondi dal completamento del flusso da parte dell'utente sottoposto a verifica.I webhook sono il pattern di integrazione **consigliato**. Il polling di [`GET /v3/session/{id}/decision/`](https://docs.didit.me/it/sessions-api/retrieve-session) è supportato come soluzione di ripiego, ma è più lento, richiede più richieste e salta eventi come `data.updated`, `transaction.status.updated` e le modifiche a livello di entità, che vengono emessi solo tramite webhook. Considera la consegna dei webhook come la fonte di verità e ricorri al polling solo all'avvio a freddo o per la riconciliazione.Questo capitolo della lezione di integrazione dell'Academy di Didit riprende dalla panoramica dei webhook.La lezione dell'Academy sull'integrazione tratta i webhook a partire da 3:12: aggiunta di una destinazione, scelta degli eventi e il segreto di firma che verificherai più avanti.[Panoramica dei webhook — Didit Academy](https://www.youtube.com/embed/fX03-WEu_EI?start=192\&rel=0\&playsinline=1)> [!NOTE]
>
> **Utenti Cloudflare / WAF.** Didit consegna i webhook dall'IP statico `18.203.201.92` con `User-Agent: DiditWebhook/2.0 +https://didit.me`. Se il tuo perimetro di rete blocca i client sconosciuti, consenti questo IP in **Security > WAF > Tools > IP Access Rules** e scegli **Consenti** per l'hostname che riceve i webhook.## Tipi di eventoUsa il valore esatto nell'array `subscribed_events` di una destinazione webhook. Non esiste un carattere jolly: elenca ogni famiglia che vuoi ricevere, e puoi distribuire gli eventi su più destinazioni.| Evento | Risorsa | Quando si attiva |
| - | - | - |
| `status.updated` | Sessione KYC o sessione aziendale KYB | Lo stato di una sessione cambia (Non avviata → In corso → Approvata / Rifiutata / In revisione / Abbandonata / Ripresentata). I payload KYB includono `session_kind: "business"` e `business_session_id`. |
| `data.updated` | Sessione KYC o sessione aziendale KYB | I dati di verifica vengono modificati dopo la creazione (correzioni manuali del revisore su KYC, POA, NFC, registro KYB, documenti KYB, ecc.). |
| `user.status.updated` | Entità utente | Lo stato di un utente consolidato cambia tra `ACTIVE`, `FLAGGED` e `BLOCKED` (azione in console, azione di una regola, blocklist). Include `previous_status`. |
| `user.data.updated` | Entità utente | Il profilo, i contatori, i metadati o gli identificatori approvati di un utente consolidato cambiano. Include `changed_fields` e una mappa `changes` con i valori `previous` e `current`. |
| `business.status.updated` | Entità azienda | Lo stato di un'azienda consolidata cambia tra `ACTIVE`, `FLAGGED` e `BLOCKED` (azione in console, azione di una regola, blocklist). Include `previous_status`. |
| `business.data.updated` | Entità azienda | Il profilo, i dati di registrazione, i contatori, i metadati o i campi di verifica aggregati di un'azienda consolidata cambiano. Include `changed_fields` e `changes`. |
| `activity.created` | Timeline delle attività | Riservato al feed della timeline delle attività. Il valore è accettato in `subscribed_events` e coperto dal tester **Try Webhook**, ma Didit al momento non invia `activity.created` per il traffico in produzione. |
| `transaction.created` | Monitoraggio delle transazioni | Una transazione viene creata e il suo primo esito delle regole è pronto. Include `transaction_id`, `txn_id`, `status`, `score`, `severity`, `amount`, `currency`, `direction`, `action_required`. |
| `transaction.status.updated` | Monitoraggio delle transazioni | Lo stato di una transazione cambia dopo la valutazione delle regole, un'azione dell'analista, un'azione correttiva, corrispondenze nella blocklist, aggiornamenti del provider o azioni tramite API o console. Viene inviato anche una sola volta, con stato modificato o meno, quando termina uno screening crypto ritentato in background ([hash delle transazioni non ancora indicizzati](https://docs.didit.me/it/transaction-monitoring/aml-screening.md#hash-delle-transazioni-non-ancora-indicizzati)). |
| `travel_rule.status.updated` | Travel Rule | Lo stato di uno scambio [Travel Rule](https://docs.didit.me/it/transaction-monitoring/travel-rule.md) cambia - alla creazione (incluso il passaggio `NOT_APPLICABLE` sotto soglia), alla conferma dell'assetto proprietario, alla risposta relativa al ritiro via email, alla conclusione o all'annullamento, oppure alla scadenza per timeout. Viene inviato per i trasferimenti in uscita e in entrata: la controparte in entrata sulla rete `INTERNAL`, le registrazioni sunrise e le richieste `TRP` in entrata. Per una transazione creata da Didit, questi eventi vengono inviati dopo il suo `transaction.created`. Include `transaction_id`, `txn_id`, `travel_rule_status`, `rail`, `direction`. |
| `workflow.compliance_changed` | Compliance del workflow | Lo stato di compliance di un workflow pubblicato è cambiato dopo una rivalutazione. Un evento per workflow. Contiene `workflow_id`, `workflow_version`, `workflow_label`, `transition` (`from` e `to`), `severity` e `kb_version`, e nessun `status`. |
| `transaction_rule_suggestions.created` | Monitoraggio delle transazioni | Un'esecuzione dei suggerimenti di regole ha proposto nuove modifiche alle regole e le impostazioni dei suggerimenti hanno le notifiche webhook attive. Contiene `run_id`, `open_count` e `suggestions[]`, e nessun `status`. |
| `investigator.report.ready` | Investigator | L'Investigator ha scritto un report su un elemento. |
| `investigator.decision.automated` | Investigator | L'Investigator ha preso autonomamente una decisione su un elemento. |
| `investigator.decision.overridden` | Investigator | Una persona ha intrapreso un'azione diversa da quella indicata nel report dell'Investigator, oppure ha annullato una decisione che lo stesso Investigator aveva preso autonomamente. |
| `investigator.decision.contested` | Investigator | L'utente finale ha contestato una decisione e ha chiesto una revisione umana. |
| `investigator.question.raised` | Investigator | L'Investigator ha posto al team una domanda sulla sua politica. |
| `investigator.automation.paused` | Investigator | L'automazione ha smesso di prendere decisioni perché è stata messa in pausa da una persona o al raggiungimento del limite di spesa mensile. |
| `investigator.rfi.answered` | Investigator | L'utente finale ha risposto a una richiesta di informazioni. |
| `investigator.rfi.overdue` | Investigator | Una richiesta di informazioni ha superato la scadenza senza risposta. |
| `investigator.drift.detected` | Investigator | Una coda ha subito una deriva e l'automazione è scesa di un livello. |
| `investigator.sla.breached` | Investigator | Un report ha atteso oltre lo SLA della sua coda senza una decisione. |
| `investigator.qa.failed` | Investigator | Una decisione presa da una persona non ha superato la revisione di qualità dell'Investigator. |
| `investigator.external.report.ready` | Investigator | Il report su un elemento inviato dall'esterno di Didit è pronto. |
| `auth.signup` | [Autenticazione](https://docs.didit.me/it/authentication/events-and-webhooks.md) | Una persona si registra tramite Autenticazione. Include `auth_event` e il riepilogo `authentication` della persona. |
| `auth.login` | Autenticazione | Una persona accede. Include `auth_event` e il riepilogo `authentication` della persona. |
| `auth.login_failed` | Autenticazione | Un tentativo di accesso non riesce. `vendor_data` è `null` quando il tentativo non indica una persona nota. Include `auth_event` e il riepilogo `authentication` della persona. |
| `auth.password_reset_requested` | Autenticazione | Una persona richiede il ripristino della password. Include `auth_event` e il riepilogo `authentication` della persona. |
| `auth.password_reset_completed` | Autenticazione | Una persona completa il ripristino della password. Include `auth_event` e il riepilogo `authentication` della persona. |
| `auth.mfa_changed` | Autenticazione | Una persona aggiunge o modifica un autenticatore. Include `auth_event` e il riepilogo `authentication` della persona. |
| `auth.new_device` | Autenticazione | Una persona accede da un dispositivo che non aveva mai usato prima. Include `auth_event` e il riepilogo `authentication` della persona. |
| `auth.account_locked` | Autenticazione | Una politica di sicurezza blocca l'account, oppure il tuo team blocca la persona. Include `auth_event` e il riepilogo `authentication` della persona. |I valori di questa tabella sono l'insieme completo degli eventi a cui è possibile iscriversi: non esistono `session.status.updated`, `kyc.completed` o simili. L'unica altra consegna effettuata da Didit è la [callback `kyb.registry_search.resolved` non firmata](#callback-della-ricerca-nel-registro-kyb), che viene inviata alla `webhook_url` indicata per ogni richiesta in [`POST /v3/kyb/search/`](https://docs.didit.me/it/standalone-apis/kyb-registry), mai a una destinazione webhook.> [!TIP]
>
> Il menu Business Console > API & Webhooks > **Try Webhook** invia payload completi per ciascuno di questi eventi (approvati, rifiutati, in revisione, nomi Unicode, KYC/KYB, entità, attività e transazione). Usalo per verificare il tuo endpoint prima di andare in produzione.## Struttura del payload### Struttura generaleTutti i webhook condividono la stessa struttura. I campi vengono sempre serializzati con `sort_keys=True` e separatori compatti (`,` / `:`), così lo stesso payload produce la stessa firma su entrambi i lati.| Campo | Tipo | Note |
| - | - | - |
| `event_id` | `uuid` | Identificatore stabile dell'evento. Lo **stesso** `event_id` viene inviato a ogni destinazione a cui l'evento viene distribuito e viene riutilizzato nei nuovi tentativi: usalo come chiave per l'idempotenza. |
| `webhook_type` | string | Uno dei [tipi di evento](#tipi-di-evento) sopra indicati. |
| `timestamp` | int (secondi Unix) | Momento in cui Didit ha inviato il webhook. Viene usato dalla verifica della firma. Aggiornato a ogni nuovo tentativo. |
| `created_at` | int (secondi Unix) | Momento in cui il record sottostante è stato aggiornato per l'ultima volta. |
| `application_id` | `uuid` | L'applicazione proprietaria della destinazione. |
| `environment` | `"live"` \| `"sandbox"` | Indica se l'evento proviene da una sessione o da un'applicazione di produzione o sandbox. |
| `status` | string | Presente in ogni evento di sessione, entità e transazione (incluso `*.data.updated`); gli eventi `investigator.*` non contengono `status` (vedi il payload dell'Investigator più avanti). Gli eventi di sessione usano le esatte [etichette di stato della verifica](https://docs.didit.me/it/integration/verification-statuses.md) (ad esempio `"Approved"`, `"Kyc Expired"`); gli eventi di entità usano `ACTIVE` / `FLAGGED` / `BLOCKED`; gli eventi di transazione usano `APPROVED` / `IN_REVIEW` / `DECLINED` / `AWAITING_USER`. |Per gli eventi di **sessione** (`status.updated`, `data.updated` su sessioni regolari e aziendali) la struttura contiene inoltre:| Campo | Tipo | Note |
| - | - | - |
| `session_id` | `uuid` | L'UUID della sessione di verifica utente o della sessione aziendale. |
| `business_session_id` | `uuid` | Solo per KYB. Uguale a `session_id`. |
| `session_kind` | `"business"` | Solo per gli eventi KYB; permette a un gestore generico di distinguere i casi. |
| `workflow_id` | `uuid` | Workflow usato per creare la sessione, quando impostato. |
| `workflow_version` | int | Versione del workflow. |
| `vendor_data` | string | Il tuo identificatore interno, se fornito alla creazione della sessione. |
| `vendor_business_id` | string | Solo per le sessioni KYB, se fornito. |
| `sandbox_scenario` | string \| null | Presente in ogni evento di sessione (KYC e KYB). Lo slug dello [scenario sandbox](https://docs.didit.me/it/integration/sandbox-testing.md) attivato, ad esempio `"decline_aml_hit"`, su una sessione sandbox con uno scenario attivo; `null` su una sessione di produzione, oppure su una sessione sandbox senza scenario attivo. Non presente nei webhook di entità o di transazione. |
| `metadata` | object | I metadati allegati alla creazione della sessione, senza le chiavi interne di deduplica. |
| `trigger` | string | Facoltativo. Motivo per cui il webhook è stato inviato quando il flusso di verifica non lo ha prodotto: `"manual_review"` (modifiche del revisore dalla console), `"manual_step_update"` (un revisore ha cambiato lo stato di una funzionalità), `"ongoing_monitoring"` (aggiornamento del monitoraggio AML continuo), `"webhook_reconciliation"` (un `status.updated` inviato di nuovo perché il primo webhook terminale non era stato creato; quindi potresti ricevere un secondo evento per una decisione che hai già ricevuto: deduplica su `session_id` e `status`), `"manual_webhook_recovery"` (un webhook recuperato manualmente), `"kyb_registry_change"` (il registro delle imprese ha segnalato una modifica), `"console_registry_edit"`, `"console_key_people_skip"` e `"console_key_people_regenerate"` (modifiche effettuate nella Console su una sessione aziendale), e `"biometric_retention"` (un `data.updated` inviato quando i dati biometrici in tempo reale della sessione vengono eliminati secondo la pianificazione). L'insieme può crescere, quindi tratta un valore sconosciuto come informativo. |
| `decision` | object | Presente quando `status` è `Approved`, `Declined`, `In Review` o `Abandoned`. Riflette la [decisione di sessione V3](https://docs.didit.me/it/sessions-api/retrieve-session.md). |
| `resubmit_info` | `{ nodes_to_resubmit, reasons }` | Presente quando `status` è `Resubmitted` (senza `decision`). |
| `decided_by` | string | Solo su `status.updated` quando lo stato è `Approved`, `Declined` o `Resubmitted` e l'applicazione usa l'Investigator. `"investigator"` quando l'Investigator ha preso la decisione da solo, `"human"` quando un utente della Console l'ha presa (inclusa l'accettazione del report dell'Investigator). Omesso quando nessun revisore ha registrato la decisione, quando la decisione è stata presa tramite una chiave API o quando l'Investigator non è in uso. |### Esempio della struttura di sessione```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'oggetto `decision` ha lo stesso schema restituito da [`GET /v3/session/{id}/decision/`](https://docs.didit.me/it/sessions-api/retrieve-session). Per il riferimento canonico dei campi di ogni funzionalità (ogni proprietà di `id_verifications[]`, `aml_screenings[]` e così via) vedi [Modelli di dati](https://docs.didit.me/it/reference/data-models.md). Non duplichiamo volutamente qui le tabelle dei campi per funzionalità, così lo schema esiste in un solo punto.### Array plurali V3 (importante)All'interno di `decision`, ogni risultato per funzionalità è un **array plurale**, così un singolo workflow può includere più istanze della stessa funzionalità (ad esempio più controlli di identità su diversi documenti):```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[]
```Ogni elemento contiene un `node_id` che corrisponde al grafo del workflow, oltre a un `status` per funzionalità. **Non** leggere i campi singolari della V2 (`id_verification`, `nfc`, `liveness`, `face_match`, `phone`, `email`, `poa`, `aml`, `ip_analysis`, `database_validation`): compaiono solo se una destinazione è esplicitamente configurata con `webhook_version: "v2"`.I risultati della **firma elettronica** si trovano in `signatures[]`, presenti solo se il workflow include un passaggio di firma elettronica: un elemento per firmatario, con il firmatario, la conferma del codice, il certificato, il timestamp, gli hash e i percorsi di download di ogni documento, e il PDF delle prove. Non esiste un evento di firma separato: `status.updated` indica quando leggerli. Vedi il [report della firma elettronica](https://docs.didit.me/it/core-technology/electronic-signature/report-electronic-signature.md).I risultati della **Verifica bancaria** si trovano in `bank_verifications[]`, un elemento per ogni passaggio di Verifica bancaria. Il `method` di ogni elemento indica come è stato verificato il conto: `open_banking` (accesso alla banca), `account_lookup` (controllo del conto), oppure un metodo di ripiego basato su `statement` o `microdeposits`; è `null` finché non viene valutato un conto. Quando la persona sceglie **Continue without your bank**, `continued_without_bank` è `true` e `continuation` indica il motivo. In quel caso `method` è `none`, a meno che in precedenza non sia stato valutato un tentativo: vedi [Quando la persona non può verificare il proprio conto bancario](https://docs.didit.me/it/core-technology/bank-verification/fallbacks.md#leggilo-nel-risultato). Un conto verificato tramite controllo del conto non legge dati finanziari, quindi `financial_data.not_collected` elenca i saldi, le transazioni o i redditi richiesti dal passaggio. Vedi [Metodi di verifica bancaria](https://docs.didit.me/it/core-technology/bank-verification/verification-methods.md#leggi-il-metodo-nel-risultato).### Blocco InvestigatorUna `decision` V3 per una sessione utente o aziendale include anche `investigator`: l'ultima revisione della sessione da parte di [Investigator](https://docs.didit.me/it/console/investigator.md), oppure `null` se la sessione non ne ha mai avuta una.| Campo | Tipo | Significato |
| - | - | - |
| `run_id` | uuid | La revisione. Leggi il report completo su `GET /v3/investigator/runs/{run_id}/`. |
| `status` | string | `running`, `completed` o `failed`. |
| `stage` | string | `shadow` o `live`, quando la revisione è stata eseguita. |
| `queue` | string | `kyc`, `kyb`, `aml`, `transactions` o `cases`. |
| `is_open` | boolean | La revisione è ancora in attesa di una decisione. |
| `recommendation` | string \| null | `approve`, `decline`, `escalate` o `request`. |
| `recommendation_detail` | string \| null | Una riga sull'azione consigliata. |
| `confidence` | string \| null | `high`, `moderate` o `low`. |
| `summary` | string \| null | Il riepilogo del report. |
| `ready_to_accept` | boolean | Il report può essere accettato così com'è. |
| `prepared_at` | datetime \| null | Quando il report era pronto. |
| `automated` | boolean | Investigator ha deciso autonomamente, anche se qualcuno l'ha poi modificata. |
| `decided_by` | string \| null | `investigator` finché la sua decisione è valida, `member` altrimenti, `null` finché non c'è una decisione. |
| `decision` | object \| null | Come l'elemento è uscito dalla coda: `outcome`, `action`, `final_status`, `agreed`, `decided_at`, `billed` e chi ha deciso. |Le funzionalità di raccolta documenti, **Documenti KYB** e **Document AI**, non hanno un array plurale dedicato: compaiono nell'elenco `features[]` della decisione come `{ "feature": "DOCUMENT_AI", "node_id": "…" }` (una voce per nodo). Vedi [Document AI](https://docs.didit.me/it/core-technology/document-ai/overview.md).Le decisioni di Verifica degli utenti includono anche un singolo oggetto `biometric_consent`: indica se la sessione richiedeva un consenso biometrico esplicito, il `trigger`, lo `status` della risposta e le relative evidenze. `status: "pending"` con `reason: "BIOMETRIC_CONSENT_UNSUPPORTED_SDK"` indica che un SDK nativo precedente alla versione 4.10.0 ha acquisito un volto senza il passaggio di consenso. Vedi [Consenso biometrico](https://docs.didit.me/it/getting-started/biometric-consent.md).### Chiavi del metodo di Verifica dell'identitàOgni elemento di `id_verifications[]` indica quale [metodo di Verifica dell'identità](https://docs.didit.me/it/core-technology/id-verification/verification-methods.md) lo ha prodotto, così un gestore può ramificare la logica in base alle evidenze effettivamente ricevute:| Chiave | Valore |
| - | - |
| `verification_method` | `document`, `id_lookup` o `wallet` |
| `assurance` | `documentary`, `data_match` o `cryptographic` |
| `wallet_provider` | ID del wallet del catalogo, quando a produrlo è stato un wallet, altrimenti `null` |
| `id_lookup` | Evidenze del registro, quando il metodo era `id_lookup`, altrimenti `null` |
| `wallet_verification` | Evidenze della credenziale, quando il metodo era `wallet`, altrimenti `null` |
| `fallback_from` | Perché un metodo diverso dal documento non ha concluso il passaggio di verifica dell'identità: il metodo, il motivo e se l'utente è passato all'acquisizione del documento o è stato rifiutato |I gestori esistenti continuano a funzionare senza modifiche: una sessione con documento imposta `verification_method` a `document` e lascia il resto a `null`. I tipi di ciascun campo sono in [Modelli di dati](https://docs.didit.me/it/reference/data-models.md#verifica-dell’identità).#### Sessione wallet approvata (`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"
  }
}
```### Payload degli eventi delle 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"
}
```Gli stati delle entità sono `ACTIVE`, `FLAGGED` o `BLOCKED`. `user.data.updated` e `business.data.updated` mantengono il campo `status` e aggiungono un array `changed_fields` e una mappa `changes` di `{ field: { previous, current } }`. Gli eventi `business.*` usano `vendor_business_id` al posto di `vendor_user_id`.### Payload degli eventi di transazioneI webhook delle transazioni usano la stessa infrastruttura di distribuzione e lo stesso involucro, ma identificano la transazione anziché una sessione:```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` è `null`, oppure l'azione richiesta all'utente ancora da eseguire (`type` `wallet_ownership` o `verification_session`, con il suo `url`), descritta in [Azioni richieste all'utente](https://docs.didit.me/it/transaction-monitoring/sdk-transaction-submission.md#azioni-richieste-all’utente).Le nuove transazioni hanno come valore predefinito `APPROVED`. Se una regola, una blocklist o un workflow modifica lo stato durante la creazione, il payload iniziale `transaction.created` riflette lo stato finale. `transaction.status.updated` ha la stessa struttura, con lo `status` aggiornato. Gli stati delle transazioni sono `APPROVED`, `IN_REVIEW`, `DECLINED` o `AWAITING_USER`; `severity` è `UNKNOWN` / `LOW` / `MEDIUM` / `HIGH` / `CRITICAL` e `direction` è `INBOUND` / `OUTBOUND`. `transaction.created` e `transaction.status.updated` vengono inviati **solo** per le applicazioni **in produzione**. Gli scambi Travel Rule in sandbox emettono comunque `travel_rule.status.updated` con `environment: sandbox`.### Callback della ricerca nel registro KYBIl flusso asincrono [`POST /v3/kyb/search/`](https://docs.didit.me/it/standalone-apis/kyb-registry) invia un payload extra che **non** è un evento di destinazione: quando passi un `webhook_url` alla richiesta di ricerca, Didit invia una callback `kyb.registry_search.resolved` a quell'URL non appena il registro restituisce il risultato. Si differenzia dai webhook di destinazione in tre modi:- È **non firmata**: non ci sono intestazioni `X-Signature*`. Didit la contrassegna con `X-Didit-Unsigned-Callback: true`; per validarla, confronta `request_id` con l'ID restituito dalla richiesta di ricerca.
- Il nome dell'evento è in `event_type` (non in `webhook_type`) e non sono presenti `application_id` né `environment`.
- `timestamp` e `created_at` sono interi epoch, come in altri webhook, ma diversi dal `created_at` ISO dell'endpoint di ricerca.```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
}
```La callback segue la stessa [politica di nuovo tentativo](#politica-di-nuovo-tentativo) dei webhook di destinazione: i nuovi tentativi aggiornano `timestamp` e restano non firmati. Per lo schema completo della lista dei candidati, vedi [KYB Registry API](https://docs.didit.me/it/standalone-apis/kyb-registry.md).## Verifica della firmaOgni webhook è firmato con HMAC-SHA256 usando `secret_shared_key` della destinazione. Vengono inviate tre intestazioni di firma, così puoi scegliere quella più adatta al tuo stack:| Intestazione | Cosa viene firmato | Sicuro per il middleware | Quando usarla |
| - | - | - | - |
| `X-Signature-V2` | JSON compatto, con chiavi ordinate e Unicode preservato (`ensure_ascii=False`) | Sì | **Consigliato**: funziona anche se il framework ricodifica il body. |
| `X-Signature` | Byte esatti della richiesta così come Didit li ha trasmessi (`sort_keys`, `ensure_ascii=True`) | No | Solo quando puoi leggere il raw body prima che qualsiasi parser lo elabori. |
| `X-Signature-Simple` **(deprecato)** | `"{timestamp}:{session_id}:{status}:{webhook_type}"` | Sì | Ultima risorsa di riserva, che autentica **solo l'involucro**: **non** autentica `decision`. Leggi l'avviso di deprecazione qui sotto prima di usarla. |Intestazioni che Didit invia sempre:| Intestazione | Esempio | Significato |
| - | - | - |
| `Content-Type` | `application/json` | Il body è JSON. |
| `User-Agent` | `DiditWebhook/2.0 +https://didit.me` | Identifica il worker dei webhook di Didit. |
| `X-Timestamp` | `1774970000` | Secondi Unix epoch in cui il webhook è stato inviato. Rifiuta se `abs(now - X-Timestamp) > 300`. |
| `X-Signature` / `X-Signature-V2` / `X-Signature-Simple` | digest esadecimali | HMAC-SHA256 del body o della stringa canonica descritta sopra. |Per 24 ore dopo la rotazione del segreto di firma di una destinazione, le consegne includono anche `X-Signature-Previous`, `X-Signature-V2-Previous` e `X-Signature-Simple-Previous` (vedi [Rotazione del segreto di firma](#rotazione-del-segreto-di-firma)).### Rotazione del segreto di firmaRuoti il segreto di firma di una destinazione nella Business Console.
Per 24 ore dopo la rotazione, ogni consegna a quella destinazione include altre tre intestazioni, generate con il segreto sostituito:| Intestazione | Stessa firma di | Firmato con |
| - | - | - |
| `X-Signature-V2-Previous` | `X-Signature-V2` | Il segreto sostituito |
| `X-Signature-Previous` | `X-Signature` | Il segreto sostituito |
| `X-Signature-Simple-Previous` | `X-Signature-Simple` (deprecato) | Il segreto sostituito |Un handler che accetta una corrispondenza su un'intestazione di firma o sulla corrispondente intestazione `-Previous` continua a verificare mentre distribuisci il nuovo segreto: il vecchio segreto corrisponde all'intestazione `-Previous`, il nuovo a quella regolare.
Gli esempi di codice qui sotto fanno proprio questo.
Distribuisci il nuovo segreto entro 24 ore, perché dopo questo periodo solo il nuovo segreto consente la verifica.
`previous_secret_expires_at` su [`GET /v3/webhook/destinations/{destination_uuid}/`](https://docs.didit.me/it/management-api/webhook/get-destination) indica quando terminano le 24 ore.I nuovi tentativi e i reinvii vengono firmati di nuovo con il segreto corrente della destinazione al momento dell'invio, e includono le intestazioni `-Previous` solo finché durano le 24 ore.
Se ruoti di nuovo entro le 24 ore, il segreto più vecchio viene ritirato subito: attraverso le intestazioni `-Previous` continua a essere verificato solo il segreto che era corrente fino alla nuova rotazione, per 24 ore a partire dalla nuova rotazione.Le intestazioni `-Previous` esistono solo per le destinazioni webhook.
Il `secret_shared_key` a livello di applicazione, che firma il `webhook_url` legacy, non prevede alcun periodo di sovrapposizione: ruotarlo con `rotate_secret_key` su [`PATCH /v3/webhook/`](https://docs.didit.me/it/sessions-api/management-api#configurazione-webhook) invalida immediatamente il vecchio segreto.### `X-Signature-Simple` è deprecato> [!WARNING]
>
> **`X-Signature-Simple` autentica l'involucro, non il payload.** La sua stringa canonica copre solo `timestamp`, `session_id`, `status` e `webhook_type`. **Non** copre `decision`, né `resubmit_info`, `metadata`, `vendor_data` o `event_id`. Una richiesta in cui `decision` è stata riscritta, lasciando intatti questi quattro campi, supera comunque la verifica di `X-Signature-Simple`.
>
> La garanzia è ancora più debole per gli eventi che non hanno affatto `session_id`. Gli eventi di transazione (`transaction_id`, `txn_id`, `amount`, `score`) ed entità (`vendor_user_id`, `vendor_business_id`) firmano `"{timestamp}::{status}:{webhook_type}"`, che non identifica **a quale** transazione o entità si riferisce l'evento: due transazioni diverse segnalate con lo stesso status nello stesso secondo hanno la stessa `X-Signature-Simple`.
> Anche gli eventi `travel_rule.status.updated`, `workflow.compliance_changed` e `transaction_rule_suggestions.created` non hanno `status`, quindi firmano `"{timestamp}:::{webhook_type}"`, per esempio `"{timestamp}:::travel_rule.status.updated"`.
>
> Se `X-Signature-Simple` è l'unica intestazione che controlli, considera non attendibile tutto ciò che sta al di fuori di questi quattro campi e recupera di nuovo il record dall'API: [`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/it/sessions-api/retrieve-session) per una sessione, [`GET /v3/transactions/{transaction_id}/`](https://docs.didit.me/it/management-api/transactions/get) per una transazione. L'API è il server di riferimento.Questo è un vincolo di compatibilità, non qualcosa che possiamo correggere sul posto. La stringa canonica a quattro campi è un contratto a livello di protocollo: ogni ricevitore che verifica `X-Signature-Simple` ricalcola esattamente questi quattro campi, quindi cambiare ciò che l'intestazione firma li farebbe smettere silenziosamente di funzionare durante la verifica. Coprire il body richiede anche una forma canonica specificata del JSON che entrambe le parti riproducano byte per byte, e Didit ne fornisce già una come `X-Signature-V2`. Invece di aggiungere una seconda variante che copra il body con regole di canonicalizzazione proprie, l'intestazione resta invariata e viene deprecata a favore delle varianti esistenti che coprono l'intero payload.Cosa significa per te:- **A livello di protocollo non cambia nulla.** `X-Signature-Simple` continua a essere inviato in ogni consegna, calcolato allo stesso modo. I verificatori esistenti continuano a funzionare, e annunceremo con largo anticipo qualsiasi rimozione.
- **Le nuove integrazioni devono usare `X-Signature-V2`.** È sicuro per il middleware *e* copre l'intero payload.
- **Se non puoi passare da `X-Signature-Simple`**, verificalo, poi recupera il record dall'API invece di leggerlo dal body del webhook.### Perché tre varianti?Alcuni framework web (parser del body di Express, middleware di Django, API gateway) ricodificano silenziosamente il JSON prima che il tuo handler lo legga. Se il body originale conteneva `"José"` e il middleware lo riscrive come `"José"`, i byte cambiano anche se i dati sono identici. `X-Signature` allora fallisce. `X-Signature-V2` è calcolato da una forma JSON canonica (chiavi ordinate, separatori compatti, **Unicode non escapato**) che quasi tutti i middleware riproducono in modo deterministico. La deprecata `X-Signature-Simple` ripiega sulla firma di una piccola stringa indipendente dal parser quando anche questo fallisce. Poiché firma solo quella stringa breve e mai il body, non può garantire il body.### Ordine di verifica consigliato1) Prova prima **`X-Signature-V2`**. Ricodifica `JSON.parse(body)` con le chiavi ordinate e Unicode preservato, poi calcola `HMAC-SHA256(secret, canonical)` e confronta l'intestazione con il risultato usando `timingSafeEqual`.
2) Se la V2 non corrisponde e puoi leggere i byte grezzi, prova **`X-Signature`** contro `HMAC-SHA256(secret, rawBody)`.
3) Solo se entrambe falliscono, ripiega sulla **`X-Signature-Simple`**, deprecata, contro `HMAC-SHA256(secret, "{timestamp}:{session_id}:{status}:{webhook_type}")`. Questo autentica solo l'involucro: considera il `decision` nel body come non attendibile e recuperalo di nuovo da [`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/it/sessions-api/retrieve-session) prima di agire.In ogni passaggio, quando è presente l'intestazione `-Previous` corrispondente, accetta una corrispondenza con una qualsiasi delle due intestazioni (vedi [Rotazione del segreto di firma](#rotazione-del-segreto-di-firma)).In tutti i casi, **rifiuta** ogni richiesta più vecchia di **5 minuti** (`abs(now - X-Timestamp) > 300`) per proteggerti dai replay.## Politica di nuovo tentativoSe il tuo endpoint risponde con un `5xx` o un `404`, Didit effettua fino a **2 nuovi tentativi** con backoff esponenziale:- **Primo nuovo tentativo**: circa **1 minuto** dopo il primo errore.
- **Secondo nuovo tentativo**: circa **4 minuti** dopo il primo nuovo tentativo.Dopo il secondo nuovo tentativo la consegna viene scartata. Ogni tentativo di consegna (iniziale più nuovi tentativi) viene registrato come voce separata nella Business Console, nella scheda **Consegne** della destinazione, così puoi rieseguire o ispezionare qualsiasi tentativo.Ogni tentativo nel registro di consegna di una destinazione riporta un numero `attempt`: `1` per la prima consegna di un evento, e un numero superiore di uno rispetto a quello del tentativo ripetuto, per ogni nuovo tentativo o reinvio.
I nuovi tentativi automatici vanno all'URL a cui è stato inviato il primo tentativo, anche se nel frattempo hai modificato `url` della destinazione.
Un reinvio manuale va all'URL attuale della destinazione, quindi correggere l'URL corregge anche il reinvio.Altre regole di consegna da conoscere:- La richiesta HTTP in uscita scade dopo **5 secondi**.
- Didit blocca le consegne verso URL privati o localhost come protezione SSRF e non segue mai i redirect. Usa un endpoint HTTPS pubblico che risponda direttamente.
- Un `url` di destinazione che contiene una barra inversa grezza, un carattere di controllo o più di una `@` nell'authority viene rifiutato come malformato: un URL del genere non può essere creato e uno esistente non può essere riconsegnato o rieseguito. Usa un URL semplice `https://host/path` con al massimo una sezione userinfo (per URL di webhook in stile basic auth).
- `2xx` è considerato un successo. Timeout ed errori di connessione sono ritentabili (compaiono come `504`/`503` nel registro di consegna); le risposte `3xx` e `4xx` diverse da `404` **non** vengono ritentate.
- Al nuovo tentativo Didit ricalcola le firme con un nuovo `X-Timestamp`, così la coppia timestamp/firma è sempre coerente.
- I nuovi tentativi e i reinvii sono firmati con il segreto di firma corrente della destinazione nel momento in cui vengono inviati.### Ordine di consegnaDidit non garantisce l'ordine in cui i webhook ti arrivano.
Ogni consegna viene accodata separatamente e un nuovo tentativo arriva minuti dopo il primo tentativo, quindi un `status.updated` per `Approved` può arrivarti prima di uno precedente per `In Progress`.Progetta il tuo ricevitore tenendo conto di questo:- Tratta un webhook come un segnale che qualcosa è cambiato. Quando l'ordine conta, chiama [`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/it/sessions-api/retrieve-session) e agisci in base allo `status` attuale.
- Non lasciare mai che un evento più vecchio sovrascriva uno stato più recente: confronta il `created_at` dell'evento con l'ultimo che hai memorizzato per quel record e ignora quello più vecchio.
- Usa `event_id` per riconoscere le ripetizioni, perché resta invariato tra i nuovi tentativi.Didit accoda gli eventi di un trasferimento Travel Rule in un ordine prestabilito, descritto in [Travel Rule exchange status changed](#stato-dello-scambio-travel-rule-modificato-travel_rule-status-updated), ma un nuovo tentativo può comunque cambiare l'ordine in cui arrivano.## Configurare una destinazionePuoi gestire le destinazioni dalla **Business Console > API & Webhooks** oppure tramite la Management API.**1. Crea la destinazione**Chiama [`POST /v3/webhook/destinations/`](https://docs.didit.me/it/management-api/webhook/create-destination) (oppure fai clic su **Aggiungi destinazione** nella console) con una `label`, un `url` pubblico, una `webhook_version` (`"v3"`) e un array `subscribed_events` che contiene ogni famiglia di eventi che questo endpoint deve ricevere. È richiesto almeno un evento e non esiste alcun wildcard.**2. Conserva il secret\_shared\_key**La risposta della creazione restituisce il `secret_shared_key` della destinazione. Conservalo insieme agli altri segreti del server: appartiene solo a questa destinazione ed è l'input di tutte e tre le varianti di firma HMAC-SHA256. [`GET /v3/webhook/destinations/{destination_uuid}/`](https://docs.didit.me/it/management-api/webhook/get-destination) lo restituisce di nuovo ai chiamanti con chiave API e agli utenti della console che possono leggere i webhook.**3. Implementa l'endpoint**Esponi una route `POST` HTTPS pubblica, leggi il body grezzo, verifica la firma (preferendo `X-Signature-V2`), controlla la finestra temporale del timestamp, poi smista in base a `webhook_type`. Restituisci `2xx` appena hai accodato il lavoro: esegui le elaborazioni intensive in modo asincrono.**4. Verifica le ricezioni**Controlla la scheda **Consegne** della destinazione. I webhook funzionanti restituiscono `200` entro uno o due secondi. `5xx`/`404` attiva la politica di nuovo tentativo di Didit; le consegne che continuano a fallire vengono scartate dopo il secondo nuovo tentativo.**5. Itera con Try Webhook**Usa gli scenari [**Try Webhook**](#test) della console per far passare il tuo endpoint attraverso payload approvati, rifiutati, in revisione, KYB, entità, attività e transazioni prima di attivare il traffico di produzione.![Funzionalità di test dei webhook di Didit nella 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)Invia webhook di test direttamente dalla Business Console per verificare il tuo endpoint prima di andare in produzione.Puoi creare tutte le destinazioni che ti servono. Uno schema comune è **una destinazione per consumatore**: ad esempio eventi KYC verso il tuo servizio di autenticazione, eventi KYB verso il tuo servizio operativo di compliance, transazioni verso la tua coda antifrode. Ogni destinazione ha il proprio segreto, la propria versione e il proprio elenco di sottoscrizioni, quindi un segreto ruotato o un deploy difettoso su un consumatore non influisce sugli altri.## Esempi di codicePer garantire la sicurezza del tuo endpoint webhook, verifica l'autenticità delle richieste in arrivo usando il `secret_shared_key` della destinazione.> [!WARNING]
>
> **Usa `X-Signature-V2`.** È la variante più resistente alla ricodifica da parte dei middleware e autentica completamente il body (incluso `decision`).### Node.js / Express (consigliato: X-Signature-V2)```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}`));
```Se puoi leggere i byte grezzi prima che qualsiasi parser li tocchi, la variante originale `X-Signature` funziona allo stesso modo, ma firma il body grezzo. La manteniamo nella sezione legacy qui sotto per completezza.### Python / FastAPI (consigliato: X-Signature-V2)```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 (consigliato: X-Signature-V2)I valori predefiniti di JSON in PHP **non** riproducono la forma canonica. Quattro di essi vanno sovrascritti, e se ne manca anche solo uno cambiano i byte firmati, quindi il digest non corrisponde mai:| Valore predefinito PHP | Cosa produce | Cosa serve |
| - | - | - |
| `json_decode($raw, true)` (associativo) | gli oggetti vuoti `{}` diventano `[]` | decodifica con `$associative = false` così gli oggetti JSON restano `stdClass` |
| `json_encode` esegue l'escape delle barre | `https:\/\/media.didit.me` | `JSON_UNESCAPED_SLASHES` |
| `json_encode` esegue l'escape dei caratteri non ASCII | `Jos\u00e9` invece di `José` | `JSON_UNESCAPED_UNICODE` |
| `ksort` confronta le chiavi dall'aspetto numerico come numeri | `"9"` viene prima di `"10"` | `ksort($props, SORT_STRING)` |La trappola degli oggetti vuoti è quella che fa più danni. I payload di Didit contengono regolarmente mappe vuote come `risk_view.countries.risk_scores`, `risk_view.crimes.risk_scores` e `risk_view.custom_list`, quindi un round-trip associativo rompe la firma già nelle sessioni approvate ordinarie, e non solo in qualche caso raro.```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]);
    }
}
```Ci sono altre due cose da sapere:- **I float con valore intero non richiedono alcuna gestione speciale in PHP.** Didit li serializza come interi (`100.0` → `100`), e `json_encode` di PHP scrive un float intero allo stesso modo, quindi non serve alcun passaggio `shortenFloats`, a differenza dell'esempio Node.js sopra.
- **Mantieni `serialize_precision` a `-1`** (il valore predefinito da PHP 7.1). Se il tuo host lo imposta a `17`, `json_encode` scrive `87.42` come `87.420000000000002` e ogni verifica di `X-Signature-V2` fallisce.Se non riesci a far coincidere la forma canonica, `verifySignatureRaw` qui sopra è l'alternativa più semplice: `$request->getContent()` di Laravel restituisce i byte originali, quindi `X-Signature` funziona finché nulla davanti a PHP ricodifica il body. È esattamente il caso per cui `X-Signature-V2` è progettato, ed è per questo che V2 viene provato per primo.## Test**Non** serve eseguire una verifica completa per testare il tuo endpoint.- Da **Business Console > API & Webhooks > Try Webhook**, scegli uno scenario (ad esempio `approved_full_features`, `declined_face_mismatch`, `in_review_aml_hit`, `business_session_approved`, `transaction_created`, `activity_created`, `user_status_updated`) e invia un webhook completo all'URL della tua destinazione.
- Ogni scenario è firmato con le stesse tre intestazioni di firma e la stessa codifica canonica del traffico di produzione, inclusi dati di test Unicode (`approved_kyc_with_unicode`), così puoi verificare che `X-Signature-V2` funzioni attraverso il tuo middleware. Le consegne di test contengono dati di esempio e sono contrassegnate con un'intestazione aggiuntiva `X-Didit-Test-Webhook: true` (e con il suffisso `(Test)` su `User-Agent`), così puoi evitare che incidano sullo stato di produzione.
- Riproduci qualsiasi consegna storica dalla scheda **Consegne** di una destinazione se devi fare il debug di una regressione.Per i test smoke end-to-end, esegui una verifica reale tramite un workflow di test nella tua applicazione: i webhook risultanti usano la stessa struttura, le stesse intestazioni e la stessa politica di nuovi tentativi degli scenari **Try Webhook**.## Esempi### Sessione KYC approvata (`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"
  }
}
```### Sessione rifiutata con avvisi (`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": []
  }
}
```### Sessione aziendale approvata (`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": []
  }
}
```### Transazione creata (`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
}
```### Stato dello scambio Travel Rule modificato (`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` assume uno dei 13 stati di scambio documentati in [Travel Rule](https://docs.didit.me/it/transaction-monitoring/travel-rule.md#ciclo-di-vita).
`rail` è `INTERNAL`, `TRP`, `GTR`, `TRUST`, `VERIFYVASP`, `SYGNA` o `EMAIL` (stringa vuota finché non viene selezionato un canale, se non è stato possibile determinarne nessuno, e sempre per le registrazioni sunrise), oppure `TEST_VASP` per un trasferimento sandbox a cui ha risposto il [Didit Test VASP](https://docs.didit.me/it/transaction-monitoring/travel-rule-sandbox.md), e `direction` è `INBOUND` o `OUTBOUND`.
Un evento sandbox contiene `"environment": "sandbox"`, e un evento `TEST_VASP` contiene anche `test_vasp: true` e `test_vasp_scenario` (lo scenario che ha risposto).Quando Didit crea una transazione per un trasferimento Travel Rule (una chiamata [`POST /v3/transactions/`](https://docs.didit.me/it/management-api/transactions/create), una [registrazione sunrise dopo il deposito](https://docs.didit.me/it/transaction-monitoring/travel-rule.md#registrazione-postuma-del-deposito-sunrise), una richiesta `TRP` in entrata o la parte in entrata sul canale `INTERNAL`), invia prima l'evento `transaction.created` per quella transazione e poi gli eventi `travel_rule.status.updated` del trasferimento.
Una regola che modifica lo stato della transazione durante questo passaggio può anche inviare `transaction.status.updated` prima di `transaction.created`, e una registrazione sunrise collegata a una transazione di deposito già creata non invia `transaction.created`.
Ogni webhook viene consegnato, e in caso di errore sottoposto a un nuovo tentativo, in modo indipendente, quindi un evento sottoposto a un nuovo tentativo può arrivare dopo uno successivo: usa `transaction_id` come chiave nel tuo handler e rileggi la transazione quando ti serve lo stato attuale.
Un trasferimento inviato tramite TRUST non riceve mai un evento `COMPLETED`, perché TRUST non restituisce nulla che Didit possa leggere: resta in `AWAITING_COUNTERPARTY` fino alla scadenza della conferma e poi termina come `EXPIRED`. Con GTR, VerifyVASP e Sygna Bridge, Didit legge dalla rete la risposta della controparte e l'evento la segue (vedi [Reti Travel Rule](https://docs.didit.me/it/transaction-monitoring/travel-rule-networks.md)).### Stato di conformità del workflow modificato (`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` e `transition.to` sono uno dei seguenti valori: `compliant`, `gaps_detected`, `pending_change`, `unknown_coverage` o `not_evaluated`.
`severity` è `actionable` quando sono state rilevate lacune, `indicative` per una modifica in attesa e `informative` quando le lacune sono state risolte.
Didit invia un evento per ogni workflow che è cambiato.### Suggerimenti di regole per le transazioni creati (`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` è `ADD` per una nuova regola, `TWEAK` per la modifica di una regola esistente e `DISABLE` per disattivarne una. `rule_id` è `null` per `ADD`.
`open_count` è il numero di suggerimenti ancora aperti dopo l'esecuzione, inclusi quelli nuovi.
Le impostazioni dei suggerimenti sulle regole attivano o disattivano questo webhook; vedi [Webhook delle transazioni](https://docs.didit.me/it/transaction-monitoring/webhooks.md).### Eventi dell'Investigator (`investigator.*`)I dodici eventi `investigator.*` usano la stessa struttura comune (`event_id`, `webhook_type`, `timestamp`, `created_at`, `application_id`, `environment`) e gli stessi header di firma di ogni altro evento. Contengono solo id e cifre: mai un nome, un motivo digitato da un revisore, una narrazione o quanto scritto dall'utente finale. Non hanno `session_id`; l'elemento a cui si riferiscono è indicato in `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
}
```| Evento | Campi oltre alla struttura comune |
| - | - |
| `investigator.report.ready` | `run_id`, `queue`, `item`, `recommendation`, `confidence`, `provenance`, `ready_to_accept`, `questions` (conteggio) |
| `investigator.decision.automated` | `run_id`, `queue`, `item`, `recommendation`, `confidence`, `provenance`, `decided_by` (`"investigator"`), `outcome`, `action`, `final_status`, `agreed` |
| `investigator.decision.overridden` | Come `investigator.decision.automated`, con `decided_by` `"human"`, più `override_category`, `overrode` (`"report"` o `"automated"`) e, quando `overrode` è `"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"` o `"spend_limit"`); per `"spend_limit"` anche `month` (`YYYY-MM`) e `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` (codici di rischio), `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` indica quale versione dell'Investigator ha scritto il report:- `engine` è la versione del motore dell'Investigator: un identificativo opaco di otto caratteri. Cambia ogni volta che cambia il motore, quindi due report con lo stesso `engine` sono stati scritti dallo stesso motore.
- `agent_version` è la versione dell'agente.
- `policy_version` è la versione della tua policy che il report ha seguito.
- `model_id` è un alias obsoleto che contiene lo stesso valore di `engine`. Leggi `engine` al suo posto.
- `prompt_hash` non viene più inviato.`item.kind` indica il tipo di elemento (una sessione, una sessione aziendale, una transazione o un caso) e `item.id` il suo id. I timestamp che terminano con `_at` sono stringhe ISO 8601. Per iscriverti a questi eventi, elenca le stringhe esatte in `subscribed_events`, come per qualsiasi altro evento.### Stato dell'entità utente modificato (`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"
}
```### Sessione reinviata```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" }
  }
}
```## Legacy: X-Signature con corpo grezzo`X-Signature` viene ancora inviata con ogni consegna. È un HMAC-SHA256 calcolato sui **byte esatti** che Didit trasmette (`json.dumps(..., sort_keys=True, separators=(",", ":"))`). Usala solo quando puoi leggere il corpo grezzo prima che qualsiasi middleware lo modifichi.> [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}
> ```## Legacy: formato webhook V2> [!WARNING]
>
> I campi seguenti si applicano solo quando una destinazione è fissata a `webhook_version: "v2"`. Esiste anche un'impostazione ancora più vecchia, `"v1"` (una terza forma di decisione, al singolare), per le destinazioni create prima della V2: contatta il supporto prima di farvi affidamento. Le nuove integrazioni devono usare **V3** (il valore predefinito), descritta sopra, che usa array al plurale.| Aspetto | V2 | V3 |
| - | - | - |
| Intestazioni di firma | `X-Signature`, `X-Signature-V2`, `X-Signature-Simple` (tutte e tre vengono inviate indipendentemente dalla versione; Simple è [deprecata](#x-signature-simple-è-deprecato)) | Uguale |
| Nomi dei campi di decisione | Oggetti singolari (`id_verification`, `liveness`, `face_match`, ...) | Array al plurale (`id_verifications`, `liveness_checks`, `face_matches`, ...) |
| Più istanze per funzionalità | Non supportato | Supportato tramite array per funzionalità con `node_id` |
| Punteggi AML | `aml.score`, `aml.hits[].match_score`, `aml.hits[].risk_score` | Gli stessi valori in `aml_screenings[]` |Gli oggetti delle singole funzionalità sono gli stessi nelle due versioni: una destinazione V2 riceve esattamente gli stessi `match_score`, `risk_score`, `score_breakdown`, `risk_view` e `review_status` per ogni riscontro, oltre al `score` aggregato che riceve V3 (vedi [report AML](https://docs.didit.me/it/core-technology/aml-screening/report-aml-screening.md#tre-punteggi-non-un-solo-verdetto)). Impostare la destinazione sulla V2 cambia la posizione del report AML all'interno di `decision`, non il suo contenuto.Se un consumatore a valle richiede ancora le strutture V2, crea una destinazione separata fissata a `webhook_version: "v2"` e mantieni i nuovi consumatori su V3.## Correlati- [API delle destinazioni webhook](https://docs.didit.me/it/management-api/webhook/list-destinations.md): elenca, crea, aggiorna ed elimina destinazioni e consulta i log di consegna.
- [Crea destinazione webhook](https://docs.didit.me/it/management-api/webhook/create-destination.md): schema di richiesta e risposta, incluso il `secret_shared_key` della destinazione.
- [Modelli dati](https://docs.didit.me/it/reference/data-models.md): schema canonico di ogni oggetto delle singole funzionalità incluso in `decision`.
- [Schema della decisione](https://docs.didit.me/it/sessions-api/retrieve-session.md): elenco completo dei campi dell'oggetto `decision` incluso nei webhook di sessione.
- [Stati di verifica](https://docs.didit.me/it/integration/verification-statuses.md): valori esatti di `status` e relative transizioni citate sopra.
- [Limiti di frequenza](https://docs.didit.me/it/integration/rate-limiting.md): limiti che si applicano quando interroghi `GET /v3/session/{id}/decision/` come soluzione di ripiego.


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