> ## 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 i KYB en temps real amb signatura HMAC

> Rep esdeveniments de verificació, entitats i transaccions de Didit en temps real. Signatura HMAC-SHA256 i reintents limitats.

> [!NOTE]
>
> El lliurament de webhooks, que depèn de la versió, es documenta a [Esdeveniments d'autenticació i webhooks](https://docs.didit.me/ca/authentication/events-and-webhooks.md). Activitat d'autenticació: no registris tipus d'esdeveniment d'autenticació que no hi siguin inclosos amb l'API de destinacions actual.

Webhook Integration Prompt
        Copia el prompt al porta-retalls



      
      

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



      
      

Enganxa a

## Visió generalEls webhooks són la manera com Didit envia **actualitzacions en temps real** al teu backend cada cop que canvia l'estat d'una sessió de verificació, d'una sessió d'empresa, d'un usuari del proveïdor, d'una empresa del proveïdor o d'una transacció. Si subscrius una o diverses destinacions als esdeveniments que t'interessen, pots reaccionar en el moment en què el resultat està a punt, normalment en pocs segons després que l'usuari que fa la verificació acabi el seu flux.Els webhooks són el patró d'integració **recomanat**. El sondeig de [`GET /v3/session/{id}/decision/`](https://docs.didit.me/ca/sessions-api/retrieve-session) està disponible com a alternativa, però és més lent, fa més peticions i no rep esdeveniments com `data.updated`, `transaction.status.updated` ni els canvis a nivell d'entitat, que només s'emeten amb webhooks. Considera el lliurament de webhooks com la font de veritat i només fes sondejos en arrencar en fred o per a la conciliació.Aquest capítol de la lliçó d'integració de Didit Academy comença a la visió general dels webhooks.La lliçó d'integració d'Academy tracta els webhooks a partir de 3:12: com afegir una destinació, com triar els esdeveniments i el secret de signatura que verificaràs més avall.[Visió general dels webhooks: Didit Academy](https://www.youtube.com/embed/fX03-WEu_EI?start=192\&rel=0\&playsinline=1)> [!NOTE]
>
> **Usuaris de Cloudflare / WAF.** Didit lliura els webhooks des de la IP estàtica `18.203.201.92` amb `User-Agent: DiditWebhook/2.0 +https://didit.me`. Si la teva capa perimetral bloqueja clients desconeguts, permet aquesta IP a **Security > WAF > Tools > IP Access Rules** i tria **Permetre** per al nom d'amfitrió receptor.## Tipus d'esdevenimentsUtilitza el valor exacte en la matriu `subscribed_events` d'una destinació. No hi ha comodí: enumera cada família que vols rebre, i pots repartir els esdeveniments entre diverses destinacions.| Esdeveniment | Recurs | Quan s'activa |
| - | - | - |
| `status.updated` | Sessió KYC o sessió d'empresa KYB | Canvia l'estat d'una sessió (No iniciada → En curs → Aprovada / Rebutjada / En revisió / Abandonada / Reenviada). Les càrregues KYB inclouen `session_kind: "business"` i `business_session_id`. |
| `data.updated` | Sessió KYC o sessió d'empresa KYB | Es modifiquen les dades de verificació després de crear-les (correccions manuals del revisor en KYC, POA, NFC, el registre KYB i els documents KYB, etc.). |
| `user.status.updated` | Entitat d'usuari | Canvia l'estat d'un usuari consolidat entre `ACTIVE`, `FLAGGED` i `BLOCKED` (acció de la consola, acció de regla, llista de bloqueig). Inclou `previous_status`. |
| `user.data.updated` | Entitat d'usuari | Canvien el perfil, els comptadors, les metadades o els identificadors aprovats d'un usuari consolidat. Inclou `changed_fields` i un mapa `changes` amb els valors `previous` i `current`. |
| `business.status.updated` | Entitat d'empresa | Canvia l'estat d'una empresa consolidada entre `ACTIVE`, `FLAGGED` i `BLOCKED` (acció de la consola, acció de regla, llista de bloqueig). Inclou `previous_status`. |
| `business.data.updated` | Entitat d'empresa | Canvien el perfil, les dades de registre, els comptadors, les metadades o els camps agregats de verificació d'una empresa consolidada. Inclou `changed_fields` i `changes`. |
| `activity.created` | Cronologia d'activitat | Reservat per al canal de cronologia d'activitat. El valor s'accepta en `subscribed_events` i el cobreix el provador **Try Webhook**, però Didit no envia actualment `activity.created` en el trànsit real. |
| `transaction.created` | Monitoratge de transaccions | Es crea una transacció i el veredicte inicial de les regles està llest. Inclou `transaction_id`, `txn_id`, `status`, `score`, `severity`, `amount`, `currency`, `direction` i `action_required`. |
| `transaction.status.updated` | Monitoratge de transaccions | Canvia l'estat d'una transacció després de l'avaluació de regles, l'acció d'un analista, la remediació, les coincidències amb la llista de bloqueig, les actualitzacions del proveïdor o les accions de l'API o de la consola. També s'envia una vegada, hagi canviat l'estat o no, quan acaba un cribratge de criptomonedes que s'ha reintentat en segon pla ([hashes de transacció encara no indexats](https://docs.didit.me/ca/transaction-monitoring/aml-screening.md#hashes-de-transacció-que-encara-no-s’han-indexat)). |
| `travel_rule.status.updated` | Travel Rule | Canvia l'estat d'un intercanvi de [Travel Rule](https://docs.didit.me/ca/transaction-monitoring/travel-rule.md) - en la creació (inclosa la transició `NOT_APPLICABLE` per sota del llindar), en la confirmació de titularitat, en la resposta a la recollida per correu electrònic, en la finalització o cancel·lació, o en l'expiració del temps d'espera. S'activa per a les transferències sortints i per a les entrants: la contrapart entrant de la via `INTERNAL`, els registres sunrise i les consultes entrants `TRP`. Per a una transacció que crea Didit, aquests esdeveniments s'envien després del seu `transaction.created`. Inclou `transaction_id`, `txn_id`, `travel_rule_status`, `rail` i `direction`. |
| `workflow.compliance_changed` | Compliment del flux de treball | Canvia l'estat de compliment d'un flux de treball publicat després d'una reavaluació. Un esdeveniment per flux de treball. Porta `workflow_id`, `workflow_version`, `workflow_label`, `transition` (`from` i `to`), `severity` i `kb_version`, i no porta `status`. |
| `transaction_rule_suggestions.created` | Monitoratge de transaccions | Una execució de suggeriments de regles ha proposat nous canvis de regles i la configuració dels suggeriments de regles té activades les notificacions de webhook. Porta `run_id`, `open_count` i `suggestions[]`, i no porta `status`. |
| `investigator.report.ready` | Investigator | L'Investigator ha escrit un informe sobre un element. |
| `investigator.decision.automated` | Investigator | L'Investigator ha decidit un element per si mateix. |
| `investigator.decision.overridden` | Investigator | Una persona ha fet una acció diferent de la indicada a l'informe de l'Investigator, o ha anul·lat una decisió que aquest havia pres per si mateix. |
| `investigator.decision.contested` | Investigator | L'usuari final ha impugnat una decisió i ha demanat una revisió humana. |
| `investigator.question.raised` | Investigator | L'Investigator ha plantejat a l'equip una pregunta sobre la seva política. |
| `investigator.automation.paused` | Investigator | L'automatització ha deixat de prendre decisions perquè una persona l'ha pausada o perquè s'ha arribat al límit de despesa mensual. |
| `investigator.rfi.answered` | Investigator | L'usuari final ha respost a una sol·licitud d'informació. |
| `investigator.rfi.overdue` | Investigator | Una sol·licitud d'informació ha superat el termini sense resposta. |
| `investigator.drift.detected` | Investigator | Una cua s'ha desviat i l'automatització ha baixat un nivell. |
| `investigator.sla.breached` | Investigator | Un informe ha esperat més enllà del SLA de la seva cua sense decisió. |
| `investigator.qa.failed` | Investigator | Una decisió presa per una persona no ha superat la revisió de qualitat de l'Investigator. |
| `investigator.external.report.ready` | Investigator | L'informe sobre un element que has enviat des de fora de Didit està llest. |
| `auth.signup` | Autenticació | Una persona es registra a través de [Autenticació](https://docs.didit.me/ca/authentication/events-and-webhooks.md). Inclou `auth_event` i el resum `authentication` de la persona. |
| `auth.login` | Autenticació | Una persona inicia sessió. Inclou `auth_event` i el resum `authentication` de la persona. |
| `auth.login_failed` | Autenticació | Un intent d'inici de sessió falla. `vendor_data` és `null` quan l'intent no correspon a cap persona coneguda. Inclou `auth_event` i el resum `authentication` de la persona. |
| `auth.password_reset_requested` | Autenticació | Una persona demana restablir la contrasenya. Inclou `auth_event` i el resum `authentication` de la persona. |
| `auth.password_reset_completed` | Autenticació | Una persona completa el restabliment de la contrasenya. Inclou `auth_event` i el resum `authentication` de la persona. |
| `auth.mfa_changed` | Autenticació | Una persona afegeix o canvia un autenticador. Inclou `auth_event` i el resum `authentication` de la persona. |
| `auth.new_device` | Autenticació | Una persona inicia sessió des d'un dispositiu que no havia utilitzat abans. Inclou `auth_event` i el resum `authentication` de la persona. |
| `auth.account_locked` | Autenticació | Una política de seguretat bloqueja el compte, o el teu equip bloqueja la persona. Inclou `auth_event` i el resum `authentication` de la persona. |Els valors d'aquesta taula són el conjunt complet d'esdeveniments subscriptibles: no hi ha `session.status.updated`, `kyc.completed` ni cap altre de similar. L'única altra entrega que fa Didit és el [callback `kyb.registry_search.resolved` sense signatura](#callback-de-la-cerca-de-registre-kyb), que s'envia a la `webhook_url` específica de cada sol·licitud que passes a [`POST /v3/kyb/search/`](https://docs.didit.me/ca/standalone-apis/kyb-registry), i mai a una destinació del webhook.> [!TIP]
>
> El menú Business Console > API & Webhooks > **Try Webhook** envia payloads completament formats per a cadascun d'aquests esdeveniments (aprovats, rebutjats, en revisió, noms en Unicode, KYC/KYB, entitat, activitat i transacció). Utilitza'l per validar el teu endpoint abans de posar-lo en producció.## Forma del payload### EmbolcallTots els webhooks comparteixen el mateix embolcall. Els camps es serialitzen sempre amb `sort_keys=True` i separadors compactes (`,` / `:`), perquè el mateix payload reprodueixi la mateixa signatura en tots dos extrems.| Camp | Tipus | Descripció |
| - | - | - |
| `event_id` | `uuid` | Identificador estable de l'esdeveniment. El **mateix** `event_id` s'envia a cada destinació a la qual es distribueix l'esdeveniment i es reutilitza en els reintents: fes-lo servir com a clau d'idempotència. |
| `webhook_type` | string | Un dels [tipus d'esdeveniment](#tipus-d’esdeveniments) anteriors. |
| `timestamp` | int (segons Unix) | Moment en què Didit ha enviat el webhook. El fa servir la verificació de la signatura. S'actualitza a cada reintent. |
| `created_at` | int (segons Unix) | Moment en què es va actualitzar per última vegada el registre subjacent. |
| `application_id` | `uuid` | L'aplicació que és propietària de la destinació. |
| `environment` | `"live"` \| `"sandbox"` | Indica si l'esdeveniment procedeix d'una sessió o aplicació de producció o de sandbox. |
| `status` | string | Hi és en tots els esdeveniments de sessió, d'entitat i de transacció (inclosos els `*.data.updated`); els esdeveniments `investigator.*` no contenen `status` (consulta el payload de l'Investigator més avall). Els esdeveniments de sessió fan servir les [etiquetes exactes d'estat de verificació](https://docs.didit.me/ca/integration/verification-statuses.md) (p. ex. `"Approved"`, `"Kyc Expired"`); els esdeveniments d'entitat fan servir `ACTIVE` / `FLAGGED` / `BLOCKED`; els esdeveniments de transacció fan servir `APPROVED` / `IN_REVIEW` / `DECLINED` / `AWAITING_USER`. |Per als esdeveniments de **sessió** (`status.updated`, `data.updated` a les sessions normals i empresarials), l'embolcall inclou a més:| Camp | Tipus | Descripció |
| - | - | - |
| `session_id` | `uuid` | L'UUID de la sessió de verificació d'usuaris o de la sessió empresarial. |
| `business_session_id` | `uuid` | Només a KYB. Igual que `session_id`. |
| `session_kind` | `"business"` | Només als esdeveniments de KYB; permet que un gestor genèric distingeixi els casos. |
| `workflow_id` | `uuid` | Flux de treball fet servir per crear la sessió, quan està definit. |
| `workflow_version` | int | Versió del flux de treball. |
| `vendor_data` | string | El teu identificador intern, quan s'ha proporcionat en crear la sessió. |
| `vendor_business_id` | string | Només a les sessions de KYB, quan s'ha proporcionat. |
| `sandbox_scenario` | string \| null | A cada esdeveniment de sessió (KYC i KYB). El slug de l'escenari de [sandbox](https://docs.didit.me/ca/integration/sandbox-testing.md) activat (p. ex. `"decline_aml_hit"`) en una sessió de sandbox amb un escenari activat; `null` en una sessió en directe, o en una sessió de sandbox sense cap escenari activat. No apareix en webhooks d'entitats ni de transaccions. |
| `metadata` | object | Qualsevol metadada que hagis adjuntat en crear la sessió, sense les claus internes de deduplicació. |
| `trigger` | string | Opcional. Motiu pel qual s'ha disparat el webhook quan el flux de verificació no l'ha produït: `"manual_review"` (edicions fetes pel revisor des de la Console), `"manual_step_update"` (un revisor ha canviat l'estat d'una funcionalitat), `"ongoing_monitoring"` (actualització del monitoratge AML continu), `"webhook_reconciliation"` (un `status.updated` reenviat perquè el primer webhook terminal no es va crear mai, de manera que pots rebre un segon esdeveniment per una decisió que ja tenies: deduplica per `session_id` i `status`), `"manual_webhook_recovery"` (un webhook recuperat manualment), `"kyb_registry_change"` (el registre mercantil ha informat d'un canvi), `"console_registry_edit"`, `"console_key_people_skip"` i `"console_key_people_regenerate"` (edicions fetes a la Console en una sessió empresarial), i `"biometric_retention"` (un `data.updated` enviat quan les dades biomètriques en directe de la sessió se suprimeixen segons la programació). El conjunt pot créixer, així que tracta qualsevol valor desconegut com a informatiu. |
| `decision` | object | Hi és quan `status` és `Approved`, `Declined`, `In Review` o `Abandoned`. Reflecteix la [decisió de sessió V3](https://docs.didit.me/ca/sessions-api/retrieve-session.md). |
| `resubmit_info` | `{ nodes_to_resubmit, reasons }` | Hi és quan `status` és `Resubmitted` (sense `decision`). |
| `decided_by` | string | Només a `status.updated` quan l'estat és `Approved`, `Declined` o `Resubmitted` i l'aplicació fa servir l'Investigator. `"investigator"` quan l'Investigator ha pres la decisió per si mateix, `"human"` quan un usuari de la consola l'ha pres (inclòs en acceptar l'informe de l'Investigator). No s'inclou quan cap revisor ha registrat la decisió, quan l'ha presa una clau d'API, o quan l'Investigator no s'utilitza. |### Exemple d'embolcall de sessió```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'objecte `decision` té el mateix esquema que el retornat per [`GET /v3/session/{id}/decision/`](https://docs.didit.me/ca/sessions-api/retrieve-session). Per a la referència canònica dels camps de cada funcionalitat (cada propietat de `id_verifications[]`, `aml_screenings[]`, etc.), consulta [Models de dades](https://docs.didit.me/ca/reference/data-models.md). Deliberadament no dupliquem aquí les taules de camps de cada funcionalitat, perquè el esquema només es trobi en un sol lloc.### Matrius plurals de V3 (important)Dins de `decision`, cada resultat per funcionalitat és una matriu amb diversos elements, perquè un mateix flux de treball pugui incloure diverses instàncies de la mateixa funcionalitat (per exemple, diverses comprovacions d'identitat en diversos 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[]
```Cada element inclou un `node_id` que coincideix amb el graf del flux de treball, i un `status` per funcionalitat. **No** llegeixis els camps singulars heretats de V2 (`id_verification`, `nfc`, `liveness`, `face_match`, `phone`, `email`, `poa`, `aml`, `ip_analysis`, `database_validation`); només apareixen si una destinació està fixada explícitament a `webhook_version: "v2"`.Els resultats de la **Signatura electrònica** són a `signatures[]`, només presents quan el flux de treball té un pas de Signatura electrònica: un element per signatari, amb el signatari, la confirmació del codi, el certificat, la marca de temps, els hashes i les rutes de descàrrega de cada document, i el PDF de l'evidència. No hi ha un esdeveniment de signatura independent: `status.updated` indica quan cal llegir-los. Consulta l'[informe de signatura electrònica](https://docs.didit.me/ca/core-technology/electronic-signature/report-electronic-signature.md).Els resultats de la **Verificació bancària** són a `bank_verifications[]`, un element per cada pas de Verificació bancària. El `method` de cada element indica com s'ha verificat el compte: `open_banking` (inici de sessió al banc), `account_lookup` (comprovació del compte), o bé una alternativa `statement` o `microdeposits`, i és `null` fins que no s'avalua un compte. Quan la persona ha triat **Continua sense el teu banc**, `continued_without_bank` és `true` i `continuation` indica el motiu. En aquest cas, `method` és `none`, tret que abans s'hagi avaluat un intent: consulta [Quan la persona no pot verificar el seu banc](https://docs.didit.me/ca/core-technology/bank-verification/fallbacks.md#llegeix-ho-en-el-resultat). Un compte verificat per comprovació del compte no llegeix dades financeres, de manera que `financial_data.not_collected` enumera els saldos, les transaccions o els ingressos que el pas demanava. Consulta [Mètodes de verificació bancària](https://docs.didit.me/ca/core-technology/bank-verification/verification-methods.md#com-llegir-el-mètode-al-resultat).### Bloc de l'InvestigatorUn `decision` V3 per a una sessió d'usuari o d'empresa també inclou `investigator`: l'última revisió d'[Investigator](https://docs.didit.me/ca/console/investigator.md) de la sessió, o `null` si mai no n'ha tingut cap.| Camp | Tipus | Significat |
| - | - | - |
| `run_id` | uuid | La revisió. Llegeix l'informe complet a `GET /v3/investigator/runs/{run_id}/`. |
| `status` | cadena | `running`, `completed` o `failed`. |
| `stage` | cadena | `shadow` o `live` quan es va executar la revisió. |
| `queue` | cadena | `kyc`, `kyb`, `aml`, `transactions` o `cases`. |
| `is_open` | booleà | La revisió encara espera una decisió. |
| `recommendation` | cadena \| null | `approve`, `decline`, `escalate` o `request`. |
| `recommendation_detail` | cadena \| null | Una línia sobre l'acció recomanada. |
| `confidence` | cadena \| null | `high`, `moderate` o `low`. |
| `summary` | cadena \| null | El resum de l'informe. |
| `ready_to_accept` | booleà | L'informe es pot acceptar tal com està. |
| `prepared_at` | datetime \| null | Quan l'informe va estar a punt. |
| `automated` | booleà | L'Investigator va decidir per si mateix, encara que després algú la va canviar. |
| `decided_by` | cadena \| null | `investigator` mentre la seva pròpia decisió es mantingui, `member` en cas contrari, `null` mentre no hi hagi decisió. |
| `decision` | objecte \| null | Com va sortir l'element de la cua: `outcome`, `action`, `final_status`, `agreed`, `decided_at`, `billed` i qui va decidir. |Les funcionalitats de recollida de documents, **Documents KYB** i **Document AI**, no tenen cap matriu específica per a múltiples elements. Apareixen a la llista `features[]` de la decisió com a `{ "feature": "DOCUMENT_AI", "node_id": "…" }` (una entrada per node). Consulta [Document AI](https://docs.didit.me/ca/core-technology/document-ai/overview.md).Les decisions de User Verification també inclouen un únic objecte `biometric_consent`: si la sessió requeria consentiment biomètric explícit, el `trigger`, el `status` de la resposta i la seva evidència. `status: "pending"` amb `reason: "BIOMETRIC_CONSENT_UNSUPPORTED_SDK"` vol dir que un SDK natiu anterior a la 4.10.0 ha capturat un rostre sense el pas de consentiment. Consulta [Consentiment biomètric](https://docs.didit.me/ca/getting-started/biometric-consent.md).### Claus del mètode de verificació d'identitatCada element de `id_verifications[]` indica quin [mètode de verificació d'identitat](https://docs.didit.me/ca/core-technology/id-verification/verification-methods.md) l'ha produït, perquè un gestor pugui bifurcar segons l'evidència que realment ha rebut:| Clau | Valor |
| - | - |
| `verification_method` | `document`, `id_lookup` o `wallet` |
| `assurance` | `documentary`, `data_match` o `cryptographic` |
| `wallet_provider` | Identificador de la cartera del catàleg quan una cartera l'ha produït; altrament, `null` |
| `id_lookup` | Evidència del registre quan el mètode era `id_lookup`; altrament, `null` |
| `wallet_verification` | Evidència de la credencial quan el mètode era `wallet`; altrament, `null` |
| `fallback_from` | Per què un mètode que no és de document no va acabar el pas d'identitat: el mètode, el motiu, i si l'usuari va passar a la captura de document o va ser rebutjat |Els gestors existents continuen funcionant sense canvis: una sessió de document defineix `verification_method` com a `document` i deixa la resta a `null`. Els tipus de cada camp es troben a [Models de dades](https://docs.didit.me/ca/reference/data-models.md#verificació-d’identitat).#### Sessió de cartera aprovada (`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 d'esdeveniments d'entitat```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"
}
```Els estats d'entitat són `ACTIVE`, `FLAGGED` o `BLOCKED`. `user.data.updated` i `business.data.updated` conserven el camp `status` i afegeixen una matriu `changed_fields` i un mapa `changes` de `{ field: { previous, current } }`. Els esdeveniments `business.*` usen `vendor_business_id` en lloc de `vendor_user_id`.### Payloads d'esdeveniments de transaccióEls webhooks de transacció utilitzen la mateixa infraestructura de distribució i el mateix embolcall, però identifiquen la transacció en lloc d'una sessió:```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` és `null`, o l'acció pendent de l'usuari (`type` `wallet_ownership` o `verification_session`, amb la seva `url`) descrita a [Accions requerides de l'usuari](https://docs.didit.me/ca/transaction-monitoring/sdk-transaction-submission.md#accions-requerides-de-l’usuari).Les transaccions noves tenen `APPROVED` per defecte. Si una regla, una llista de bloqueig o un flux de treball canvia l'estat durant la creació, el payload inicial de `transaction.created` reflecteix aquest estat final. `transaction.status.updated` té la mateixa forma amb el `status` actualitzat. Els estats de transacció són `APPROVED`, `IN_REVIEW`, `DECLINED` o `AWAITING_USER`; `severity` és `UNKNOWN` / `LOW` / `MEDIUM` / `HIGH` / `CRITICAL` i `direction` és `INBOUND` / `OUTBOUND`. `transaction.created` i `transaction.status.updated` només es lliuren per a aplicacions **en producció**. Els intercanvis de Travel Rule del sandbox sí que emeten `travel_rule.status.updated` amb `environment: sandbox`.### Callback de la cerca de registre KYBEl flux asíncron de [`POST /v3/kyb/search/`](https://docs.didit.me/ca/standalone-apis/kyb-registry) envia un payload extra que **no** és un esdeveniment de destinació: quan passes un `webhook_url` a la sol·licitud de cerca, Didit fa un POST d'un callback `kyb.registry_search.resolved` a aquesta URL quan el registre resol la consulta. Es diferencia dels webhooks de destinació en tres aspectes:- És **sense signar**: no té capçaleres `X-Signature*`. Didit l'indica amb `X-Didit-Unsigned-Callback: true`; valida-la fent coincidir `request_id` amb l'id que ha retornat la sol·licitud de cerca.
- El nom de l'esdeveniment és a `event_type` (no a `webhook_type`), i no hi ha `application_id` ni `environment`.
- `timestamp` i `created_at` són enters d'epoch, com altres webhooks, però a diferència del `created_at` ISO de l'endpoint de cerca.```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
}
```El callback segueix la mateixa [política de reintents](#política-de-reintents) que les destinacions de webhook (els reintents tornen a assignar el `timestamp` i continuen sense signar). Consulta la [API de registre KYB](https://docs.didit.me/ca/standalone-apis/kyb-registry.md) per a l'esquema complet de la llista de candidats.## Verificació de la signaturaCada webhook es signa amb HMAC-SHA256 fent servir el `secret_shared_key` de la destinació. S'envien tres capçaleres de signatura perquè puguis escollir la que funcioni amb el teu stack:| Capçalera | Què es signa | Segur per al middleware | Quan usar-la |
| - | - | - | - |
| `X-Signature-V2` | JSON compacte, ordenat i amb Unicode preservat (`ensure_ascii=False`) | Sí | **Recomanat**: funciona fins i tot si el teu framework recodifica el cos. |
| `X-Signature` | Els bytes exactes de la sol·licitud tal com Didit els va transmetre (`sort_keys`, `ensure_ascii=True`) | No | Només quan pots llegir el cos abans que cap analitzador el processi. |
| `X-Signature-Simple` **(obsoleta)** | `"{timestamp}:{session_id}:{status}:{webhook_type}"` | Sí | Alternativa d'últim recurs que autentica només el **sobre**: **no** autentica `decision`. Consulta l'avís d'obsolescència més avall abans d'usar-la. |Capçaleres que Didit sempre envia:| Capçalera | Exemple | Significat |
| - | - | - |
| `Content-Type` | `application/json` | El cos és JSON. |
| `User-Agent` | `DiditWebhook/2.0 +https://didit.me` | Identifica el procés de webhooks de Didit. |
| `X-Timestamp` | `1774970000` | Segons de l'època Unix en què es va enviar el webhook. Rebutja'l si `abs(now - X-Timestamp) > 300`. |
| `X-Signature` / `X-Signature-V2` / `X-Signature-Simple` | resums hexadecimals | HMAC-SHA256 del cos o de la cadena canònica descrita més amunt. |Durant 24 hores després que es rotí el secret de signatura d'una destinació, els lliuraments també porten `X-Signature-Previous`, `X-Signature-V2-Previous` i `X-Signature-Simple-Previous` (consulta [Rotació del secret de signatura](#rotació-del-secret-de-signatura)).### Rotació del secret de signaturaPots rotar el secret de signatura d'una destinació a la Business Console.
Durant 24 hores després d'una rotació, cada lliurament a aquesta destinació porta tres capçaleres més, fetes amb el secret que s'ha substituït:| Capçalera | Igual que | Signada amb |
| - | - | - |
| `X-Signature-V2-Previous` | `X-Signature-V2` | El secret substituït |
| `X-Signature-Previous` | `X-Signature` | El secret substituït |
| `X-Signature-Simple-Previous` | `X-Signature-Simple` (obsoleta) | El secret substituït |Un gestor que accepti una coincidència amb una capçalera de signatura o amb la seva capçalera `-Previous` seguirà verificant mentre desplegues el nou secret: el secret antic coincideix amb la capçalera `-Previous`, i el nou amb la capçalera habitual.
Els exemples de codi de més avall ho fan.
Desplega el nou secret dins de les 24 hores, perquè després només es pugui verificar amb el nou secret.
`previous_secret_expires_at` a [`GET /v3/webhook/destinations/{destination_uuid}/`](https://docs.didit.me/ca/management-api/webhook/get-destination) t'indica quan acaben les 24 hores.Els reintents i els reenviaments es tornen a signar amb el secret actual de la destinació quan s'envien, i només porten les capçaleres `-Previous` mentre duren les 24 hores.
Si tornes a rotar dins de les 24 hores, el secret més antic es retira de seguida: només el secret que era vigent fins a la nova rotació continua verificant a través de les capçaleres `-Previous`, durant 24 hores des de la nova rotació.Les capçaleres `-Previous` només existeixen per a les destinacions de webhook.
El `secret_shared_key` a nivell d'aplicació que signa el `webhook_url` heretat no es solapa: rotar-lo amb `rotate_secret_key` a [`PATCH /v3/webhook/`](https://docs.didit.me/ca/sessions-api/management-api#configuració-del-webhook) invalida el secret antic de seguida.### `X-Signature-Simple` està obsoleta> [!WARNING]
>
> **`X-Signature-Simple` autentica el sobre, no el payload.** La seva cadena canònica només cobreix `timestamp`, `session_id`, `status` i `webhook_type`. **No** cobreix `decision`, ni `resubmit_info`, `metadata`, `vendor_data` o `event_id`. Una sol·licitud amb el camp `decision` reescrit, però amb aquests quatre camps sense tocar, encara supera la verificació de `X-Signature-Simple`.
>
> És encara més feble en els esdeveniments que no porten cap `session_id`. Els esdeveniments de transacció (`transaction_id`, `txn_id`, `amount`, `score`) i d'entitat (`vendor_user_id`, `vendor_business_id`) signen `"{timestamp}::{status}:{webhook_type}"`, que no identifica **a quina** transacció o entitat fa referència l'esdeveniment: dues transaccions diferents informades amb el mateix estat en el mateix segon porten la mateixa `X-Signature-Simple`.
> Els esdeveniments `travel_rule.status.updated`, `workflow.compliance_changed` i `transaction_rule_suggestions.created` tampoc porten cap `status`, així que signen `"{timestamp}:::{webhook_type}"`, per exemple `"{timestamp}:::travel_rule.status.updated"`.
>
> Si `X-Signature-Simple` és l'única capçalera que verifiques, tracta tot el que hi ha fora d'aquests quatre camps com a **no fiable** i torna a obtenir el registre des de l'API: [`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/ca/sessions-api/retrieve-session) per a una sessió, o [`GET /v3/transactions/{transaction_id}/`](https://docs.didit.me/ca/management-api/transactions/get) per a una transacció. L'API és el servidor de referència.Això és una restricció de compatibilitat, no quelcom que puguem corregir en calent. La cadena canònica de quatre camps és un contracte de format de transmissió: cada receptor que verifica `X-Signature-Simple` recalcula exactament aquests quatre camps, així que canviar el que signa la capçalera trencaria en silenci tots aquests receptors a la ruta de verificació de la signatura. Cobrir el cos també exigeix una forma canònica del JSON especificada que els dos costats reprodueixin byte a byte, i Didit ja n'ofereix una com a `X-Signature-V2`. En lloc d'afegir una segona variant que cobreixi el cos amb les seves pròpies regles de canonicalització, la capçalera es manté exactament igual i queda obsoleta a favor de les variants existents que signen tot el payload.Què significa això per a tu:- **Res no canvia en el canal de transmissió.** `X-Signature-Simple` continua enviant-se a cada lliurament, calculada de la mateixa manera. Els verificadors existents continuen funcionant, i anunciarem amb molta antelació qualsevol eliminació.
- **Les integracions noves han d'usar `X-Signature-V2`.** És segura per al middleware *i* cobreix tot el payload.
- **Si no pots deixar `X-Signature-Simple`**, verifica-la i després obtén el registre des de l'API en lloc de llegir-lo del cos del webhook.### Per què tres variants?Alguns frameworks web (els analitzadors de cos d'Express, el middleware de Django, les passarel·les d'API) recodifiquen en silenci el JSON abans que el teu gestor el llegeixi. Si el cos original contenia `"José"` i el teu middleware el reescriu com a `"José"`, els bytes canvien encara que les dades siguin idèntiques. Aleshores `X-Signature` falla. `X-Signature-V2` es calcula a partir d'una forma canònica del JSON (claus ordenades, separadors compactes, **Unicode sense escapar**) que gairebé qualsevol middleware reprodueix de manera determinista. La signatura `X-Signature-Simple` obsoleta, en canvi, recorre a la signatura de només una cadena curta i independent de l'analitzador quan fins i tot això falla. Com que signa només aquesta cadena curta i mai el cos, no pot garantir el cos.### Ordre de verificació recomanat1) Prova primer **`X-Signature-V2`**. Torna a codificar `JSON.parse(body)` amb les claus ordenades i Unicode preservat, després `HMAC-SHA256(secret, canonical)` i `timingSafeEqual` contra la capçalera.
2) Si V2 falla i pots llegir els bytes en brut, prova **`X-Signature`** contra `HMAC-SHA256(secret, rawBody)`.
3) Només si fallen els dos, recorre a **`X-Signature-Simple`**, obsoleta, contra `HMAC-SHA256(secret, "{timestamp}:{session_id}:{status}:{webhook_type}")`. Això només autentica el sobre: considera el `decision` del cos com a no fiable i torna'l a obtenir des de [`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/ca/sessions-api/retrieve-session) abans d'actuar-hi.En cada pas, quan hi hagi la capçalera `-Previous` corresponent, accepta una coincidència amb qualsevol de les dues capçaleres (consulta [Rotació del secret de signatura](#rotació-del-secret-de-signatura)).En tots els casos, **rebutja** qualsevol sol·licitud amb una diferència respecte de l'hora actual superior a **5 minuts** (`abs(now - X-Timestamp) > 300`) per protegir-te d'atacs de repetició.## Política de reintentsSi el teu endpoint respon amb un `5xx` o un `404`, Didit reintenta fins a **2 vegades** amb una espera exponencial:- **1r reintent**: aproximadament **1 minut** després de la fallada inicial.
- **2n reintent**: aproximadament **4 minuts** després del 1r reintent.Després del segon reintent, el lliurament es descarta. Cada intent de lliurament (l'inicial i els reintents) es registra com una entrada separada a la Business Console, a la pestanya **Lliuraments** de la destinació, perquè puguis repetir o inspeccionar qualsevol intent.Cada intent del registre de lliuraments d'una destinació porta un número `attempt`: `1` per al primer lliurament d'un esdeveniment, i un número més que el de l'intent que repeteix, per a cada reintent o reenviament.
Els reintents automàtics s'envien a la URL on es va enviar el primer intent, encara que hagis canviat després el `url` de la destinació.
Un reenviament manual s'envia a la URL actual de la destinació, de manera que corregir una URL també corregeix el reenviament.Altres regles de lliurament que has de conèixer:- La sol·licitud HTTP de sortida té un temps d'espera de **5 segons**.
- Didit bloqueja els lliuraments a URL privades o a localhost com a mesura de protecció contra SSRF, i mai no segueix redireccions. Utilitza un endpoint HTTPS públic que respongui directament.
- Una `url` de destinació que contingui una barra invertida literal, un caràcter de control o més d'una `@` a l'autoritat es rebutja com a malformada: aquesta URL no es pot crear i una d'existent no es pot tornar a lliurar ni repetir. Utilitza una URL `https://host/path` simple, amb com a màxim una secció d'informació d'usuari (per a URL de webhook a l'estil de l'autenticació bàsica).
- `2xx` es considera èxit. Els temps d'espera i els errors de connexió es consideren reintentables (apareixen com a `504`/`503` al registre de lliuraments); les respostes `3xx` i `4xx`, excepte `404`, **no** es reintenten.
- En cada reintent, Didit recalcula les signatures amb un `X-Timestamp` nou, de manera que el parell de marca de temps i signatura sempre coincideix.
- Els reintents i els reenviaments es signen amb el secret de signatura actual de la destinació en el moment d'enviar-los.### Ordre de lliuramentDidit no garanteix l'ordre en què et arriben els webhooks.
Cada lliurament es posa en cua per separat i un reintent arriba minuts després de l'intent inicial, de manera que un `status.updated` per a `Approved` pot arribar-te abans que un anterior per a `In Progress`.Crea el receptor tenint això en compte:- Considera un webhook com un senyal que alguna cosa ha canviat. Quan l'ordre importa, crida [`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/ca/sessions-api/retrieve-session) i actua segons el `status` actual.
- No permetis mai que un esdeveniment més antic sobreescrigui un estat més nou: compara el `created_at` de l'esdeveniment amb l'últim que hagis desat per a aquest registre i ignora'l si és més antic.
- Detecta les repeticions per `event_id`, que es manté igual entre reintents.Didit posa en cua els esdeveniments d'una transferència Travel Rule en un ordre establert, descrit a [Travel Rule exchange status changed](#canvi-d’estat-d’un-intercanvi-de-travel-rule-travel_rule-status-updated), però un reintent pot canviar igualment l'ordre en què arriben.## Configurar una destinacióPots gestionar les destinacions des de la **Business Console > API & Webhooks** o mitjançant l'API de gestió.**1. Crea la destinació**Crida [`POST /v3/webhook/destinations/`](https://docs.didit.me/ca/management-api/webhook/create-destination) (o fes clic a **Afegir destinació** a la consola) amb un `label`, una `url` pública, un `webhook_version` (`"v3"`) i un array `subscribed_events` que contingui totes les famílies d'esdeveniments que aquest endpoint ha de rebre. Cal com a mínim un esdeveniment i no existeix cap comodí.**2. Desa el secret\_shared\_key**La resposta de creació retorna el `secret_shared_key` de la destinació. Desa'l amb els altres secrets del teu servidor: pertany només a aquesta destinació i és l'entrada de les tres variants de signatura HMAC-SHA256. [`GET /v3/webhook/destinations/{destination_uuid}/`](https://docs.didit.me/ca/management-api/webhook/get-destination) el torna a retornar als clients amb clau d'API i als usuaris de la consola que poden llegir els webhooks.**3. Implementa l'endpoint**Exposa una ruta `POST` HTTPS pública, llegeix el cos en brut, verifica la signatura (preferint `X-Signature-V2`), comprova la finestra de la marca de temps i després distribueix segons el `webhook_type`. Retorna `2xx` tan bon punt hagis posat el treball en cua: fes el processament pesat de manera asíncrona.**4. Confirma les recepcions**Vigila la pestanya **Lliuraments** de la destinació. Els webhooks que funcionen correctament retornen `200` en un segon o dos. Un `5xx` o `404` activa la política de reintents de Didit; les fallades persistents es descarten després del segon reintent.**5. Itera amb Try Webhook**Fes servir els escenaris de [**Try Webhook**](#proves) de la consola per enviar al teu endpoint payloads d'aprovació, rebuig, revisió, KYB, entitat, activitat i transacció abans d'activar el trànsit de producció.![Funcionalitat de prova de webhooks de Didit a 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)Envia webhooks de prova directament des de la Business Console per validar el teu endpoint abans de posar-lo en producció.Pots crear tantes destinacions com necessitis. Un patró habitual és **una destinació per consumidor**: p. ex., esdeveniments KYC → el teu servei d'autenticació, esdeveniments KYB → el teu servei d'operacions de compliment, transaccions → la teva cua de frau. Cada destinació té el seu propi secret, versió i llista de subscripcions, de manera que un secret rotat o un desplegament defectuós en un consumidor no afecta els altres.## Mostres de codiPer garantir la seguretat del teu endpoint de webhook, verifica l'autenticitat de les sol·licituds entrants amb el `secret_shared_key` de la destinació.> [!WARNING]
>
> **Utilitza `X-Signature-V2`.** És la variant més resistent a la recodificació que fan els middleware, i autentica completament el cos (inclosa la `decision`).### Node.js / Express (recomanat: 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}`));
```Si pots llegir els bytes en brut abans que cap analitzador els toqui, la variant original `X-Signature` funciona de la mateixa manera, però signa el cos en brut. La mantenim a la secció heretada següent per completesa.### Python / FastAPI (recomanat: 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 (recomanat: X-Signature-V2)Els valors per defecte del JSON de PHP **no** reprodueixen la forma canònica. Cal sobreescriure'n quatre, i si en falta només un, canvien els bytes signats i el resum mai no coincideix:| Valor per defecte de PHP | Què emet | Què necessites |
| - | - | - |
| `json_decode($raw, true)` (associatiu) | els objectes buits `{}` es converteixen en `[]` | decodifica amb `$associative = false` perquè els objectes JSON es mantinguin com a `stdClass` |
| `json_encode` escapa les barres inclinades | `https:\/\/media.didit.me` | `JSON_UNESCAPED_SLASHES` |
| `json_encode` escapa els caràcters no ASCII | `Jos\u00e9` en lloc de `José` | `JSON_UNESCAPED_UNICODE` |
| `ksort` compara les claus que semblen numèriques com a nombres | `"9"` es col·loca abans que `"10"` | `ksort($props, SORT_STRING)` |La trampa dels objectes buits és la que fa més mal. Els payloads de Didit porten sovint mapes buits, com `risk_view.countries.risk_scores`, `risk_view.crimes.risk_scores` i `risk_view.custom_list`, de manera que una conversió associativa d'anada i tornada trenca la signatura en sessions aprovades habituals, en lloc de fer-ho només en algun cas excepcional i poc freqüent.```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]);
    }
}
```Hi ha dues coses més que cal saber:- **Els nombres en coma flotant amb valor enter no necessiten cap tractament especial en PHP.** Didit els serialitza com a enters (`100.0` → `100`), i el `json_encode` de PHP escriu un float amb valor enter de la mateixa manera, així que no cal cap pas `shortenFloats`, a diferència de la mostra de Node.js de dalt.
- **Mantén `serialize_precision` en `-1`** (el valor per defecte des de PHP 7.1). Si el teu servidor el sobreescriu amb `17`, `json_encode` escriu `87.42` com `87.420000000000002` i totes les comprovacions de `X-Signature-V2` fallen.Si no aconsegueixes que la forma canònica coincideixi, `verifySignatureRaw` de dalt és l'alternativa més simple: el `$request->getContent()` de Laravel retorna els bytes sense tocar, de manera que `X-Signature` funciona sempre que res davant de PHP recodifiqui el cos. Precisament aquest és el cas per al qual està dissenyat `X-Signature-V2`, i per això es prova primer V2.## ProvesNo **cal** que executis una verificació completa per provar el teu endpoint.- Des de **Business Console > API & Webhooks > Try Webhook**, tria un escenari (p. ex. `approved_full_features`, `declined_face_mismatch`, `in_review_aml_hit`, `business_session_approved`, `transaction_created`, `activity_created`, `user_status_updated`) i envia un webhook amb el format complet a la URL de la teva destinació.
- Cada escenari es signa amb les mateixes tres capçaleres de signatura i la mateixa codificació canònica que el trànsit de producció, incloses les dades de prova amb unicode (`approved_kyc_with_unicode`), perquè puguis confirmar que `X-Signature-V2` funciona a través del teu middleware. Els lliuraments de prova porten dades d'exemple i es marquen amb una capçalera addicional `X-Didit-Test-Webhook: true` (i amb un sufix `(Test)` al `User-Agent`), perquè no afectin l'estat de producció.
- Torna a reproduir qualsevol lliurament històric des de la pestanya **Lliuraments** d'una destinació si necessites depurar una regressió.Per a les proves de funcionament bàsic d'extrem a extrem, executa una verificació real a través d'un flux de treball de prova de la teva pròpia aplicació: els webhooks resultants fan servir la mateixa estructura, les mateixes capçaleres i la mateixa política de reintents que els escenaris de **Try Webhook**.## Exemples### Sessió KYC aprovada (`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"
  }
}
```### Sessió rebutjada amb advertències (`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": []
  }
}
```### Sessió empresarial aprovada (`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": []
  }
}
```### Transacció creada (`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
}
```### Canvi d'estat d'un intercanvi de Travel Rule (`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` conté un dels 13 estats d'intercanvi documentats a [Travel Rule](https://docs.didit.me/ca/transaction-monitoring/travel-rule.md#cicle-de-vida).
`rail` és `INTERNAL`, `TRP`, `GTR`, `TRUST`, `VERIFYVASP`, `SYGNA` o `EMAIL` (cadena buida fins que se selecciona una via, si no se n'ha pogut determinar cap, i sempre en els registres sunrise), o `TEST_VASP` per a una transferència de sandbox que ha respost el [Didit Test VASP](https://docs.didit.me/ca/transaction-monitoring/travel-rule-sandbox.md), i `direction` és `INBOUND` o `OUTBOUND`.
Un esdeveniment de sandbox porta `"environment": "sandbox"`, i un esdeveniment `TEST_VASP` porta també `test_vasp: true` i `test_vasp_scenario` (l'escenari que ha respost).Quan Didit crea una transacció per a una transferència de Travel Rule (una crida [`POST /v3/transactions/`](https://docs.didit.me/ca/management-api/transactions/create), un [registre sunrise posterior al dipòsit](https://docs.didit.me/ca/transaction-monitoring/travel-rule.md#registre-posterior-al-dipòsit), una consulta `TRP` entrant o la part entrant de la via `INTERNAL`), envia primer l'esdeveniment `transaction.created` d'aquesta transacció i després els esdeveniments `travel_rule.status.updated` de la transferència.
Una regla que canvia l'estat de la transacció durant aquest pas també pot enviar `transaction.status.updated` abans de `transaction.created`, i un registre sunrise que s'adjunta a una transacció de dipòsit que ja has creat no envia cap `transaction.created`.
Cada webhook s'envia i es reintenta de manera independent, de manera que un esdeveniment reintentat pot arribar després d'un de posterior: fes que el teu gestor depengui de `transaction_id` i torna a llegir la transacció quan necessitis el seu estat actual.
Una transferència enviada per TRUST no rep mai un esdeveniment `COMPLETED`, perquè TRUST no envia res que Didit pugui llegir: es manté a `AWAITING_COUNTERPARTY` fins al termini de confirmació i després passa a `EXPIRED`. A GTR, VerifyVASP i Sygna Bridge, Didit llegeix la resposta de la contrapart des de la xarxa i l'esdeveniment la segueix (consulta [xarxes de Travel Rule](https://docs.didit.me/ca/transaction-monitoring/travel-rule-networks.md)).### Canvi de compliment del flux de treball (`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` i `transition.to` són cadascun un dels valors següents: `compliant`, `gaps_detected`, `pending_change`, `unknown_coverage` o `not_evaluated`.
`severity` és `actionable` quan s'han detectat llacunes, `indicative` per a un canvi pendent i `informative` quan s'han resolt llacunes.
Didit envia un esdeveniment per cada flux de treball que ha canviat.### Suggeriments de regla de transacció creats (`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` és `ADD` per a una regla nova, `TWEAK` per a un canvi en una regla existent i `DISABLE` per desactivar-ne una. `rule_id` és `null` per a `ADD`.
`open_count` és el nombre de suggeriments encara oberts després de l'execució, incloent-hi els nous.
La configuració dels suggeriments de regla activa o desactiva aquest webhook; consulta [Webhooks de transaccions](https://docs.didit.me/ca/transaction-monitoring/webhooks.md).### Esdeveniments d'Investigator (`investigator.*`)Els dotze esdeveniments `investigator.*` fan servir la mateixa estructura (`event_id`, `webhook_type`, `timestamp`, `created_at`, `application_id`, `environment`) i les mateixes capçaleres de signatura que qualsevol altre esdeveniment. Només porten identificadors i xifres: mai un nom, un motiu que hagi escrit un revisor, una narrativa ni res que hagi escrit l'usuari final. No tenen `session_id`; l'element al qual fan referència s'indica a `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
}
```| Esdeveniment | Camps a més de l'estructura |
| - | - |
| `investigator.report.ready` | `run_id`, `queue`, `item`, `recommendation`, `confidence`, `provenance`, `ready_to_accept`, `questions` (recompte) |
| `investigator.decision.automated` | `run_id`, `queue`, `item`, `recommendation`, `confidence`, `provenance`, `decided_by` (`"investigator"`), `outcome`, `action`, `final_status`, `agreed` |
| `investigator.decision.overridden` | El mateix que `investigator.decision.automated` amb `decided_by` `"human"`, més `override_category`, `overrode` (`"report"` o `"automated"`) i, quan `overrode` és `"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 a `"spend_limit"` també `month` (`YYYY-MM`) i `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` (codis de risc), `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 quina versió de l'Investigator ha escrit l'informe:- `engine` és la versió del motor de l'Investigator: un identificador opac de vuit caràcters. Canvia cada vegada que canvia el motor, de manera que dos informes amb el mateix `engine` els ha escrit el mateix motor.
- `agent_version` és la versió de l'agent.
- `policy_version` és la versió de la teva pròpia política que ha seguit l'informe.
- `model_id` és un àlies obsolet que porta el mateix valor que `engine`. Llegeix `engine` en lloc seu.
- `prompt_hash` ja no s'envia.`item.kind` indica el tipus d'element (una sessió, una sessió empresarial, una transacció o un cas) i `item.id` el seu identificador. Les marques de temps que acaben en `_at` són cadenes ISO 8601. Subscriu-te a aquests esdeveniments indicant les cadenes exactes a `subscribed_events`, igual que qualsevol altre esdeveniment.### Canvi d'estat de l'entitat d'usuari (`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"
}
```### Sessió reenviada```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" }
  }
}
```## Obsolet: X-Signature amb el cos en brutEncara s'envia `X-Signature` en cada lliurament. És un HMAC-SHA256 sobre els **bytes exactes** que transmet Didit (`json.dumps(..., sort_keys=True, separators=(",", ":"))`). Utilitza-la només quan puguis llegir el cos en brut abans que cap middleware el modifiqui.> [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}
> ```## Obsolet: format de webhook V2> [!WARNING]
>
> Els camps següents només s'apliquen quan una destinació està fixada a `webhook_version: "v2"`. També existeix una fixació `"v1"` encara més antiga (una tercera forma de decisió, en singular) per a les destinacions creades abans de V2; contacta amb suport abans de confiar-hi. Les noves integracions han d'utilitzar **V3** (el valor per defecte), que es documenta més amunt i utilitza matrius en plural.| Aspecte | V2 | V3 |
| - | - | - |
| Capçaleres de signatura | `X-Signature`, `X-Signature-V2`, `X-Signature-Simple` (les tres s'envien independentment de la versió; Simple està [obsolet](#x-signature-simple-està-obsoleta)) | Igual |
| Noms dels camps de decisió | Objectes en singular (`id_verification`, `liveness`, `face_match`, ...) | Matrius en plural (`id_verifications`, `liveness_checks`, `face_matches`, ...) |
| Múltiples instàncies de funcionalitat | No admès | Admès mitjançant matrius per funcionalitat amb `node_id` |
| Puntuacions AML | `aml.score`, `aml.hits[].match_score`, `aml.hits[].risk_score` | Mateixos valors sota `aml_screenings[]` |Els objectes per funcionalitat són els mateixos en les dues versions: una destinació V2 rep exactament el `match_score`, `risk_score`, `score_breakdown`, `risk_view` i `review_status` per cada coincidència, i el `score` agregat que rep V3 (vegeu [Informe AML](https://docs.didit.me/ca/core-technology/aml-screening/report-aml-screening.md#tres-puntuacions-no-pas-un-sol-veredicte)). Fixar la destinació a V2 canvia on es troba l'informe AML dins de `decision`, però no el que conté.Si un consumidor final encara necessita les formes V2, crea una destinació separada fixada a `webhook_version: "v2"` i mantén els nous consumidors a V3.## Relacionats- [API de destinacions de webhook](https://docs.didit.me/ca/management-api/webhook/list-destinations.md): llista, crea, actualitza i elimina destinacions, i consulta els registres de lliurament.
- [Crea una destinació del webhook](https://docs.didit.me/ca/management-api/webhook/create-destination.md): esquema de la petició i la resposta, inclòs el `secret_shared_key` de la destinació.
- [Models de dades](https://docs.didit.me/ca/reference/data-models.md): esquema canònic de cada objecte per funcionalitat inclòs a `decision`.
- [Esquema de la decisió](https://docs.didit.me/ca/sessions-api/retrieve-session.md): llista completa de camps de l'objecte `decision` inclòs als webhooks de sessió.
- [Estats de verificació](https://docs.didit.me/ca/integration/verification-statuses.md): valors exactes de `status` i les transicions esmentades més amunt.
- [Límits de freqüència](https://docs.didit.me/ca/integration/rate-limiting.md): límits que s'apliquen quan es consulta `GET /v3/session/{id}/decision/` com a alternativa.


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