> ## 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 KYC/KYB em tempo real, assinatura HMAC

> Receba eventos de verificação, entidades e transações da Didit em tempo real. Assinatura HMAC-SHA256, múltiplos destinos e retentativas limitadas.

> [!NOTE]
>
> A atividade de autenticação e a entrega de webhooks correspondente, que depende da versão, estão documentadas em [Eventos de autenticação e webhooks](https://docs.didit.me/pt-br/authentication/events-and-webhooks.md). Não registre tipos de evento de autenticação não listados na API atual de destinos.

Prompt de integração de webhooks
        Copiar o prompt para a área de transferência



      
      

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



      
      

Colar em

## Visão geralWebhooks são a forma como a Didit envia **atualizações em tempo real** para o seu backend sempre que uma sessão de verificação, uma sessão de empresa, um usuário (vendor user), uma empresa (vendor business) ou uma transação muda de estado. Ao inscrever um ou mais destinos nos eventos que interessam a você, é possível reagir no momento em que um resultado fica pronto, normalmente poucos segundos depois de o usuário em verificação concluir o fluxo.Webhooks são o padrão de integração **recomendado**. Consultar periodicamente [`GET /v3/session/{id}/decision/`](https://docs.didit.me/pt-br/sessions-api/retrieve-session) é suportado como alternativa, mas é mais lento, consome mais requisições e deixa de fora eventos como `data.updated`, `transaction.status.updated` e alterações no nível de entidade, que são emitidos apenas por webhooks. Trate a entrega de webhooks como a fonte da verdade e faça consultas apenas na inicialização a frio ou para reconciliação.Este capítulo da aula de integração da Didit Academy retoma a partir da visão geral de webhooks.A aula de integração da Academy aborda webhooks a partir de 3:12: como adicionar um destino, escolher eventos e o segredo de assinatura que você vai verificar a seguir.[Visão geral de webhooks — Didit Academy](https://www.youtube.com/embed/fX03-WEu_EI?start=192\&rel=0\&playsinline=1)> [!NOTE]
>
> **Usuários de Cloudflare / WAF.** A Didit entrega webhooks a partir do IP estático `18.203.201.92` com `User-Agent: DiditWebhook/2.0 +https://didit.me`. Se a sua infraestrutura de borda bloquear clientes desconhecidos, libere esse IP em **Security > WAF > Tools > IP Access Rules** e escolha **Allow** para o hostname receptor.## Tipos de eventoUse o valor exato no array `subscribed_events` de um destino. Não há curinga: liste cada família que você quer receber. Você pode distribuir os eventos entre vários destinos.| Evento | Recurso | Quando é disparado |
| - | - | - |
| `status.updated` | Sessão KYC ou sessão de empresa KYB | O status de uma sessão muda (Não iniciada → Em andamento → Aprovada / Recusada / Em revisão / Abandonada / Reenviada). Os payloads de KYB incluem `session_kind: "business"` e `business_session_id`. |
| `data.updated` | Sessão KYC ou sessão de empresa KYB | Os dados de verificação são editados após a criação (correções manuais do revisor em KYC, POA, NFC, registro KYB, documentos KYB etc.). |
| `user.status.updated` | Entidade de usuário | O status de um usuário consolidado muda entre `ACTIVE`, `FLAGGED` e `BLOCKED` (ação no console, ação de regra, lista de bloqueio). Inclui `previous_status`. |
| `user.data.updated` | Entidade de usuário | O perfil, os contadores, os metadados ou os identificadores aprovados de um usuário consolidado mudam. Inclui `changed_fields` e um mapa `changes` com os valores `previous` / `current`. |
| `business.status.updated` | Entidade de empresa | O status de uma empresa consolidada muda entre `ACTIVE`, `FLAGGED` e `BLOCKED` (ação no console, ação de regra, lista de bloqueio). Inclui `previous_status`. |
| `business.data.updated` | Entidade de empresa | O perfil, os dados de registro, os contadores, os metadados ou os campos agregados de verificação de uma empresa consolidada mudam. Inclui `changed_fields` e `changes`. |
| `activity.created` | Linha do tempo de atividades | Reservado para o feed da linha do tempo de atividades. O valor é aceito em `subscribed_events` e coberto pelo testador **Try Webhook**, mas atualmente a Didit não entrega `activity.created` para tráfego de produção. |
| `transaction.created` | Monitoramento de transações | Uma transação é criada e o veredito inicial das regras está pronto. Inclui `transaction_id`, `txn_id`, `status`, `score`, `severity`, `amount`, `currency`, `direction`, `action_required`. |
| `transaction.status.updated` | Monitoramento de transações | O status de uma transação muda após avaliação de regras, ação de analista, remediação, correspondências na lista de bloqueio, atualizações do provedor ou ações via API/console. Também é enviado uma vez, com ou sem mudança de status, quando termina uma triagem de criptoativos que foi tentada novamente em segundo plano ([hashes de transação ainda não indexados](https://docs.didit.me/pt-br/transaction-monitoring/aml-screening.md#hashes-de-transação-ainda-não-indexados)). |
| `travel_rule.status.updated` | Travel Rule | O status de um intercâmbio de [Travel Rule](https://docs.didit.me/pt-br/transaction-monitoring/travel-rule.md) muda: na criação (incluindo a transição `NOT_APPLICABLE` abaixo do limiar), na confirmação de propriedade, na resposta à coleta por e-mail, na finalização/cancelamento ou na expiração por tempo limite. É disparado para transferências de saída e de entrada: a contraparte de entrada na rede `INTERNAL`, os registros sunrise e as consultas `TRP` de entrada. Para uma transação que a Didit cria, esses eventos são enviados depois do seu `transaction.created`. Inclui `transaction_id`, `txn_id`, `travel_rule_status`, `rail`, `direction`. |
| `workflow.compliance_changed` | Conformidade do fluxo de verificação (workflow) | O status de conformidade de um fluxo publicado mudou após uma reavaliação. Um evento por fluxo. Contém `workflow_id`, `workflow_version`, `workflow_label`, `transition` (`from` e `to`), `severity` e `kb_version`, e não contém `status`. |
| `transaction_rule_suggestions.created` | Monitoramento de transações | Uma execução de sugestão de regras propôs novas alterações de regras e as configurações de sugestão de regras estão com as notificações por webhook ativadas. Contém `run_id`, `open_count` e `suggestions[]`, e não contém `status`. |
| `investigator.report.ready` | Investigator | O Investigator escreveu um relatório sobre um item. |
| `investigator.decision.automated` | Investigator | O Investigator decidiu um item por conta própria. |
| `investigator.decision.overridden` | Investigator | Uma pessoa tomou uma ação diferente da indicada no relatório do Investigator ou anulou uma decisão que ele tomou por conta própria. |
| `investigator.decision.contested` | Investigator | O usuário final contestou uma decisão e pediu uma revisão humana. |
| `investigator.question.raised` | Investigator | O Investigator fez uma pergunta à equipe sobre a política dele. |
| `investigator.automation.paused` | Investigator | A automação parou de decidir, pausada por uma pessoa ou ao atingir o limite de gastos mensal. |
| `investigator.rfi.answered` | Investigator | O usuário final respondeu a uma solicitação de informações. |
| `investigator.rfi.overdue` | Investigator | Uma solicitação de informações passou do prazo sem resposta. |
| `investigator.drift.detected` | Investigator | Uma fila apresentou desvio e a automação desceu um nível. |
| `investigator.sla.breached` | Investigator | Um relatório permaneceu sem decisão após o SLA da sua fila. |
| `investigator.qa.failed` | Investigator | Uma decisão tomada por uma pessoa foi reprovada na revisão de qualidade do Investigator. |
| `investigator.external.report.ready` | Investigator | O relatório sobre um item que você enviou externamente à Didit está pronto. |
| `auth.signup` | Autenticação | Uma pessoa se cadastra pela [Autenticação](https://docs.didit.me/pt-br/authentication/events-and-webhooks.md). Inclui `auth_event` e o resumo `authentication` da pessoa. |
| `auth.login` | Autenticação | Uma pessoa faz login. Inclui `auth_event` e o resumo `authentication` da pessoa. |
| `auth.login_failed` | Autenticação | Uma tentativa de login falha. `vendor_data` é `null` quando a tentativa não indica nenhuma pessoa conhecida. Inclui `auth_event` e o resumo `authentication` da pessoa. |
| `auth.password_reset_requested` | Autenticação | Uma pessoa solicita a redefinição de senha. Inclui `auth_event` e o resumo `authentication` da pessoa. |
| `auth.password_reset_completed` | Autenticação | Uma pessoa conclui a redefinição de senha. Inclui `auth_event` e o resumo `authentication` da pessoa. |
| `auth.mfa_changed` | Autenticação | Uma pessoa adiciona ou altera um autenticador. Inclui `auth_event` e o resumo `authentication` da pessoa. |
| `auth.new_device` | Autenticação | Uma pessoa faz login em um dispositivo que nunca usou antes. Inclui `auth_event` e o resumo `authentication` da pessoa. |
| `auth.account_locked` | Autenticação | Uma política de segurança bloqueia a conta, ou sua equipe bloqueia a pessoa. Inclui `auth_event` e o resumo `authentication` da pessoa. |Os valores desta tabela são o conjunto completo de eventos que você pode assinar: não existe `session.status.updated`, `kyc.completed` nem nada parecido. A única outra entrega que a Didit faz é o [callback `kyb.registry_search.resolved`](#callback-de-busca-no-registro-kyb), sem assinatura, que vai para a `webhook_url` informada em cada requisição em [`POST /v3/kyb/search/`](https://docs.didit.me/pt-br/standalone-apis/kyb-registry), nunca para um destino.> [!TIP]
>
> O menu Business Console > API & Webhooks > **Try Webhook** envia payloads completos para cada um desses eventos (aprovado, recusado, em revisão, nomes com caracteres Unicode, KYC/KYB, entidade, atividade e transação). Use-o para validar seu endpoint antes de entrar em produção.## Formato do payload### EnvelopeTodo webhook compartilha o mesmo envelope. Os campos são sempre serializados com `sort_keys=True` e separadores compactos (`,` / `:`), para que o mesmo payload gere a mesma assinatura nos dois lados.| Campo | Tipo | Observações |
| - | - | - |
| `event_id` | `uuid` | Identificador estável do evento. O **mesmo** `event_id` é enviado a todos os destinos para os quais o evento é distribuído e é reutilizado nas novas tentativas: baseie sua idempotência nele. |
| `webhook_type` | string | Um dos [tipos de evento](#tipos-de-evento) acima. |
| `timestamp` | int (segundos Unix) | Quando a Didit enviou o webhook. Usado pela verificação de assinatura. Atualizado a cada nova tentativa. |
| `created_at` | int (segundos Unix) | Quando o registro subjacente foi atualizado pela última vez. |
| `application_id` | `uuid` | A aplicação dona do destino. |
| `environment` | `"live"` \| `"sandbox"` | Indica se o evento veio de uma sessão/aplicação de produção ou de sandbox. |
| `status` | string | Presente em todos os eventos de sessão, entidade e transação (incluindo `*.data.updated`). Os eventos `investigator.*` não trazem `status` (veja o payload do Investigator abaixo). Os eventos de sessão usam exatamente os [rótulos de status de verificação](https://docs.didit.me/pt-br/integration/verification-statuses.md) (por exemplo, `"Approved"`, `"Kyc Expired"`). Os eventos de entidade usam `ACTIVE` / `FLAGGED` / `BLOCKED`. Os eventos de transação usam `APPROVED` / `IN_REVIEW` / `DECLINED` / `AWAITING_USER`. |Nos eventos de **sessão** (`status.updated`, `data.updated` em sessões comuns e de empresas), o envelope também traz:| Campo | Tipo | Observações |
| - | - | - |
| `session_id` | `uuid` | O UUID da verificação de usuário ou da sessão de empresa. |
| `business_session_id` | `uuid` | Somente em KYB. Igual a `session_id`. |
| `session_kind` | `"business"` | Somente em eventos de KYB. Permite que um handler genérico siga por outra ramificação. |
| `workflow_id` | `uuid` | Fluxo usado para criar a sessão, quando definido. |
| `workflow_version` | int | Versão do fluxo. |
| `vendor_data` | string | Seu identificador interno, quando informado na criação da sessão. |
| `vendor_business_id` | string | Somente em sessões de KYB, quando informado. |
| `sandbox_scenario` | string \| null | Em todos os eventos de sessão (KYC e KYB). O slug do cenário de [sandbox](https://docs.didit.me/pt-br/integration/sandbox-testing.md) ativado (por exemplo, `"decline_aml_hit"`) em uma sessão de sandbox com um cenário ativado. `null` em uma sessão de produção ou em uma sessão de sandbox sem cenário ativado. Não aparece em webhooks de entidade ou de transação. |
| `metadata` | object | Os metadados que você anexou na criação da sessão, sem as chaves internas de deduplicação. |
| `trigger` | string | Opcional. Indica por que o webhook foi disparado quando não foi o próprio fluxo de verificação que o gerou: `"manual_review"` (edições de um revisor no console), `"manual_step_update"` (um revisor alterou o status de um recurso), `"ongoing_monitoring"` (atualização do monitoramento AML contínuo), `"webhook_reconciliation"` (um `status.updated` reenviado porque o primeiro webhook terminal nunca foi criado; por isso você pode receber um segundo evento para uma decisão que já recebeu: deduplique por `session_id` e `status`), `"manual_webhook_recovery"` (um webhook recuperado manualmente), `"kyb_registry_change"` (o registro de empresas informou uma alteração), `"console_registry_edit"`, `"console_key_people_skip"` e `"console_key_people_regenerate"` (edições feitas no Console em uma sessão de empresa) e `"biometric_retention"` (um `data.updated` enviado quando os dados biométricos de prova de vida da sessão são excluídos conforme o agendamento). Esse conjunto pode crescer, então trate um valor desconhecido como informativo. |
| `decision` | object | Presente quando `status` é `Approved`, `Declined`, `In Review` ou `Abandoned`. Reflete a [decisão de sessão da V3](https://docs.didit.me/pt-br/sessions-api/retrieve-session.md). |
| `resubmit_info` | `{ nodes_to_resubmit, reasons }` | Presente quando `status` é `Resubmitted` (sem `decision`). |
| `decided_by` | string | Somente em `status.updated` quando o status é `Approved`, `Declined` ou `Resubmitted` e a aplicação usa o Investigator. `"investigator"` quando o Investigator tomou a decisão sozinho, `"human"` quando um usuário do console a tomou (inclusive ao aceitar o relatório do Investigator). Omitido quando nenhum revisor registrou a decisão, quando ela foi tomada por uma chave de API ou quando o Investigator não está em uso. |### Exemplo de envelope de sessão```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]
>
> O objeto `decision` tem o mesmo schema retornado por [`GET /v3/session/{id}/decision/`](https://docs.didit.me/pt-br/sessions-api/retrieve-session). Para a referência canônica de campos por recurso (todas as propriedades de `id_verifications[]`, `aml_screenings[]` etc.), consulte [Modelos de dados](https://docs.didit.me/pt-br/reference/data-models.md). Não duplicamos de propósito as tabelas de campos por recurso aqui, para que o schema fique em um único lugar.### Arrays no plural da V3 (importante)Dentro de `decision`, cada resultado por recurso é um **array no plural**, para que um único fluxo possa incluir várias instâncias do mesmo recurso (por exemplo, várias verificações de identidade em documentos diferentes):```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 item traz um `node_id` correspondente ao grafo do fluxo, além de um `status` por recurso. **Não** leia os campos legados no singular da V2 (`id_verification`, `nfc`, `liveness`, `face_match`, `phone`, `email`, `poa`, `aml`, `ip_analysis`, `database_validation`). Eles só aparecem se um destino estiver fixado explicitamente em `webhook_version: "v2"`.Os resultados de **Assinatura eletrônica** ficam em `signatures[]`, presente somente quando o fluxo tem uma etapa de Assinatura eletrônica: um item por signatário, com o signatário, a confirmação por código, o certificado, o carimbo de data e hora, os hashes e caminhos de download de cada documento e o PDF de evidências. Não existe um evento de assinatura separado: o `status.updated` indica quando lê-los. Consulte o [relatório de Assinatura eletrônica](https://docs.didit.me/pt-br/core-technology/electronic-signature/report-electronic-signature.md).Os resultados de **Verificação bancária** ficam em `bank_verifications[]`, com um item por etapa de Verificação bancária. O `method` de cada item indica como a conta foi verificada: `open_banking` (login no banco), `account_lookup` (verificação da conta) ou uma alternativa `statement` ou `microdeposits`. O valor é `null` até que uma conta seja avaliada. Quando a pessoa escolheu **Continue without your bank**, `continued_without_bank` é `true` e `continuation` indica o motivo. Nesse caso, `method` é `none`, a menos que uma tentativa tenha sido avaliada antes: consulte [Quando a pessoa não consegue verificar o banco](https://docs.didit.me/pt-br/core-technology/bank-verification/fallbacks.md#veja-no-resultado). Uma conta verificada por verificação da conta não lê dados financeiros, então `financial_data.not_collected` lista os saldos, transações ou rendimentos que a etapa solicitou. Consulte [Métodos de verificação bancária](https://docs.didit.me/pt-br/core-technology/bank-verification/verification-methods.md#leia-o-método-no-resultado).### Bloco do InvestigatorUma `decision` V3 de uma sessão de usuário ou de empresa também traz `investigator`: a revisão mais recente da sessão feita pelo [Investigator](https://docs.didit.me/pt-br/console/investigator.md), ou `null` quando a sessão nunca teve uma.| Campo | Tipo | Significado |
| - | - | - |
| `run_id` | uuid | A revisão. Leia o relatório completo em `GET /v3/investigator/runs/{run_id}/`. |
| `status` | string | `running`, `completed` ou `failed`. |
| `stage` | string | `shadow` ou `live` no momento em que a revisão foi executada. |
| `queue` | string | `kyc`, `kyb`, `aml`, `transactions` ou `cases`. |
| `is_open` | boolean | A revisão ainda aguarda uma decisão. |
| `recommendation` | string \| null | `approve`, `decline`, `escalate` ou `request`. |
| `recommendation_detail` | string \| null | Uma linha sobre a ação recomendada. |
| `confidence` | string \| null | `high`, `moderate` ou `low`. |
| `summary` | string \| null | O resumo do relatório. |
| `ready_to_accept` | boolean | O relatório pode ser aceito como está. |
| `prepared_at` | datetime \| null | Quando o relatório ficou pronto. |
| `automated` | boolean | O Investigator decidiu por conta própria, mesmo que alguém tenha revertido a decisão depois. |
| `decided_by` | string \| null | `investigator` enquanto a decisão dele prevalecer, `member` caso contrário, `null` enquanto não houver decisão. |
| `decision` | object \| null | Como o item saiu da fila: `outcome`, `action`, `final_status`, `agreed`, `decided_at`, `billed` e quem decidiu. |Os recursos de coleta de documentos, **Documentos KYB** e **Document AI**, não têm um array dedicado no plural: eles aparecem na lista `features[]` da decisão como `{ "feature": "DOCUMENT_AI", "node_id": "…" }` (uma entrada por nó). Veja [Document AI](https://docs.didit.me/pt-br/core-technology/document-ai/overview.md).As decisões de Verificação de usuários também trazem um único objeto `biometric_consent`: se a sessão exigiu consentimento biométrico explícito, o `trigger`, o `status` da resposta e a evidência correspondente. `status: "pending"` com `reason: "BIOMETRIC_CONSENT_UNSUPPORTED_SDK"` significa que um SDK nativo anterior à versão 4.10.0 capturou um rosto sem a etapa de consentimento. Veja [Consentimento biométrico](https://docs.didit.me/pt-br/getting-started/biometric-consent.md).### Chaves do método de Verificação de identidadeCada item de `id_verifications[]` informa qual [método de Verificação de identidade](https://docs.didit.me/pt-br/core-technology/id-verification/verification-methods.md) o gerou, para que um handler possa ramificar conforme a evidência que de fato recebeu:| Chave | Valor |
| - | - |
| `verification_method` | `document`, `id_lookup` ou `wallet` |
| `assurance` | `documentary`, `data_match` ou `cryptographic` |
| `wallet_provider` | ID da carteira no catálogo quando uma carteira gerou o item, caso contrário `null` |
| `id_lookup` | Evidência do registro quando o método foi `id_lookup`, caso contrário `null` |
| `wallet_verification` | Evidência da credencial quando o método foi `wallet`, caso contrário `null` |
| `fallback_from` | Por que um método que não é de documento encerrou a etapa de identificação, quando ele recusou a sessão |Os handlers existentes continuam funcionando sem alterações: uma sessão de documento define `verification_method` como `document` e deixa os demais como `null`. Os tipos campo a campo estão em [Modelos de dados](https://docs.didit.me/pt-br/reference/data-models.md#verificação-de-identidade).#### Sessão de carteira 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 de eventos de entidade```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"
}
```Os status de entidade são `ACTIVE`, `FLAGGED` ou `BLOCKED`. `user.data.updated` e `business.data.updated` mantêm o campo `status` e adicionam um array `changed_fields` e um mapa `changes` no formato `{ field: { previous, current } }`. Os eventos `business.*` usam `vendor_business_id` em vez de `vendor_user_id`.### Payloads de eventos de transaçãoOs webhooks de transação usam a mesma infraestrutura de distribuição (fan-out) e o mesmo envelope, mas identificam a transação em vez de uma sessão:```json theme={null}
{
  "event_id": "uuid",
  "webhook_type": "transaction.created",
  "timestamp": 1774970000,
  "created_at": 1774970000,
  "application_id": "uuid",
  "environment": "live",
  "transaction_id": "uuid",
  "txn_id": "finance0001",
  "status": "APPROVED",
  "score": 0,
  "severity": "UNKNOWN",
  "amount": "1200.00",
  "currency": "EUR",
  "direction": "OUTBOUND",
  "action_required": null
}
````action_required` é `null` ou a ação pendente do usuário (`type` `wallet_ownership` ou `verification_session`, com a respectiva `url`) descrita em [Ações exigidas do usuário](https://docs.didit.me/pt-br/transaction-monitoring/sdk-transaction-submission.md#ações-exigidas-do-usuário).Novas transações têm `APPROVED` como padrão. Se uma regra, lista de bloqueio ou fluxo alterar o status durante a criação, o payload inicial de `transaction.created` reflete esse status final. `transaction.status.updated` traz o mesmo formato com o `status` atualizado. Os status de transação são `APPROVED`, `IN_REVIEW`, `DECLINED` ou `AWAITING_USER`; `severity` é `UNKNOWN` / `LOW` / `MEDIUM` / `HIGH` / `CRITICAL` e `direction` é `INBOUND` / `OUTBOUND`. `transaction.created` e `transaction.status.updated` são entregues apenas para aplicações **em produção**. As trocas de Travel Rule em sandbox emitem, sim, `travel_rule.status.updated` com `environment: sandbox`.### Callback de busca no registro KYBO fluxo assíncrono de [`POST /v3/kyb/search/`](https://docs.didit.me/pt-br/standalone-apis/kyb-registry) entrega um payload extra que **não** é um evento de destino: quando você passa uma `webhook_url` na requisição de busca, a Didit envia um POST com o callback `kyb.registry_search.resolved` para essa URL assim que a consulta ao registro é concluída. Ele difere dos webhooks de destino em três aspectos:- Ele é **sem assinatura**: não tem cabeçalhos `X-Signature*`. Em vez disso, a Didit o marca com `X-Didit-Unsigned-Callback: true`; valide-o comparando o `request_id` com o ID retornado pela requisição de busca.
- O nome do evento fica em `event_type` (e não em `webhook_type`), e não há `application_id` nem `environment`.
- `timestamp` e `created_at` são inteiros epoch, como nos outros webhooks, mas diferentes do `created_at` em ISO do endpoint de busca.```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
}
```O callback segue a mesma [política de novas tentativas](#política-de-novas-tentativas) dos webhooks de destino (as novas tentativas atualizam o `timestamp` e continuam sem assinatura). Veja a [API de registro KYB](https://docs.didit.me/pt-br/standalone-apis/kyb-registry.md) para o schema completo da lista de candidatos.## Verificação de assinaturaTodo webhook é assinado com HMAC-SHA256 usando a `secret_shared_key` do destino. São enviados três cabeçalhos de assinatura para que você escolha aquele que funcione com sua stack:| Cabeçalho | O que é assinado | Seguro com middleware | Quando usar |
| - | - | - | - |
| `X-Signature-V2` | JSON compacto, com chaves ordenadas e Unicode preservado (`ensure_ascii=False`) | Sim | **Recomendado**: funciona mesmo que seu framework recodifique o corpo. |
| `X-Signature` | Os bytes brutos exatos da requisição, como a Didit os transmitiu (`sort_keys`, `ensure_ascii=True`) | Não | Somente quando você consegue ler o corpo bruto antes que qualquer parser o processe. |
| `X-Signature-Simple` **(obsoleto)** | `"{timestamp}:{session_id}:{status}:{webhook_type}"` | Sim | Alternativa de último recurso que autentica **somente o envelope**: ela **não** autentica `decision`. Leia o aviso de descontinuação abaixo antes de usá-la. |Cabeçalhos que a Didit sempre envia:| Cabeçalho | Exemplo | Significado |
| - | - | - |
| `Content-Type` | `application/json` | O corpo é JSON. |
| `User-Agent` | `DiditWebhook/2.0 +https://didit.me` | Identifica o worker de webhooks da Didit. |
| `X-Timestamp` | `1774970000` | Segundos desde a época Unix em que o webhook foi despachado. Rejeite se `abs(now - X-Timestamp) > 300`. |
| `X-Signature` / `X-Signature-V2` / `X-Signature-Simple` | digests em hexadecimal | HMAC-SHA256 do corpo ou da string canônica descrita acima. |Por 24 horas após a rotação do segredo de assinatura de um destino, as entregas também incluem `X-Signature-Previous`, `X-Signature-V2-Previous` e `X-Signature-Simple-Previous` (consulte [Rotação do segredo de assinatura](#rotação-do-segredo-de-assinatura)).### Rotação do segredo de assinaturaVocê faz a rotação do segredo de assinatura de um destino no Business Console.
Por 24 horas após uma rotação, toda entrega para esse destino inclui mais três cabeçalhos, gerados com o segredo que foi substituído:| Cabeçalho | Mesma assinatura que | Assinado com |
| - | - | - |
| `X-Signature-V2-Previous` | `X-Signature-V2` | O segredo substituído |
| `X-Signature-Previous` | `X-Signature` | O segredo substituído |
| `X-Signature-Simple-Previous` | `X-Signature-Simple` (obsoleto) | O segredo substituído |Um handler que aceita a correspondência tanto em um cabeçalho de assinatura quanto no seu cabeçalho `-Previous` continua verificando enquanto você implanta o novo segredo: o segredo antigo corresponde ao cabeçalho `-Previous`, e o novo corresponde ao cabeçalho normal.
Os exemplos de código abaixo fazem isso.
Implante o novo segredo dentro das 24 horas, porque, depois disso, somente o novo segredo valida as assinaturas.
O campo `previous_secret_expires_at` em [`GET /v3/webhook/destinations/{destination_uuid}/`](https://docs.didit.me/pt-br/management-api/webhook/get-destination) informa quando as 24 horas terminam.Novas tentativas e reenvios são assinados novamente com o segredo atual do destino no momento do envio, e incluem os cabeçalhos `-Previous` somente enquanto durarem as 24 horas.
Fazer uma nova rotação dentro das 24 horas desativa imediatamente o segredo mais antigo: somente o segredo que era o atual até a nova rotação continua validando as assinaturas dos cabeçalhos `-Previous`, por 24 horas a partir da nova rotação.Os cabeçalhos `-Previous` existem somente para destinos de webhook.
A `secret_shared_key` no nível da aplicação, que assina o `webhook_url` legado, não tem período de sobreposição: fazer a rotação dela com `rotate_secret_key` em [`PATCH /v3/webhook/`](https://docs.didit.me/pt-br/sessions-api/management-api#configuração-de-webhook) invalida o segredo antigo imediatamente.### `X-Signature-Simple` está obsoleto> [!WARNING]
>
> **`X-Signature-Simple` autentica o envelope, não o payload.** Sua string canônica cobre somente `timestamp`, `session_id`, `status` e `webhook_type`. Ela **não** cobre `decision`, nem `resubmit_info`, `metadata`, `vendor_data` ou `event_id`. Uma requisição cujo `decision` foi reescrito, mantendo intactos esses quatro campos, ainda passa na verificação de `X-Signature-Simple`.
>
> Ela é ainda mais fraca nos eventos que não incluem nenhum `session_id`. Eventos de transação (`transaction_id`, `txn_id`, `amount`, `score`) e eventos de entidade (`vendor_user_id`, `vendor_business_id`) assinam `"{timestamp}::{status}:{webhook_type}"`, que não identifica **qual** transação ou entidade é o objeto do evento: duas transações diferentes informadas com o mesmo status no mesmo segundo têm o mesmo `X-Signature-Simple`.
> Os eventos `travel_rule.status.updated`, `workflow.compliance_changed` e `transaction_rule_suggestions.created` também não incluem `status`, então assinam `"{timestamp}:::{webhook_type}"`, por exemplo `"{timestamp}:::travel_rule.status.updated"`.
>
> Se `X-Signature-Simple` for o único cabeçalho que você verifica, trate tudo fora desses quatro campos como **não confiável** e busque novamente o registro na API, que é a fonte oficial dos dados: [`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/pt-br/sessions-api/retrieve-session) para uma sessão, [`GET /v3/transactions/{transaction_id}/`](https://docs.didit.me/pt-br/management-api/transactions/get) para uma transação.Esta é uma restrição de compatibilidade, não algo que possamos corrigir sem quebrar a compatibilidade. A string canônica de quatro campos é um contrato de comunicação: todo receptor que verifica `X-Signature-Simple` recalcula exatamente esses quatro campos, então mudar o que o cabeçalho assina quebraria silenciosamente todos eles na verificação de assinatura. Cobrir o corpo também exige uma forma canônica especificada do JSON que os dois lados reproduzam byte a byte, e a Didit já oferece uma como `X-Signature-V2`. Em vez de adicionar uma segunda variante que cubra o corpo, com suas próprias regras de canonicalização, o cabeçalho permanece exatamente como está e fica obsoleto em favor das variantes existentes que cobrem o payload completo.O que isso significa para você:- **Nada muda na comunicação.** `X-Signature-Simple` continua sendo enviado em toda entrega, calculado da mesma forma. Os verificadores existentes continuam funcionando, e anunciaremos qualquer remoção com bastante antecedência.
- **Novas integrações devem usar `X-Signature-V2`.** Ele é seguro com middleware *e* cobre o payload inteiro.
- **Se você não puder deixar de usar `X-Signature-Simple`**, verifique-o e depois busque o registro na API, em vez de lê-lo do corpo do webhook.### Por que três variantes?Alguns frameworks web (parsers de corpo do Express, middleware do Django, API gateways) recodificam o JSON silenciosamente antes que seu handler o leia. Se o corpo original continha `"José"` e seu middleware o reescreve como `"José"`, os bytes mudam, embora os dados sejam idênticos. Nesse caso, `X-Signature` falha. `X-Signature-V2` é calculado a partir de uma forma canônica do JSON (chaves ordenadas, separadores compactos, **Unicode sem escape**) que quase todo middleware reproduz de forma determinística, e o obsoleto `X-Signature-Simple` recorre a assinar somente uma string curta, independente do parser, quando até isso falha. Como ele assina somente essa string curta e nunca o corpo, não pode garantir a integridade do corpo.### Ordem de verificação recomendada1) Tente primeiro o **`X-Signature-V2`**. Recodifique `JSON.parse(body)` com as chaves ordenadas e os caracteres Unicode preservados, calcule `HMAC-SHA256(secret, canonical)` e compare o resultado com o cabeçalho usando `timingSafeEqual`.
2) Se a V2 falhar e você conseguir ler os bytes brutos, tente o **`X-Signature`** com `HMAC-SHA256(secret, rawBody)`.
3) Somente se ambas falharem, recorra ao **`X-Signature-Simple`**, que está obsoleto, com `HMAC-SHA256(secret, "{timestamp}:{session_id}:{status}:{webhook_type}")`. Ele autentica apenas o envelope: trate a `decision` do corpo como não confiável e busque-a novamente em [`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/pt-br/sessions-api/retrieve-session) antes de agir com base nela.Em cada etapa, quando o cabeçalho `-Previous` correspondente estiver presente, aceite uma correspondência em qualquer um dos dois cabeçalhos (consulte [Rotação do segredo de assinatura](#rotação-do-segredo-de-assinatura)).Em todos os casos, **rejeite** qualquer requisição com mais de **5 minutos** (`abs(now - X-Timestamp) > 300`) para se proteger contra ataques de repetição.## Política de novas tentativasSe o seu endpoint responder com `5xx` ou `404`, a Didit tenta novamente até **2 vezes**, com backoff exponencial:- **1ª nova tentativa**: cerca de **1 minuto** após a falha inicial.
- **2ª nova tentativa**: cerca de **4 minutos** após a 1ª nova tentativa.Após a segunda nova tentativa, a entrega é descartada. Cada tentativa de entrega (a inicial e as novas tentativas) é registrada como uma entrada separada no Business Console, na aba **Deliveries** do destino, para que você possa reenviar ou inspecionar qualquer tentativa.Cada tentativa no registro de entregas de um destino tem um número `attempt`: `1` para a primeira entrega de um evento e, para cada nova tentativa ou reenvio, um a mais que o número da tentativa que ele repete.
As novas tentativas automáticas vão para a URL para a qual a primeira tentativa foi enviada, mesmo que você tenha alterado a `url` do destino depois disso.
Um reenvio manual vai para a URL atual do destino, então corrigir uma URL também corrige o reenvio.Outras regras de entrega que você deve conhecer:- A requisição HTTP de saída atinge o tempo limite após **5 segundos**.
- A Didit bloqueia entregas para URLs privadas ou de localhost como proteção contra SSRF e nunca segue redirecionamentos. Use um endpoint HTTPS público que responda diretamente.
- Uma `url` de destino que contenha uma barra invertida literal, um caractere de controle ou mais de um `@` na autoridade é rejeitada como malformada: uma URL assim não pode ser criada, e uma já existente não pode ter entregas reenviadas nem repetidas. Use uma URL simples `https://host/path` com no máximo uma seção de userinfo (para URLs de webhook no estilo basic auth).
- `2xx` é tratado como sucesso. Timeouts e falhas de conexão contam como passíveis de nova tentativa (aparecem como `504`/`503` no registro de entregas); respostas `3xx` e `4xx` diferentes de `404` **não** geram novas tentativas.
- Em cada nova tentativa, a Didit recalcula as assinaturas com um `X-Timestamp` novo, de modo que o par timestamp/assinatura sempre corresponde.
- Novas tentativas e reenvios são assinados com o segredo de assinatura atual do destino no momento em que são enviados.### Ordem de entregaA Didit não garante a ordem em que os webhooks chegam até você.
Cada entrega entra na fila de forma independente e uma nova tentativa chega minutos após a primeira tentativa, então um `status.updated` para `Approved` pode chegar antes de um anterior para `In Progress`.Construa o seu receptor levando isso em conta:- Trate um webhook como um sinal de que algo mudou. Quando a ordem importar, chame [`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/pt-br/sessions-api/retrieve-session) e aja com base no `status` atual.
- Nunca permita que um evento mais antigo sobrescreva um estado mais recente: compare o `created_at` do evento com o do último que você armazenou para aquele registro e ignore o mais antigo.
- Identifique repetições pelo `event_id`, que permanece o mesmo em todas as novas tentativas.A Didit coloca na fila os eventos de uma transferência de Travel Rule em uma ordem definida, descrita em [Status da troca de Travel Rule alterado](#status-do-intercâmbio-da-travel-rule-alterado-travel_rule-status-updated), mas uma nova tentativa ainda pode alterar a ordem em que eles chegam.## Configurar um destinoVocê pode gerenciar destinos em **Business Console > API & Webhooks** ou pela Management API.**1. Crie o destino**Chame [`POST /v3/webhook/destinations/`](https://docs.didit.me/pt-br/management-api/webhook/create-destination) (ou clique em **Add destination** no console) com um `label`, uma `url` pública, `webhook_version` (`"v3"`) e um array `subscribed_events` contendo todas as famílias de eventos que esse endpoint deve receber. É necessário pelo menos um evento, e não existe curinga.**2. Armazene o secret\_shared\_key**A resposta da criação retorna o `secret_shared_key` do destino. Armazene-o junto com os outros segredos do seu servidor: ele pertence apenas a esse destino e é a entrada das três variantes de assinatura HMAC-SHA256. [`GET /v3/webhook/destinations/{destination_uuid}/`](https://docs.didit.me/pt-br/management-api/webhook/get-destination) o retorna novamente para quem chama a API com uma chave de API e para usuários do console que podem ler webhooks.**3. Implemente o endpoint**Exponha uma rota `POST` HTTPS pública, leia o corpo bruto, verifique a assinatura (dando preferência ao `X-Signature-V2`), verifique se o timestamp está dentro da janela permitida e, em seguida, faça o despacho com base no `webhook_type`. Retorne `2xx` assim que tiver colocado o trabalho na fila; faça o processamento pesado de forma assíncrona.**4. Confirme os recebimentos**Acompanhe a aba **Deliveries** do destino. Webhooks em bom funcionamento retornam `200` em um ou dois segundos. `5xx`/`404` aciona a política de novas tentativas da Didit; falhas persistentes são descartadas após a segunda tentativa.**5. Itere com o Try Webhook**Use os cenários do [**Try Webhook**](#testes) no console para fazer o seu endpoint processar payloads de aprovado, recusado, em revisão, KYB, entidade, atividade e transação antes de ativar o tráfego de produção.![Recurso de teste de webhooks da Didit no 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)Envie webhooks de teste diretamente do Business Console para validar o seu endpoint antes de entrar em produção.Você pode criar quantos destinos precisar. Um padrão comum é **um destino por consumidor**: por exemplo, eventos de KYC → o seu serviço de autenticação, eventos de KYB → o seu serviço de operações de conformidade, transações → a sua fila de fraude. Cada destino tem o seu próprio segredo, versão e lista de eventos aos quais está inscrito, então um segredo rotacionado ou um deploy com problema em um consumidor não afeta os demais.## Exemplos de códigoPara garantir a segurança do seu endpoint de webhook, verifique a autenticidade das requisições recebidas usando a `secret_shared_key` do destino.> [!WARNING]
>
> **Use `X-Signature-V2`.** É a variante mais resistente à recodificação por middleware e autentica o corpo por completo (incluindo `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}`));
```Se você consegue ler os bytes brutos antes que qualquer parser os processe, a variante original `X-Signature` funciona da mesma forma, mas assina o corpo bruto. Ela é mantida na seção legada abaixo para fins de completude.### 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)Os padrões de JSON do PHP **não** reproduzem a forma canônica. Quatro deles precisam ser substituídos, e esquecer qualquer um deles altera os bytes assinados, de modo que o digest nunca coincide:| Padrão do PHP | O que ele gera | O que você precisa |
| - | - | - |
| `json_decode($raw, true)` (associativo) | objetos vazios `{}` viram `[]` | decodifique com `$associative = false` para que os objetos JSON continuem como `stdClass` |
| `json_encode` escapa barras | `https:\/\/media.didit.me` | `JSON_UNESCAPED_SLASHES` |
| `json_encode` escapa caracteres não ASCII | `Jos\u00e9` em vez de `José` | `JSON_UNESCAPED_UNICODE` |
| `ksort` compara chaves com aparência numérica como números | `"9"` é ordenado antes de `"10"` | `ksort($props, SORT_STRING)` |A armadilha do objeto vazio é a que mais causa problemas. Os payloads da Didit trazem com frequência mapas vazios como `risk_view.countries.risk_scores`, `risk_view.crimes.risk_scores` e `risk_view.custom_list`, então uma conversão de ida e volta associativa quebra a assinatura em sessões aprovadas comuns, e não em algum caso extremo raro.```php theme={null}
<?php

use Illuminate\Http\Request;

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

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

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

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

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

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

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

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

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

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

        // Dispatch on $body['webhook_type']; return 2xx fast.
        return response()->json(['ok' => true]);
    }
}
```Mais duas coisas que vale a pena saber:- **Floats com valor inteiro não precisam de tratamento especial no PHP.** A Didit os serializa como inteiros (`100.0` → `100`), e o `json_encode` do PHP escreve um float inteiro da mesma forma, então nenhuma etapa `shortenFloats` é necessária, ao contrário do exemplo de Node.js acima.
- **Mantenha `serialize_precision` em `-1`** (o padrão desde o PHP 7.1). Se o seu servidor o substituir para `17`, o `json_encode` escreve `87.42` como `87.420000000000002` e toda verificação de `X-Signature-V2` falha.Se você não conseguir fazer a forma canônica corresponder, `verifySignatureRaw` acima é a alternativa mais simples: o `$request->getContent()` do Laravel retorna os bytes intactos, então `X-Signature` funciona desde que nada à frente do PHP recodifique o corpo. Esse é exatamente o cenário ao qual `X-Signature-V2` foi projetado para resistir, e é por isso que a V2 é testada primeiro.## TestesVocê **não** precisa executar uma verificação completa para testar o seu endpoint.- Em **Business Console > API & Webhooks > Try Webhook**, escolha um cenário (por exemplo, `approved_full_features`, `declined_face_mismatch`, `in_review_aml_hit`, `business_session_approved`, `transaction_created`, `activity_created`, `user_status_updated`) e envie um webhook completo para a URL do seu destino.
- Todo cenário é assinado com os mesmos três cabeçalhos de assinatura e a mesma codificação canônica do tráfego de produção, incluindo dados de teste em unicode (`approved_kyc_with_unicode`), para que você possa confirmar que `X-Signature-V2` funciona através do seu middleware. As entregas de teste trazem dados de exemplo e são marcadas com um cabeçalho extra `X-Didit-Test-Webhook: true` (e um sufixo `(Test)` no `User-Agent`), para que você possa impedir que elas afetem o estado de produção.
- Reenvie qualquer entrega anterior pela aba **Deliveries** de um destino se precisar depurar uma regressão.Para testes de fumaça de ponta a ponta, execute uma verificação real por meio de um workflow de teste na sua própria aplicação. Os webhooks resultantes usam o mesmo envelope, os mesmos cabeçalhos e a mesma política de novas tentativas dos cenários de **Try Webhook**.## Exemplos### Sessão 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"
  }
}
```### Sessão recusada com avisos (`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": []
  }
}
```### Sessão de empresa 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": []
  }
}
```### Transação criada (`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
}
```### Status do intercâmbio da Travel Rule alterado (`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` traz um dos 13 status de intercâmbio documentados em [Travel Rule](https://docs.didit.me/pt-br/transaction-monitoring/travel-rule.md#ciclo-de-vida).
`rail` é `INTERNAL`, `TRP`, `GTR`, `TRUST`, `VERIFYVASP`, `SYGNA` ou `EMAIL` (string vazia até que um canal seja selecionado, quando nenhum pôde ser determinado e sempre em registros sunrise), ou `TEST_VASP` para uma transferência de sandbox respondida pelo [Didit Test VASP](https://docs.didit.me/pt-br/transaction-monitoring/travel-rule-sandbox.md), e `direction` é `INBOUND` ou `OUTBOUND`.
Um evento de sandbox traz `"environment": "sandbox"`, e um evento `TEST_VASP` também traz `test_vasp: true` e `test_vasp_scenario` (o cenário que respondeu).Quando a Didit cria uma transação para uma transferência Travel Rule (uma chamada a [`POST /v3/transactions/`](https://docs.didit.me/pt-br/management-api/transactions/create), um [registro sunrise após o depósito](https://docs.didit.me/pt-br/transaction-monitoring/travel-rule.md#registro-sunrise-após-o-depósito), uma consulta `TRP` de entrada ou a perna de entrada do canal `INTERNAL`), ela envia primeiro o evento `transaction.created` dessa transação e, depois dele, os eventos `travel_rule.status.updated` da transferência.
Uma regra que altera o status da transação durante essa etapa também pode enviar `transaction.status.updated` antes de `transaction.created`, e um registro sunrise vinculado a uma transação de depósito que você já criou não envia `transaction.created`.
Cada webhook é entregue e reenviado de forma independente, então um evento reenviado ainda pode chegar depois de um evento posterior: use `transaction_id` como chave no seu handler e consulte a transação novamente quando precisar do estado atual dela.
Uma transferência enviada pelo TRUST nunca recebe um evento `COMPLETED`, porque o TRUST não devolve nada que a Didit consiga ler: ela permanece em `AWAITING_COUNTERPARTY` até o prazo de confirmação e então termina como `EXPIRED`. No GTR, no VerifyVASP e no Sygna Bridge, a Didit lê a resposta da contraparte na rede e o evento a acompanha (consulte [Redes Travel Rule](https://docs.didit.me/pt-br/transaction-monitoring/travel-rule-networks.md)).### Conformidade do fluxo alterada (`workflow.compliance_changed`)```json theme={null}
{
  "event_id": "9c0c8b8a-1111-4222-9333-777777777777",
  "webhook_type": "workflow.compliance_changed",
  "timestamp": 1774970000,
  "created_at": 1774970000,
  "application_id": "11111111-2222-3333-4444-555555555555",
  "environment": "live",
  "workflow_id": "22222222-3333-4444-5555-666666666666",
  "workflow_version": 4,
  "workflow_label": "KYC standard",
  "transition": { "from": "compliant", "to": "gaps_detected" },
  "severity": "actionable",
  "kb_version": "2026-10"
}
````transition.from` e `transition.to` são um dos valores `compliant`, `gaps_detected`, `pending_change`, `unknown_coverage` ou `not_evaluated`.
`severity` é `actionable` quando foram detectadas lacunas, `indicative` para uma mudança pendente e `informative` quando as lacunas foram resolvidas.
A Didit envia um evento por fluxo alterado.### Sugestões de regras de transação criadas (`transaction_rule_suggestions.created`)```json theme={null}
{
  "event_id": "9c0c8b8a-1111-4222-9333-888888888888",
  "webhook_type": "transaction_rule_suggestions.created",
  "timestamp": 1774970000,
  "created_at": 1774970000,
  "application_id": "11111111-2222-3333-4444-555555555555",
  "environment": "live",
  "run_id": "33333333-4444-5555-6666-777777777777",
  "open_count": 3,
  "suggestions": [
    {
      "suggestion_id": "44444444-5555-6666-7777-888888888888",
      "kind": "TWEAK",
      "title": "Raise the review threshold on high-value transfers",
      "rule_id": "55555555-6666-7777-8888-999999999999",
      "expected_impact": {}
    }
  ]
}
````kind` é `ADD` para uma nova regra, `TWEAK` para uma alteração em uma regra existente e `DISABLE` para desativar uma regra. `rule_id` é `null` para `ADD`.
`open_count` é o número de sugestões ainda abertas após a execução, incluindo as novas.
As configurações de sugestão de regras ativam ou desativam este webhook; consulte [Webhooks de transação](https://docs.didit.me/pt-br/transaction-monitoring/webhooks.md).### Eventos do Investigator (`investigator.*`)Os doze eventos `investigator.*` usam o mesmo envelope (`event_id`, `webhook_type`, `timestamp`, `created_at`, `application_id`, `environment`) e os mesmos cabeçalhos de assinatura usados por todos os outros eventos. Eles trazem apenas ids e números: nunca um nome, um motivo digitado por um revisor, uma narrativa ou qualquer coisa que o usuário final tenha escrito. Eles não têm `session_id`; o item a que se referem é identificado em `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 além do envelope |
| - | - |
| `investigator.report.ready` | `run_id`, `queue`, `item`, `recommendation`, `confidence`, `provenance`, `ready_to_accept`, `questions` (contagem) |
| `investigator.decision.automated` | `run_id`, `queue`, `item`, `recommendation`, `confidence`, `provenance`, `decided_by` (`"investigator"`), `outcome`, `action`, `final_status`, `agreed` |
| `investigator.decision.overridden` | O mesmo que `investigator.decision.automated`, com `decided_by` `"human"`, além de `override_category`, `overrode` (`"report"` ou `"automated"`) e, quando `overrode` é `"automated"`, `previous_status` |
| `investigator.decision.contested` | `run_id`, `queue`, `item`, `recommendation`, `confidence`, `provenance`, `case_id`, `contested_at` |
| `investigator.question.raised` | `question_id`, `number`, `queue`, `situation`, `situation_label`, `origin`, `rule_action`, `run_id`, `item` |
| `investigator.automation.paused` | `reason` (`"manual"` ou `"spend_limit"`); para `"spend_limit"`, também `month` (`YYYY-MM`) e `currency` |
| `investigator.rfi.answered` | `request_id`, `run_id`, `queue`, `item`, `status`, `deadline_at`, `answered_by`, `answered_at` |
| `investigator.rfi.overdue` | `request_id`, `run_id`, `queue`, `item`, `status`, `deadline_at` |
| `investigator.drift.detected` | `queue`, `reasons`, `recent_agreement`, `baseline_agreement`, `recent_decisions`, `baseline_decisions`, `drop_points`, `psi`, `from_tier`, `to_tier` |
| `investigator.sla.breached` | `run_id`, `queue`, `item`, `priority`, `sla_due_at` |
| `investigator.qa.failed` | `qa_id`, `run_id`, `queue`, `item`, `kind`, `overall`, `scores`, `sop_complies`, `missed_risks` (códigos de risco), `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 qual versão do Investigator escreveu o relatório:- `engine` é a versão do motor do Investigator: um id opaco de oito caracteres. Ele muda sempre que o motor muda, então dois relatórios com o mesmo `engine` foram escritos pelo mesmo motor.
- `agent_version` é a versão do agente.
- `policy_version` é a versão da sua própria política que o relatório seguiu.
- `model_id` é um alias obsoleto que traz o mesmo valor de `engine`. Use `engine` em vez dele.
- `prompt_hash` não é mais enviado.`item.kind` indica o tipo de item (uma sessão, uma sessão de empresa, uma transação ou um caso) e `item.id`, o id dele. Os timestamps terminados em `_at` são strings ISO 8601. Assine esses eventos listando as strings exatas em `subscribed_events`, como qualquer outro evento.### Status da entidade de usuário alterado (`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"
}
```### Sessão 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 com corpo brutoO `X-Signature` continua sendo enviado em todas as entregas; ele é um HMAC-SHA256 calculado sobre os **bytes exatos** que a Didit transmite (`json.dumps(..., sort_keys=True, separators=(",", ":"))`). Use-o somente quando você conseguir ler o corpo bruto antes que qualquer middleware o 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]
>
> Os campos abaixo só se aplicam quando um destino está fixado em `webhook_version: "v2"`. Também existe uma configuração ainda mais antiga, `"v1"` (um terceiro formato de decisão, com objetos no singular), para destinos criados antes da V2; entre em contato com o suporte antes de depender dela. Novas integrações devem usar a **V3** (o padrão), documentada acima, que usa arrays no plural.| Aspecto | V2 | V3 |
| - | - | - |
| Cabeçalhos de assinatura | `X-Signature`, `X-Signature-V2`, `X-Signature-Simple` (os três são enviados independentemente da versão; o Simple está [obsoleto](#x-signature-simple-está-obsoleto)) | Igual |
| Nomes dos campos de decisão | Objetos no singular (`id_verification`, `liveness`, `face_match`, ...) | Arrays no plural (`id_verifications`, `liveness_checks`, `face_matches`, ...) |
| Várias instâncias de um recurso | Não suportado | Suportado por meio de arrays por recurso com `node_id` |
| Pontuações AML | `aml.score`, `aml.hits[].match_score`, `aml.hits[].risk_score` | Os mesmos valores em `aml_screenings[]` |Os objetos de cada recurso são os mesmos nas duas versões: um destino V2 recebe exatamente os mesmos `match_score`, `risk_score`, `score_breakdown`, `risk_view` e `review_status` por ocorrência e o mesmo `score` agregado que a V3 (consulte o [relatório AML](https://docs.didit.me/pt-br/core-technology/aml-screening/report-aml-screening.md#três-pontuações-não-um-único-veredito)). Configurar para V2 muda onde o relatório AML fica dentro de `decision`, não o que ele contém.Se um consumidor downstream ainda precisar dos formatos V2, crie um destino separado fixado em `webhook_version: "v2"` e mantenha seus novos consumidores na V3.## Relacionados- [API de destinos de webhook](https://docs.didit.me/pt-br/management-api/webhook/list-destinations.md): liste, crie, atualize e exclua destinos e inspecione os logs de entrega.
- [Criar destino de webhook](https://docs.didit.me/pt-br/management-api/webhook/create-destination.md): esquema de requisição e resposta, incluindo o `secret_shared_key` do destino.
- [Modelos de dados](https://docs.didit.me/pt-br/reference/data-models.md): esquema canônico de cada objeto específico de cada recurso incorporado em `decision`.
- [Esquema de decisão](https://docs.didit.me/pt-br/sessions-api/retrieve-session.md): lista completa de campos do objeto `decision` incorporado nos webhooks de sessão.
- [Status de verificação](https://docs.didit.me/pt-br/integration/verification-statuses.md): valores exatos de `status` e transições mencionados acima.
- [Limite de requisições](https://docs.didit.me/pt-br/integration/rate-limiting.md): limites que se aplicam ao consultar repetidamente `GET /v3/session/{id}/decision/` como alternativa.


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