> ## 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: eventos de KYC y KYB en tiempo real, firma HMAC

> Recibe de Didit eventos de verificación, entidades y transacciones en tiempo real. Firma HMAC-SHA256, múltiples destinos y reintentos limitados.

> [!NOTE]
>
> La actividad de autenticación y la entrega de webhooks asociada, que depende de la versión, se documentan en [Eventos de autenticación y webhooks](https://docs.didit.me/es/authentication/events-and-webhooks.md). No registres tipos de eventos de autenticación que no aparezcan en la lista en la API de destino actual.

Prompt de integración de webhooks
        Copiar el prompt al portapapeles



      
      

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



      
      

Pegar en

## Descripción generalLos webhooks son la forma en que Didit envía **actualizaciones en tiempo real** a tu backend cada vez que una sesión de verificación, una sesión de empresa, un usuario del proveedor, una empresa del proveedor o una transacción cambia de estado. Si suscribes uno o varios destinos a los eventos que te interesan, puedes reaccionar en cuanto un resultado está listo, normalmente a los pocos segundos de que el usuario que se verifica termine su flujo.Los webhooks son el patrón de integración **recomendado**. Consultar periódicamente [`GET /v3/session/{id}/decision/`](https://docs.didit.me/es/sessions-api/retrieve-session) se admite como alternativa, pero es más lento, consume más solicitudes y no recibe eventos como `data.updated`, `transaction.status.updated` ni los cambios a nivel de entidad, que solo se emiten mediante webhooks. Considera la entrega de webhooks como la fuente de verdad y consulta solo en un arranque en frío o para conciliar datos.Este capítulo de la lección de integración de Didit Academy comienza en la descripción general de los webhooks.La lección de integración de Didit Academy trata los webhooks a partir del minuto 3:12: cómo añadir un destino, elegir eventos y el secreto de firma que verificarás más abajo.[Descripción general de los webhooks — Didit Academy](https://www.youtube.com/embed/fX03-WEu_EI?start=192\&rel=0\&playsinline=1)> [!NOTE]
>
> **Usuarios de Cloudflare o WAF.** Didit entrega los webhooks desde la IP estática `18.203.201.92` con `User-Agent: DiditWebhook/2.0 +https://didit.me`. Si tu edge bloquea clientes desconocidos, permite esta IP en **Security > WAF > Tools > IP Access Rules** y elige **Permitir** para el hostname que recibe los webhooks.## Tipos de eventosUsa el valor exacto en el array `subscribed_events` de un destino. No hay comodín: incluye cada familia que quieras recibir. Puedes repartir los eventos entre varios destinos.| Evento | Recurso | Cuándo se dispara |
| - | - | - |
| `status.updated` | Sesión KYC o sesión de empresa KYB | Cambia el estado de una sesión (No iniciado → En curso → Aprobado / Rechazado / En revisión / Abandonado / Reenviado). Los payloads de KYB incluyen `session_kind: "business"` y `business_session_id`. |
| `data.updated` | Sesión KYC o sesión de empresa KYB | Se editan los datos de verificación después de su creación (correcciones manuales del revisor en KYC, POA, NFC, registro KYB, documentos KYB, etc.). |
| `user.status.updated` | Entidad de usuario | El estado de un usuario consolidado cambia entre `ACTIVE`, `FLAGGED` y `BLOCKED` (acción en la consola, acción de una regla, lista de bloqueo). Incluye `previous_status`. |
| `user.data.updated` | Entidad de usuario | Cambian el perfil, los contadores, los metadatos o los identificadores aprobados de un usuario consolidado. Incluye `changed_fields` y un mapa `changes` con los valores `previous` / `current`. |
| `business.status.updated` | Entidad de empresa | El estado de una empresa consolidada cambia entre `ACTIVE`, `FLAGGED` y `BLOCKED` (acción en la consola, acción de una regla, lista de bloqueo). Incluye `previous_status`. |
| `business.data.updated` | Entidad de empresa | Cambian el perfil, los datos de registro, los contadores, los metadatos o los campos agregados de verificación de una empresa consolidada. Incluye `changed_fields` y `changes`. |
| `activity.created` | Cronología de actividad | Reservado para el feed de la cronología de actividad. El valor se acepta en `subscribed_events` y está cubierto por la herramienta de prueba **Try Webhook**, pero Didit no entrega actualmente `activity.created` para el tráfico real. |
| `transaction.created` | Monitorización de transacciones | Se crea una transacción y su veredicto inicial de reglas está listo. Incluye `transaction_id`, `txn_id`, `status`, `score`, `severity`, `amount`, `currency`, `direction`, `action_required`. |
| `transaction.status.updated` | Monitorización de transacciones | El estado de una transacción cambia tras la evaluación de reglas, una acción de un analista, una remediación, coincidencias con la lista de bloqueo, actualizaciones del proveedor o acciones desde la API o la consola. También se envía una vez, haya cambiado o no el estado, cuando finaliza un análisis cripto que se reintentó en segundo plano ([hashes de transacción aún no indexados](https://docs.didit.me/es/transaction-monitoring/aml-screening.md#hashes-de-transacción-aún-no-indexados)). |
| `travel_rule.status.updated` | Travel Rule | Cambia el estado de un intercambio de [Travel Rule](https://docs.didit.me/es/transaction-monitoring/travel-rule.md). Ocurre al crearse (incluida la transición `NOT_APPLICABLE` por debajo del umbral), al confirmarse la propiedad, al recibirse la respuesta a la recogida por correo electrónico, al finalizarse o cancelarse, o al expirar por tiempo de espera. Se dispara para transferencias salientes y entrantes: la contraparte entrante del canal `INTERNAL`, los registros sunrise y las consultas `TRP` entrantes. Para una transacción que crea Didit, estos eventos se envían después de su `transaction.created`. Incluye `transaction_id`, `txn_id`, `travel_rule_status`, `rail`, `direction`. |
| `workflow.compliance_changed` | Cumplimiento del flujo de verificación (workflow) | El estado de cumplimiento de un flujo publicado cambió tras una reevaluación. Se envía un evento por flujo. Contiene `workflow_id`, `workflow_version`, `workflow_label`, `transition` (`from` y `to`), `severity` y `kb_version`, y no incluye `status`. |
| `transaction_rule_suggestions.created` | Monitorización de transacciones | Una ejecución de sugerencias de reglas propuso nuevos cambios en las reglas y la configuración de sugerencias de reglas tiene activadas las notificaciones por webhook. Contiene `run_id`, `open_count` y `suggestions[]`, y no incluye `status`. |
| `investigator.report.ready` | Investigator | Investigator redactó un informe sobre un elemento. |
| `investigator.decision.automated` | Investigator | Investigator decidió sobre un elemento por sí solo. |
| `investigator.decision.overridden` | Investigator | Una persona tomó una acción distinta de la del informe de Investigator, o anuló una decisión que este tomó por sí solo. |
| `investigator.decision.contested` | Investigator | El usuario final impugnó una decisión y solicitó una revisión humana. |
| `investigator.question.raised` | Investigator | Investigator le hizo al equipo una pregunta sobre la política de Investigator. |
| `investigator.automation.paused` | Investigator | La automatización dejó de tomar decisiones porque la pausó una persona o porque se alcanzó el límite de gasto mensual. |
| `investigator.rfi.answered` | Investigator | El usuario final respondió a una solicitud de información. |
| `investigator.rfi.overdue` | Investigator | Una solicitud de información superó su plazo sin respuesta. |
| `investigator.drift.detected` | Investigator | Se detectó una deriva en una cola y la automatización bajó un nivel. |
| `investigator.sla.breached` | Investigator | Un informe esperó más allá del SLA de su cola sin una decisión. |
| `investigator.qa.failed` | Investigator | Una decisión tomada por una persona no superó la revisión de calidad de Investigator. |
| `investigator.external.report.ready` | Investigator | Está listo el informe sobre un elemento que enviaste desde fuera de Didit. |
| `auth.signup` | Autenticación | Una persona se registra a través de [Autenticación](https://docs.didit.me/es/authentication/events-and-webhooks.md). Incluye `auth_event` y el resumen `authentication` de la persona. |
| `auth.login` | Autenticación | Una persona inicia sesión. Incluye `auth_event` y el resumen `authentication` de la persona. |
| `auth.login_failed` | Autenticación | Falla un intento de inicio de sesión. `vendor_data` es `null` cuando el intento no identifica a ninguna persona conocida. Incluye `auth_event` y el resumen `authentication` de la persona. |
| `auth.password_reset_requested` | Autenticación | Una persona solicita restablecer su contraseña. Incluye `auth_event` y el resumen `authentication` de la persona. |
| `auth.password_reset_completed` | Autenticación | Una persona completa el restablecimiento de su contraseña. Incluye `auth_event` y el resumen `authentication` de la persona. |
| `auth.mfa_changed` | Autenticación | Una persona añade o cambia un autenticador. Incluye `auth_event` y el resumen `authentication` de la persona. |
| `auth.new_device` | Autenticación | Una persona inicia sesión desde un dispositivo que no había usado antes. Incluye `auth_event` y el resumen `authentication` de la persona. |
| `auth.account_locked` | Autenticación | Una política de seguridad bloquea la cuenta, o tu equipo bloquea a la persona. Incluye `auth_event` y el resumen `authentication` de la persona. |Los valores de esta tabla son el conjunto completo de eventos a los que puedes suscribirte: no existe `session.status.updated`, `kyc.completed` ni nada similar. La única otra entrega que hace Didit es el [callback `kyb.registry_search.resolved`](#callback-de-búsqueda-en-el-registro-kyb) sin firmar, que se envía al `webhook_url` que proporcionas con cada solicitud a [`POST /v3/kyb/search/`](https://docs.didit.me/es/standalone-apis/kyb-registry), nunca a un destino.> [!TIP]
>
> El menú Business Console > API & Webhooks > **Try Webhook** envía payloads completos para cada uno de estos eventos (aprobado, rechazado, en revisión, nombres Unicode, KYC/KYB, entidad, actividad y transacción). Úsalo para validar tu endpoint antes de pasar a producción.## Estructura del payload### EnvoltorioTodos los webhooks comparten el mismo envoltorio. Los campos se serializan siempre con `sort_keys=True` y separadores compactos (`,` / `:`), de modo que el mismo payload produce la misma firma en ambos lados.| Campo | Tipo | Notas |
| - | - | - |
| `event_id` | `uuid` | Identificador estable del evento. Se envía el **mismo** `event_id` a cada destino al que se distribuye el evento y se reutiliza en los reintentos: basa tu idempotencia en él. |
| `webhook_type` | string | Uno de los [tipos de evento](#tipos-de-eventos) indicados arriba. |
| `timestamp` | int (segundos Unix) | Momento en que Didit envió el webhook. Lo usa la verificación de firma. Se actualiza en cada reintento. |
| `created_at` | int (segundos Unix) | Momento de la última actualización del registro subyacente. |
| `application_id` | `uuid` | La aplicación propietaria del destino. |
| `environment` | `"live"` \| `"sandbox"` | Indica si el evento procede de una sesión o aplicación de producción o de sandbox. |
| `status` | string | Presente en todos los eventos de sesión, entidad y transacción (incluidos los `*.data.updated`); los eventos `investigator.*` no incluyen `status` (consulta el payload del Investigator más abajo). Los eventos de sesión usan exactamente las [etiquetas de estado de verificación](https://docs.didit.me/es/integration/verification-statuses.md) (por ejemplo, `"Approved"`, `"Kyc Expired"`); los eventos de entidad usan `ACTIVE` / `FLAGGED` / `BLOCKED`; los eventos de transacción usan `APPROVED` / `IN_REVIEW` / `DECLINED` / `AWAITING_USER`. |En los eventos de **sesión** (`status.updated`, `data.updated` en sesiones normales y de empresa), el envoltorio incluye además:| Campo | Tipo | Notas |
| - | - | - |
| `session_id` | `uuid` | El UUID de la verificación de usuario o de la sesión de empresa. |
| `business_session_id` | `uuid` | Solo en KYB. Igual a `session_id`. |
| `session_kind` | `"business"` | Solo en eventos KYB; permite que un handler genérico diferencie los casos. |
| `workflow_id` | `uuid` | Flujo de verificación (workflow) usado para crear la sesión, cuando se ha definido. |
| `workflow_version` | int | Versión del flujo. |
| `vendor_data` | string | Tu identificador interno, cuando se proporciona al crear la sesión. |
| `vendor_business_id` | string | Solo en sesiones KYB, cuando se proporciona. |
| `sandbox_scenario` | string \| null | En todos los eventos de sesión (KYC y KYB). El slug del escenario de [sandbox](https://docs.didit.me/es/integration/sandbox-testing.md) activado (por ejemplo, `"decline_aml_hit"`) en una sesión de sandbox con un escenario activado; `null` en una sesión de producción o en una sesión de sandbox sin escenario activado. No aparece en los webhooks de entidad ni de transacción. |
| `metadata` | object | Los metadatos que adjuntaste al crear la sesión, sin las claves internas de deduplicación. |
| `trigger` | string | Opcional. Indica por qué se disparó el webhook cuando no lo generó el propio flujo de verificación: `"manual_review"` (ediciones de un revisor desde la consola), `"manual_step_update"` (un revisor cambió el estado de una función), `"ongoing_monitoring"` (actualización de la monitorización AML continua), `"webhook_reconciliation"` (un `status.updated` reenviado porque el primer webhook terminal nunca se llegó a crear, por lo que puedes recibir un segundo evento para una decisión que ya recibiste: deduplica por `session_id` y `status`), `"manual_webhook_recovery"` (un webhook recuperado manualmente), `"kyb_registry_change"` (el registro de empresas notificó un cambio), `"console_registry_edit"`, `"console_key_people_skip"` y `"console_key_people_regenerate"` (ediciones realizadas en la consola sobre una sesión de empresa), y `"biometric_retention"` (un `data.updated` enviado cuando los datos biométricos en vivo de la sesión se eliminan según la programación). El conjunto puede crecer, así que trata cualquier valor desconocido como informativo. |
| `decision` | object | Presente cuando `status` es `Approved`, `Declined`, `In Review` o `Abandoned`. Replica la [decisión de sesión V3](https://docs.didit.me/es/sessions-api/retrieve-session.md). |
| `resubmit_info` | `{ nodes_to_resubmit, reasons }` | Presente cuando `status` es `Resubmitted` (sin `decision`). |
| `decided_by` | string | Solo en `status.updated` cuando el estado es `Approved`, `Declined` o `Resubmitted` y la aplicación usa el Investigator. `"investigator"` cuando el Investigator tomó la decisión por su cuenta, `"human"` cuando la tomó un usuario de la consola (incluido el caso de aceptar el informe del Investigator). Se omite cuando ningún revisor registró la decisión, cuando la tomó una clave de API o cuando no se usa el Investigator. |### Ejemplo de envoltorio de sesión```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]
>
> El objeto `decision` tiene el mismo esquema que devuelve [`GET /v3/session/{id}/decision/`](https://docs.didit.me/es/sessions-api/retrieve-session). Para la referencia canónica de campos por función (todas las propiedades de `id_verifications[]`, `aml_screenings[]`, etc.), consulta [Modelos de datos](https://docs.didit.me/es/reference/data-models.md). No duplicamos aquí, de forma deliberada, las tablas de campos por función para que el esquema exista en un único lugar.### Arrays en plural de V3 (importante)Dentro de `decision`, cada resultado por función es un **array plural**, de modo que un mismo flujo puede incluir varias instancias de la misma función (por ejemplo, varias comprobaciones de identidad sobre distintos documentos):```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 elemento incluye un `node_id` que coincide con el grafo del flujo, además de un `status` por función. **No** leas los campos singulares heredados de V2 (`id_verification`, `nfc`, `liveness`, `face_match`, `phone`, `email`, `poa`, `aml`, `ip_analysis`, `database_validation`); solo aparecen si un destino está fijado explícitamente a `webhook_version: "v2"`.Los resultados de **Firma electrónica** están en `signatures[]`, que solo aparece cuando el flujo tiene un paso de Firma electrónica: un elemento por firmante, con el firmante, la confirmación del código, el certificado, la marca de tiempo, los hashes y las rutas de descarga de cada documento, y el PDF de evidencias. No existe un evento de firma independiente: `status.updated` te indica cuándo leerlos. Consulta el [informe de Firma electrónica](https://docs.didit.me/es/core-technology/electronic-signature/report-electronic-signature.md).Los resultados de **Verificación bancaria** están en `bank_verifications[]`, un elemento por cada paso de Verificación bancaria. El campo `method` de cada elemento indica cómo se verificó la cuenta: `open_banking` (inicio de sesión en el banco), `account_lookup` (comprobación de la cuenta), o una alternativa `statement` o `microdeposits`, y vale `null` hasta que se evalúa una cuenta. Cuando la persona eligió **Continue without your bank**, `continued_without_bank` es `true` y `continuation` indica el motivo. En ese caso `method` es `none`, salvo que antes se haya evaluado un intento: consulta [Cuando la persona no puede verificar su banco](https://docs.didit.me/es/core-technology/bank-verification/fallbacks.md#cómo-leerlo-en-el-resultado). Una cuenta verificada mediante comprobación de la cuenta no lee datos financieros, por lo que `financial_data.not_collected` enumera los saldos, transacciones o ingresos que solicitaba el paso. Consulta [Métodos de verificación bancaria](https://docs.didit.me/es/core-technology/bank-verification/verification-methods.md#consultar-el-método-en-los-resultados).### Bloque del InvestigatorUna `decision` V3 de una sesión de usuario o de empresa también incluye `investigator`: la revisión más reciente del [Investigator](https://docs.didit.me/es/console/investigator.md) sobre la sesión, o `null` si nunca tuvo ninguna.| Campo | Tipo | Significado |
| - | - | - |
| `run_id` | uuid | La revisión. Consulta el informe completo en `GET /v3/investigator/runs/{run_id}/`. |
| `status` | string | `running`, `completed` o `failed`. |
| `stage` | string | `shadow` o `live` en el momento en que se ejecutó la revisión. |
| `queue` | string | `kyc`, `kyb`, `aml`, `transactions` o `cases`. |
| `is_open` | boolean | La revisión sigue esperando una decisión. |
| `recommendation` | string \| null | `approve`, `decline`, `escalate` o `request`. |
| `recommendation_detail` | string \| null | Una línea sobre la acción recomendada. |
| `confidence` | string \| null | `high`, `moderate` o `low`. |
| `summary` | string \| null | El resumen del informe. |
| `ready_to_accept` | boolean | El informe se puede aceptar tal como está. |
| `prepared_at` | datetime \| null | Cuándo estuvo listo el informe. |
| `automated` | boolean | El Investigator decidió por sí solo, aunque alguien anulara después su decisión. |
| `decided_by` | string \| null | `investigator` mientras se mantiene su propia decisión, `member` en caso contrario, `null` mientras no hay decisión. |
| `decision` | object \| null | Cómo salió el elemento de la cola: `outcome`, `action`, `final_status`, `agreed`, `decided_at`, `billed` y quién decidió. |Las funciones de recopilación de documentos (**Documentos KYB** y **Document AI**) no tienen un array plural propio; aparecen en la lista `features[]` de la decisión como `{ "feature": "DOCUMENT_AI", "node_id": "…" }` (una entrada por nodo). Consulta [Document AI](https://docs.didit.me/es/core-technology/document-ai/overview.md).Las decisiones de Verificación de usuarios también incluyen un único objeto `biometric_consent`: si la sesión exigió consentimiento biométrico explícito, el `trigger`, el `status` de la respuesta y su evidencia. `status: "pending"` con `reason: "BIOMETRIC_CONSENT_UNSUPPORTED_SDK"` significa que un SDK nativo anterior a la versión 4.10.0 capturó un rostro sin el paso de consentimiento. Consulta [Consentimiento biométrico](https://docs.didit.me/es/getting-started/biometric-consent.md).### Claves del método de Verificación de identidadCada elemento de `id_verifications[]` indica qué [método de Verificación de identidad](https://docs.didit.me/es/core-technology/id-verification/verification-methods.md) lo generó, para que un handler pueda actuar según la evidencia que realmente obtuvo:| Clave | Valor |
| - | - |
| `verification_method` | `document`, `id_lookup` o `wallet` |
| `assurance` | `documentary`, `data_match` o `cryptographic` |
| `wallet_provider` | ID de la wallet en el catálogo cuando una wallet produjo el resultado; en caso contrario, `null` |
| `id_lookup` | Evidencia del registro cuando el método fue `id_lookup`; en caso contrario, `null` |
| `wallet_verification` | Evidencia de la credencial cuando el método fue `wallet`; en caso contrario, `null` |
| `fallback_from` | Por qué un método distinto del documento terminó el paso de identidad, cuando rechazó la sesión |Los handlers existentes siguen funcionando sin cambios: una sesión con documento establece `verification_method` en `document` y deja el resto en `null`. Los tipos campo por campo están en [Modelos de datos](https://docs.didit.me/es/reference/data-models.md#verificación-de-identidad).#### Sesión con wallet aprobada (`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 de eventos de entidades```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"
}
```Los estados de las entidades son `ACTIVE`, `FLAGGED` o `BLOCKED`. `user.data.updated` y `business.data.updated` mantienen el campo `status` y añaden un array `changed_fields` y un mapa `changes` de la forma `{ field: { previous, current } }`. Los eventos `business.*` usan `vendor_business_id` en lugar de `vendor_user_id`.### Payloads de eventos de transaccionesLos webhooks de transacciones usan la misma infraestructura de fan-out y el mismo envoltorio, pero identifican la transacción en lugar de una sesión:```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` es `null` o la acción pendiente del usuario (`type` `wallet_ownership` o `verification_session`, con su `url`) descrita en [Acciones requeridas del usuario](https://docs.didit.me/es/transaction-monitoring/sdk-transaction-submission.md#acciones-requeridas-del-usuario).Las transacciones nuevas tienen el estado `APPROVED` por defecto. Si una regla, la lista de bloqueo o un flujo cambia el estado durante la creación, el payload inicial de `transaction.created` refleja ese estado final. `transaction.status.updated` tiene la misma estructura con el `status` actualizado. Los estados de las transacciones son `APPROVED`, `IN_REVIEW`, `DECLINED` o `AWAITING_USER`; `severity` es `UNKNOWN` / `LOW` / `MEDIUM` / `HIGH` / `CRITICAL` y `direction` es `INBOUND` / `OUTBOUND`. `transaction.created` y `transaction.status.updated` solo se entregan para aplicaciones **en producción**. Los intercambios de Travel Rule en sandbox sí emiten `travel_rule.status.updated` con `environment: sandbox`.### Callback de búsqueda en el registro KYBEl flujo asíncrono de [`POST /v3/kyb/search/`](https://docs.didit.me/es/standalone-apis/kyb-registry) entrega un payload adicional que **no** es un evento de destino: cuando pasas un `webhook_url` en la solicitud de búsqueda, Didit envía por POST un callback `kyb.registry_search.resolved` a esa URL en cuanto el registro responde. Se diferencia de los webhooks de destino en tres aspectos:- No está **firmado**: no lleva encabezados `X-Signature*`. En su lugar, Didit lo marca con `X-Didit-Unsigned-Callback: true`; valídalo comprobando que `request_id` coincide con el ID devuelto por la solicitud de búsqueda.
- El nombre del evento va en `event_type` (no en `webhook_type`) y no hay `application_id` ni `environment`.
- `timestamp` y `created_at` son enteros de época, como en los demás webhooks, pero a diferencia del `created_at` en formato ISO del endpoint de búsqueda.```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 sigue la misma [política de reintentos](#política-de-reintentos) que los webhooks de destino (los reintentos actualizan de nuevo `timestamp` y siguen sin firmar). Consulta la [API de registro KYB](https://docs.didit.me/es/standalone-apis/kyb-registry.md) para ver el esquema completo de la lista de candidatos.## Verificación de firmaCada webhook se firma con HMAC-SHA256 usando la `secret_shared_key` del destino. Se envían tres encabezados de firma para que elijas el que funcione con tu stack:| Encabezado | Qué se firma | Compatible con middleware | Cuándo usarlo |
| - | - | - | - |
| `X-Signature-V2` | JSON compacto con claves ordenadas y Unicode conservado (`ensure_ascii=False`) | Sí | **Recomendado**: funciona incluso si tu framework vuelve a codificar el cuerpo. |
| `X-Signature` | Los bytes sin procesar exactos de la solicitud tal como Didit los transmitió (`sort_keys`, `ensure_ascii=True`) | No | Solo cuando puedes leer el cuerpo sin procesar antes de que lo toque cualquier parser. |
| `X-Signature-Simple` **(obsoleto)** | `"{timestamp}:{session_id}:{status}:{webhook_type}"` | Sí | Último recurso que autentica **solo el sobre**: **no** autentica `decision`. Lee el aviso de obsolescencia más abajo antes de usarlo. |Encabezados que Didit envía siempre:| Encabezado | Ejemplo | Significado |
| - | - | - |
| `Content-Type` | `application/json` | El cuerpo es JSON. |
| `User-Agent` | `DiditWebhook/2.0 +https://didit.me` | Identifica el worker de webhooks de Didit. |
| `X-Timestamp` | `1774970000` | Segundos de época Unix en que se despachó el webhook. Rechaza la solicitud si `abs(now - X-Timestamp) > 300`. |
| `X-Signature` / `X-Signature-V2` / `X-Signature-Simple` | resúmenes hexadecimales | HMAC-SHA256 del cuerpo o de la cadena canónica indicada arriba. |Durante las 24 horas posteriores a la rotación del secreto de firma de un destino, las entregas también incluyen `X-Signature-Previous`, `X-Signature-V2-Previous` y `X-Signature-Simple-Previous` (consulta [Rotación del secreto de firma](#rotación-del-secreto-de-firma)).### Rotación del secreto de firmaPuedes rotar el secreto de firma de un destino en la Business Console.
Durante las 24 horas posteriores a una rotación, cada entrega a ese destino incluye tres encabezados más, generados con el secreto que se reemplazó:| Encabezado | Misma firma que | Firmado con |
| - | - | - |
| `X-Signature-V2-Previous` | `X-Signature-V2` | El secreto reemplazado |
| `X-Signature-Previous` | `X-Signature` | El secreto reemplazado |
| `X-Signature-Simple-Previous` | `X-Signature-Simple` (obsoleto) | El secreto reemplazado |Un handler que acepta una coincidencia tanto en un encabezado de firma como en su encabezado `-Previous` sigue verificando mientras despliegas el nuevo secreto: el secreto antiguo coincide con el encabezado `-Previous` y el nuevo coincide con el encabezado normal.
Los ejemplos de código de más abajo lo hacen así.
Despliega el nuevo secreto dentro de esas 24 horas, porque después solo se podrá verificar con el nuevo secreto.
`previous_secret_expires_at` en [`GET /v3/webhook/destinations/{destination_uuid}/`](https://docs.didit.me/es/management-api/webhook/get-destination) te indica cuándo terminan las 24 horas.Los reintentos y reenvíos se vuelven a firmar con el secreto actual del destino en el momento en que se envían, e incluyen los encabezados `-Previous` solo mientras duran las 24 horas.
Si vuelves a rotar dentro de las 24 horas, el secreto más antiguo se retira de inmediato: solo el secreto que estaba vigente hasta la nueva rotación sigue verificando a través de los encabezados `-Previous`, durante 24 horas desde la nueva rotación.Los encabezados `-Previous` existen solo para los destinos de webhook.
La `secret_shared_key` a nivel de aplicación que firma el `webhook_url` heredado no tiene periodo de superposición: rotarla con `rotate_secret_key` en [`PATCH /v3/webhook/`](https://docs.didit.me/es/sessions-api/management-api#configuración-de-webhooks) invalida el secreto antiguo de inmediato.### `X-Signature-Simple` está obsoleto> [!WARNING]
>
> **`X-Signature-Simple` autentica el sobre, no el payload.** Su cadena canónica cubre solo `timestamp`, `session_id`, `status` y `webhook_type`. **No** cubre `decision`, ni tampoco `resubmit_info`, `metadata`, `vendor_data` ni `event_id`. Una solicitud cuya `decision` se haya reescrito dejando intactos esos cuatro campos sigue superando la verificación de `X-Signature-Simple`.
>
> Es aún más débil en los eventos que no incluyen ningún `session_id`. Los eventos de transacción (`transaction_id`, `txn_id`, `amount`, `score`) y los eventos de entidad (`vendor_user_id`, `vendor_business_id`) firman `"{timestamp}::{status}:{webhook_type}"`, que no identifica **a qué** transacción o entidad se refiere el evento: dos transacciones distintas notificadas con el mismo estado en el mismo segundo llevan el mismo `X-Signature-Simple`.
> Los eventos `travel_rule.status.updated`, `workflow.compliance_changed` y `transaction_rule_suggestions.created` tampoco incluyen `status`, así que firman `"{timestamp}:::{webhook_type}"`, por ejemplo `"{timestamp}:::travel_rule.status.updated"`.
>
> Si `X-Signature-Simple` es el único encabezado que compruebas, trata todo lo que quede fuera de esos cuatro campos como **no confiable** y vuelve a obtener el registro desde la API ([`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/es/sessions-api/retrieve-session) para una sesión, [`GET /v3/transactions/{transaction_id}/`](https://docs.didit.me/es/management-api/transactions/get) para una transacción), que es la fuente de referencia.Se trata de una restricción de compatibilidad, no de algo que podamos corregir sobre la marcha. La cadena canónica de cuatro campos es un contrato de protocolo: cada receptor que verifica `X-Signature-Simple` vuelve a calcular exactamente esos cuatro campos, así que cambiar lo que firma el encabezado haría que todos dejaran de funcionar silenciosamente al verificar firmas. Además, cubrir el cuerpo requiere una forma canónica especificada del JSON que ambas partes reproduzcan byte a byte, y Didit ya ofrece una como `X-Signature-V2`. En lugar de añadir una segunda variante que cubra el cuerpo con sus propias reglas de canonicalización, el encabezado se mantiene exactamente como está y queda obsoleto en favor de las variantes existentes que cubren el payload completo.Qué significa esto para ti:- **No cambia nada en la transmisión.** `X-Signature-Simple` se sigue enviando en cada entrega, calculado de la misma manera. Los verificadores existentes siguen funcionando, y anunciaremos cualquier retirada con mucha antelación.
- **Las nuevas integraciones deben usar `X-Signature-V2`.** Es compatible con middleware *y* cubre todo el payload.
- **Si no puedes dejar de usar `X-Signature-Simple`**, verifícalo y después obtén el registro desde la API en lugar de leerlo del cuerpo del webhook.### Por qué tres variantesAlgunos frameworks web (parsers de cuerpo de Express, middleware de Django, API gateways) vuelven a codificar el JSON de forma silenciosa antes de que tu handler lo lea. Si el cuerpo original contenía `"José"` y tu middleware lo reescribe como `"José"`, los bytes cambian aunque los datos sean idénticos. Entonces `X-Signature` falla. `X-Signature-V2` se calcula a partir de una forma canónica del JSON (claves ordenadas, separadores compactos, **Unicode sin escapar**) que casi cualquier middleware reproduce de forma determinista, y el obsoleto `X-Signature-Simple` recurre a firmar solo una cadena corta e independiente del parser cuando incluso eso falla. Como firma solo esa cadena corta y nunca el cuerpo, no puede dar fe del cuerpo.### Orden de verificación recomendado1) Prueba primero con **`X-Signature-V2`**. Vuelve a codificar `JSON.parse(body)` con las claves ordenadas y los caracteres Unicode sin escapar, calcula `HMAC-SHA256(secret, canonical)` y compáralo con el encabezado mediante `timingSafeEqual`.
2) Si V2 falla y puedes leer los bytes sin procesar, prueba con **`X-Signature`** comparándolo con `HMAC-SHA256(secret, rawBody)`.
3) Solo si ambas fallan, recurre a la obsoleta **`X-Signature-Simple`** comparándola con `HMAC-SHA256(secret, "{timestamp}:{session_id}:{status}:{webhook_type}")`. Esta firma solo autentica el sobre: considera que la `decision` del cuerpo no es de confianza y vuelve a obtenerla desde [`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/es/sessions-api/retrieve-session) antes de actuar basándote en ella.En cada paso, cuando esté presente el encabezado `-Previous` correspondiente, acepta la coincidencia con cualquiera de los dos encabezados (consulta [Rotación del secreto de firma](#rotación-del-secreto-de-firma)).En todos los casos, **rechaza** cualquier solicitud con más de **5 minutos** de antigüedad (`abs(now - X-Timestamp) > 300`) para protegerte frente a ataques de repetición.## Política de reintentosSi tu endpoint responde con un `5xx` o un `404`, Didit reintenta hasta **2 veces** con espera exponencial:- **1.er reintento**: aproximadamente **1 minuto** después del fallo inicial.
- **2.º reintento**: unos **4 minutos** después del 1.er reintento.Tras el segundo reintento, la entrega se descarta. Cada intento de entrega (el inicial y los reintentos) se registra como una entrada independiente en la Business Console, en la pestaña **Entregas** del destino, para que puedas reenviar o inspeccionar cualquier intento.Cada intento del registro de entregas de un destino lleva un número de `attempt`: `1` para la primera entrega de un evento y, en cada reintento o reenvío, uno más que el del intento que repite.
Los reintentos automáticos se envían a la URL a la que se envió el primer intento, aunque hayas cambiado la `url` del destino desde entonces.
Un reenvío manual se envía a la URL actual del destino, así que al corregir una URL también se corrige el reenvío.Otras reglas de entrega que debes conocer:- El tiempo de espera de la solicitud HTTP saliente se agota después de **5 segundos**.
- Didit bloquea las entregas a URL privadas o de localhost como protección frente a SSRF y nunca sigue redirecciones. Usa un endpoint HTTPS público que responda directamente.
- Una `url` de destino que contenga una barra invertida sin escapar, un carácter de control o más de un `@` en la autoridad se rechaza por estar mal formada: no se puede crear una URL así, y una existente no se puede volver a entregar ni reenviar. Usa una URL `https://host/path` simple con, como máximo, una sección de información de usuario (para URL de webhook con autenticación básica).
- Un `2xx` se considera un éxito. Los tiempos de espera agotados y los fallos de conexión se pueden reintentar (aparecen como `504`/`503` en el registro de entregas); las respuestas `3xx` y `4xx` distintas de `404` **no** se reintentan.
- Al reintentar, Didit vuelve a calcular las firmas con un `X-Timestamp` nuevo, de modo que la marca de tiempo y la firma siempre coinciden.
- Los reintentos y reenvíos se firman con el secreto de firma vigente del destino en el momento en que se envían.### Orden de entregaDidit no garantiza el orden en que te llegan los webhooks.
Cada entrega se pone en cola por separado y un reintento llega minutos después del primer intento, por lo que un `status.updated` para `Approved` puede llegarte antes que uno anterior para `In Progress`.Prepara tu receptor para ello:- Trata un webhook como una señal de que algo ha cambiado. Cuando el orden importe, invoca [`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/es/sessions-api/retrieve-session) y actúa según el `status` actual.
- Nunca permitas que un evento más antiguo sobrescriba un estado más reciente: compara el `created_at` del evento con el último que guardaste para ese registro e ignora el más antiguo.
- Identifica las repeticiones por `event_id`, que no cambia entre reintentos.Didit pone en cola los eventos de una transferencia de Travel Rule en un orden fijo, descrito en [Cambio de estado del intercambio de Travel Rule](#cambio-de-estado-de-un-intercambio-de-travel-rule-travel_rule-status-updated), pero un reintento puede cambiar igualmente el orden en que llegan.## Configurar un destinoPuedes gestionar los destinos desde **Business Console > API & Webhooks** o mediante la Management API.**1. Crea el destino**Llama a [`POST /v3/webhook/destinations/`](https://docs.didit.me/es/management-api/webhook/create-destination) (o haz clic en **Add destination** en la consola) con un `label`, una `url` pública, un `webhook_version` (`"v3"`) y un array `subscribed_events` que contenga todas las familias de eventos que debe recibir este endpoint. Se requiere al menos un evento y no existe ningún comodín.**2. Guarda el secret\_shared\_key**La respuesta de creación devuelve el `secret_shared_key` del destino. Guárdalo junto con los demás secretos de tu servidor: pertenece solo a este destino y se usa como entrada para las tres variantes de firma HMAC-SHA256. [`GET /v3/webhook/destinations/{destination_uuid}/`](https://docs.didit.me/es/management-api/webhook/get-destination) lo devuelve de nuevo a quienes invocan la API con una clave de API y a los usuarios de la consola que pueden leer webhooks.**3. Implementa el endpoint**Expón una ruta HTTPS `POST` pública, lee el cuerpo sin procesar, verifica la firma (preferiblemente `X-Signature-V2`), comprueba la ventana de la marca de tiempo y, después, despacha según `webhook_type`. Devuelve un `2xx` en cuanto hayas puesto el trabajo en cola: haz el procesamiento pesado de forma asíncrona.**4. Confirma la recepción**Revisa la pestaña **Deliveries** del destino. Los webhooks que funcionan bien devuelven `200` en uno o dos segundos. Un `5xx`/`404` activa la política de reintentos de Didit; los fallos persistentes se descartan tras el segundo reintento.**5. Itera con Try Webhook**Usa los escenarios de [**Try Webhook**](#pruebas) de la consola para hacer pasar tu endpoint por payloads aprobados, rechazados, en revisión, de KYB, de entidad, de actividad y de transacción antes de activar el tráfico de producción.![Función de prueba de webhooks de Didit en 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)Envía webhooks de prueba directamente desde la Business Console para validar tu endpoint antes de pasar a producción.Puedes crear tantos destinos como necesites. Un patrón habitual es **un destino por consumidor**: por ejemplo, eventos de KYC → tu servicio de autenticación, eventos de KYB → tu servicio de operaciones de cumplimiento, transacciones → tu cola de fraude. Cada destino tiene su propio secreto, su propia versión y su propia lista de suscripciones, de modo que la rotación de un secreto o un despliegue defectuoso en un consumidor no afecta a los demás.## Ejemplos de códigoPara proteger tu endpoint de webhook, verifica la autenticidad de las solicitudes entrantes con la `secret_shared_key` del destino.> [!WARNING]
>
> **Usa `X-Signature-V2`.** Es la variante más resistente a la recodificación del middleware y autentica el cuerpo completo (incluido `decision`).### Node.js / Express (recomendado: 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 puedes leer los bytes sin procesar antes de que los toque cualquier parser, la variante original `X-Signature` funciona igual, pero firma el cuerpo sin procesar. La mantenemos en la sección heredada más abajo para que la documentación esté completa.### Python / FastAPI (recomendado: 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 (recomendado: X-Signature-V2)Los valores predeterminados de JSON en PHP **no** reproducen la forma canónica. Hay que sobrescribir cuatro de ellos, y basta con omitir uno solo para que cambien los bytes firmados y el digest nunca coincida:| Valor predeterminado de PHP | Qué genera | Qué necesitas |
| - | - | - |
| `json_decode($raw, true)` (asociativo) | los objetos vacíos `{}` se convierten en `[]` | decodificar con `$associative = false` para que los objetos JSON sigan siendo `stdClass` |
| `json_encode` escapa las barras | `https:\/\/media.didit.me` | `JSON_UNESCAPED_SLASHES` |
| `json_encode` escapa los caracteres no ASCII | `Jos\u00e9` en lugar de `José` | `JSON_UNESCAPED_UNICODE` |
| `ksort` compara como números las claves que parecen numéricas | `"9"` se ordena antes que `"10"` | `ksort($props, SORT_STRING)` |La trampa del objeto vacío es la que más problemas causa. Los payloads de Didit suelen incluir mapas vacíos como `risk_view.countries.risk_scores`, `risk_view.crimes.risk_scores` y `risk_view.custom_list`, así que una conversión de ida y vuelta con arrays asociativos rompe la firma en sesiones aprobadas normales, no en algún caso límite poco frecuente.```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]);
    }
}
```Otras dos cosas que conviene saber:- **Los floats con valor entero no necesitan ningún tratamiento especial en PHP.** Didit los serializa como enteros (`100.0` → `100`) y `json_encode` de PHP escribe un float entero de la misma forma, así que no hace falta ningún paso `shortenFloats`, a diferencia del ejemplo de Node.js anterior.
- **Mantén `serialize_precision` en `-1`** (el valor predeterminado desde PHP 7.1). Si tu hosting lo cambia a `17`, `json_encode` escribe `87.42` como `87.420000000000002` y todas las comprobaciones de `X-Signature-V2` fallan.Si no consigues que la forma canónica coincida, `verifySignatureRaw`, más arriba, es la alternativa más sencilla: `$request->getContent()` de Laravel devuelve los bytes intactos, así que `X-Signature` funciona siempre que nada delante de PHP recodifique el cuerpo. Ese es justo el caso que `X-Signature-V2` está diseñado para resistir, y por eso V2 se prueba primero.## Pruebas**No** necesitas ejecutar una verificación completa para probar tu endpoint.- Desde **Business Console > API & Webhooks > Try Webhook**, elige un escenario (por ejemplo, `approved_full_features`, `declined_face_mismatch`, `in_review_aml_hit`, `business_session_approved`, `transaction_created`, `activity_created`, `user_status_updated`) y envía un webhook completamente formado a la URL de tu destino.
- Cada escenario se firma con los mismos tres encabezados de firma y la misma codificación canónica que el tráfico de producción, incluidos datos de prueba con unicode (`approved_kyc_with_unicode`), para que puedas confirmar que `X-Signature-V2` funciona a través de tu middleware. Las entregas de prueba contienen datos de ejemplo y se marcan con un encabezado adicional `X-Didit-Test-Webhook: true` (y un sufijo `(Test)` en el `User-Agent`), para que puedas evitar que afecten al estado de producción.
- Si necesitas depurar una regresión, vuelve a enviar cualquier entrega anterior desde la pestaña **Deliveries** de un destino.Para pruebas de humo de extremo a extremo, ejecuta una verificación real con un flujo de verificación (workflow) de prueba en tu propia aplicación: los webhooks resultantes usan el mismo envoltorio, los mismos encabezados y la misma política de reintentos que los escenarios de **Try Webhook**.## Ejemplos### Sesión KYC aprobada (`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"
  }
}
```### Sesión rechazada con advertencias (`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": []
  }
}
```### Sesión de empresa aprobada (`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ón 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
}
```### Cambio de estado de un intercambio 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` contiene uno de los 13 estados de intercambio documentados en [Travel Rule](https://docs.didit.me/es/transaction-monitoring/travel-rule.md#ciclo-de-vida).
`rail` es `INTERNAL`, `TRP`, `GTR`, `TRUST`, `VERIFYVASP`, `SYGNA` o `EMAIL` (cadena vacía hasta que se selecciona un rail, si no se pudo determinar ninguno, y siempre en los registros sunrise), o `TEST_VASP` para una transferencia en sandbox a la que respondió el [Didit Test VASP](https://docs.didit.me/es/transaction-monitoring/travel-rule-sandbox.md), y `direction` es `INBOUND` u `OUTBOUND`.
Un evento de sandbox incluye `"environment": "sandbox"`, y un evento `TEST_VASP` incluye además `test_vasp: true` y `test_vasp_scenario` (el escenario que respondió).Cuando Didit crea una transacción para una transferencia de Travel Rule (una llamada a [`POST /v3/transactions/`](https://docs.didit.me/es/management-api/transactions/create), un [registro sunrise posterior al depósito](https://docs.didit.me/es/transaction-monitoring/travel-rule.md#registro-sunrise-posterior-al-depósito), una consulta `TRP` entrante o el tramo entrante del rail `INTERNAL`), envía primero el evento `transaction.created` de esa transacción y después los eventos `travel_rule.status.updated` de la transferencia.
Una regla que cambie el estado de la transacción durante ese paso también puede enviar `transaction.status.updated` antes de `transaction.created`, y un registro sunrise que se asocia a una transacción de depósito que ya creaste no envía `transaction.created`.
Cada webhook se entrega y se reintenta por separado, así que un evento reintentado puede llegar después de uno posterior: usa `transaction_id` como clave en tu handler y vuelve a consultar la transacción cuando necesites su estado actual.
Una transferencia enviada por TRUST nunca recibe un evento `COMPLETED`, porque TRUST no devuelve nada que Didit pueda leer: permanece en `AWAITING_COUNTERPARTY` hasta la fecha límite de confirmación y luego termina en `EXPIRED`. En GTR, VerifyVASP y Sygna Bridge, Didit lee la respuesta de la contraparte desde la red y el evento la refleja (consulta [Redes de Travel Rule](https://docs.didit.me/es/transaction-monitoring/travel-rule-networks.md)).### Cambio de cumplimiento del flujo (`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` y `transition.to` son uno de `compliant`, `gaps_detected`, `pending_change`, `unknown_coverage` o `not_evaluated`.
`severity` es `actionable` cuando se detectaron carencias, `indicative` para un cambio pendiente e `informative` cuando se subsanaron las carencias.
Didit envía un evento por cada flujo que cambió.### Sugerencias de reglas de transacciones creadas (`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` es `ADD` para una regla nueva, `TWEAK` para un cambio en una regla existente y `DISABLE` para desactivar una. `rule_id` es `null` para `ADD`.
`open_count` es el número de sugerencias que siguen abiertas después de la ejecución, incluidas las nuevas.
La configuración de sugerencias de reglas activa o desactiva este webhook; consulta [Webhooks de transacciones](https://docs.didit.me/es/transaction-monitoring/webhooks.md).### Eventos de Investigator (`investigator.*`)Los doce eventos `investigator.*` usan el mismo envoltorio (`event_id`, `webhook_type`, `timestamp`, `created_at`, `application_id`, `environment`) y los mismos encabezados de firma que cualquier otro evento. Solo contienen identificadores y cifras: nunca un nombre, un motivo escrito por un revisor, una narrativa ni nada que haya escrito el usuario final. No tienen `session_id`; el elemento al que se refieren se indica en `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 | Campos además del envoltorio |
| - | - |
| `investigator.report.ready` | `run_id`, `queue`, `item`, `recommendation`, `confidence`, `provenance`, `ready_to_accept`, `questions` (número) |
| `investigator.decision.automated` | `run_id`, `queue`, `item`, `recommendation`, `confidence`, `provenance`, `decided_by` (`"investigator"`), `outcome`, `action`, `final_status`, `agreed` |
| `investigator.decision.overridden` | Los mismos que `investigator.decision.automated`, con `decided_by` igual a `"human"`, más `override_category`, `overrode` (`"report"` o `"automated"`) y, cuando `overrode` es `"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"`); para `"spend_limit"`, también `month` (`YYYY-MM`) y `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` (códigos de riesgo), `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 qué versión de Investigator redactó el informe:- `engine` es la versión del motor de Investigator: un identificador opaco de ocho caracteres. Cambia cada vez que cambia el motor, así que dos informes con el mismo `engine` los redactó el mismo motor.
- `agent_version` es la versión del agente.
- `policy_version` es la versión de tu propia política que siguió el informe.
- `model_id` es un alias obsoleto que contiene el mismo valor que `engine`. Usa `engine` en su lugar.
- `prompt_hash` ya no se envía.`item.kind` indica el tipo de elemento (una sesión, una sesión de empresa, una transacción o un caso) e `item.id`, su identificador. Las marcas de tiempo que terminan en `_at` son cadenas ISO 8601. Para suscribirte a estos eventos, indica las cadenas exactas en `subscribed_events`, como con cualquier otro evento.### Cambio de estado de la entidad de usuario (`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"
}
```### Sesión 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" }
  }
}
```## Legado: X-Signature con el cuerpo sin procesar`X-Signature` se sigue enviando en cada entrega; es un HMAC-SHA256 calculado sobre los **bytes exactos** que transmite Didit (`json.dumps(..., sort_keys=True, separators=(",", ":"))`). Úsalo solo cuando puedas leer el cuerpo sin procesar antes de que cualquier middleware lo modifique.> [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}
> ```## Legado: formato de webhook V2> [!WARNING]
>
> Los campos siguientes solo se aplican cuando un destino está fijado a `webhook_version: "v2"`. También existe una fijación aún más antigua, `"v1"` (una tercera estructura de decisión, en singular), para destinos creados antes de V2: contacta con soporte antes de depender de ella. Las nuevas integraciones deben usar **V3** (la versión predeterminada), que se documenta más arriba y usa arrays en plural.| Aspecto | V2 | V3 |
| - | - | - |
| Encabezados de firma | `X-Signature`, `X-Signature-V2`, `X-Signature-Simple` (los tres se envían independientemente de la versión; Simple está [obsoleto](#x-signature-simple-está-obsoleto)) | Igual |
| Nombres de los campos de decisión | Objetos en singular (`id_verification`, `liveness`, `face_match`, ...) | Arrays en plural (`id_verifications`, `liveness_checks`, `face_matches`, ...) |
| Varias instancias de una función | No se admite | Se admite mediante arrays por función con `node_id` |
| Puntuaciones AML | `aml.score`, `aml.hits[].match_score`, `aml.hits[].risk_score` | Los mismos valores dentro de `aml_screenings[]` |Los objetos de cada función son los mismos en ambas versiones: un destino V2 recibe exactamente los mismos `match_score`, `risk_score`, `score_breakdown`, `risk_view` y `review_status` por coincidencia y el mismo `score` agregado que V3 (consulta el [informe AML](https://docs.didit.me/es/core-technology/aml-screening/report-aml-screening.md#tres-puntuaciones-no-un-único-veredicto)). Fijar un destino a V2 cambia dónde se ubica el informe AML dentro de `decision`, no lo que contiene.Si un consumidor posterior todavía necesita las estructuras de V2, crea un destino independiente fijado a `webhook_version: "v2"` y mantén tus nuevos consumidores en V3.## Relacionado- [API de destinos de webhook](https://docs.didit.me/es/management-api/webhook/list-destinations.md): lista, crea, actualiza y elimina destinos, e inspecciona los registros de entrega.
- [Crear destino de webhook](https://docs.didit.me/es/management-api/webhook/create-destination.md): esquema de solicitud y respuesta, incluido el `secret_shared_key` del destino.
- [Modelos de datos](https://docs.didit.me/es/reference/data-models.md): esquema canónico de cada objeto específico de cada función incluido en `decision`.
- [Esquema de decisión](https://docs.didit.me/es/sessions-api/retrieve-session.md): lista completa de campos del objeto `decision` incluido en los webhooks de sesión.
- [Estados de verificación](https://docs.didit.me/es/integration/verification-statuses.md): valores exactos de `status` y transiciones mencionados más arriba.
- [Límites de solicitudes](https://docs.didit.me/es/integration/rate-limiting.md): límites que se aplican al consultar periódicamente `GET /v3/session/{id}/decision/` como alternativa.


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