> ## 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 em Portugal: eventos KYC e KYB em tempo real

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

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

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 geralOs webhooks 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 empresarial, um utilizador de fornecedor, uma empresa de fornecedor ou uma transação muda de estado. Ao subscrever um ou mais destinos aos eventos que lhe interessam, pode reagir no momento em que um resultado está pronto, normalmente em poucos segundos após o utilizador que está a ser verificado concluir o seu fluxo.Os webhooks são o padrão de integração **recomendado**. O polling de [`GET /v3/session/{id}/decision/`](https://docs.didit.me/pt/sessions-api/retrieve-session) é suportado como alternativa, mas é mais lento, consome mais pedidos e não recebe eventos como `data.updated`, `transaction.status.updated` ou alterações ao nível das entidades, que só são emitidos através de webhooks. Trate a entrega de webhooks como fonte de verdade e recorra ao polling apenas no arranque a frio ou para reconciliação.Este capítulo da lição de integração da Didit Academy continua a partir da visão geral dos webhooks.A lição de integração da Academy aborda os webhooks a partir dos 3:12: como adicionar um destino, escolher eventos e o segredo de assinatura que vai verificar mais abaixo.[Visão geral dos webhooks — Didit Academy](https://www.youtube.com/embed/fX03-WEu_EI?start=192\&rel=0\&playsinline=1)> [!NOTE]
>
> **Utilizadores do Cloudflare / WAF.** A Didit envia webhooks a partir do IP estático `18.203.201.92` com `User-Agent: DiditWebhook/2.0 +https://didit.me`. Se o seu perímetro bloquear clientes desconhecidos, permita este IP em **Security > WAF > Tools > IP Access Rules** e escolha **Permitir** para o nome de anfitrião que recebe os webhooks.## Tipos de eventosUse o valor exato no array `subscribed_events` de um destino. Não existe carácter universal: indique cada família que pretende receber, e pode distribuir os eventos por vários destinos.| Evento | Recurso | Quando é disparado |
| - | - | - |
| `status.updated` | Sessão KYC ou sessão KYB de empresa | O estado de uma sessão muda (Não iniciada → Em curso → Aprovada / Recusada / Em análise / Abandonada / Reenviada). Os payloads KYB incluem `session_kind: "business"` e `business_session_id`. |
| `data.updated` | Sessão KYC ou sessão KYB de empresa | Os dados de verificação são editados após a criação (correções manuais do revisor em KYC, comprovativo de morada, NFC, registo KYB, documentos KYB, etc.). |
| `user.status.updated` | Entidade de utilizador | O estado de um utilizador consolidado muda entre `ACTIVE`, `FLAGGED` e `BLOCKED` (ação na consola, ação de regra, lista de bloqueio). Inclui `previous_status`. |
| `user.data.updated` | Entidade de utilizador | O perfil, os contadores, os metadados ou os identificadores aprovados de um utilizador consolidado mudam. Inclui `changed_fields` e um mapa `changes` com os valores `previous` / `current`. |
| `business.status.updated` | Entidade de empresa | O estado de uma empresa consolidada muda entre `ACTIVE`, `FLAGGED` e `BLOCKED` (ação na consola, ação de regra, lista de bloqueio). Inclui `previous_status`. |
| `business.data.updated` | Entidade de empresa | O perfil, os dados de registo, os contadores, os metadados ou os campos agregados de verificação de uma empresa consolidada mudam. Inclui `changed_fields` e `changes`. |
| `activity.created` | Linha temporal de atividade | Reservado para o fluxo da linha temporal de atividade. O valor é aceite em `subscribed_events` e coberto pelo testador **Try Webhook**, mas a Didit não entrega atualmente `activity.created` para tráfego em produção. |
| `transaction.created` | Monitorização de transações | Uma transação é criada e o seu veredicto inicial das regras está pronto. Inclui `transaction_id`, `txn_id`, `status`, `score`, `severity`, `amount`, `currency`, `direction`, `action_required`. |
| `transaction.status.updated` | Monitorização de transações | O estado de uma transação muda após a avaliação das regras, a ação de um analista, a remediação, correspondências na lista de bloqueio, atualizações do fornecedor ou ações via API/consola. Também é enviado uma única vez, com ou sem alteração de estado, quando uma triagem de criptoativos repetida em segundo plano é concluída ([hashes de transação ainda não indexados](https://docs.didit.me/pt/transaction-monitoring/aml-screening.md#hashes-de-transação-ainda-não-indexados)). |
| `travel_rule.status.updated` | Travel Rule | O estado de uma troca [Travel Rule](https://docs.didit.me/pt/transaction-monitoring/travel-rule.md) muda: na criação (incluindo a transição para `NOT_APPLICABLE` quando o valor fica abaixo do limiar), na confirmação de titularidade, na resposta ao pedido de recolha por e-mail, na conclusão ou cancelamento, ou na expiração por tempo limite. Dispara para transferências de saída e de entrada: a contrapartida de entrada no canal `INTERNAL`, os registos sunrise e as consultas `TRP` recebidas. Para uma transação criada pela Didit, estes 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 | O estado de conformidade de um fluxo publicado muda após uma reavaliação. Um evento por fluxo. Transporta `workflow_id`, `workflow_version`, `workflow_label`, `transition` (`from` e `to`), `severity` e `kb_version`, e não tem `status`. |
| `transaction_rule_suggestions.created` | Monitorização de transações | Uma execução de sugestões de regras propôs novas alterações às regras e as definições de sugestões de regras têm as notificações webhook ativas. Transporta `run_id`, `open_count` e `suggestions[]`, e não tem `status`. |
| `investigator.report.ready` | Investigator | O Investigator redigiu um relatório sobre um item. |
| `investigator.decision.automated` | Investigator | O Investigator decidiu um item por iniciativa própria. |
| `investigator.decision.overridden` | Investigator | Uma pessoa tomou uma ação diferente do relatório do Investigator, ou anulou uma decisão que este tomou por iniciativa própria. |
| `investigator.decision.contested` | Investigator | O utilizador final contestou uma decisão e pediu uma revisão humana. |
| `investigator.question.raised` | Investigator | O Investigator colocou à equipa uma questão sobre a sua política. |
| `investigator.automation.paused` | Investigator | A automatização deixou de tomar decisões, tendo sido pausada por uma pessoa ou por ter atingido o limite mensal de gastos. |
| `investigator.rfi.answered` | Investigator | O utilizador final respondeu a um pedido de informação. |
| `investigator.rfi.overdue` | Investigator | Um pedido de informação ultrapassou o prazo sem resposta. |
| `investigator.drift.detected` | Investigator | Uma fila desviou-se e a automatização desceu um nível. |
| `investigator.sla.breached` | Investigator | Um relatório esperou além do SLA da sua fila sem decisão. |
| `investigator.qa.failed` | Investigator | Uma decisão tomada por uma pessoa não passou na revisão de qualidade do Investigator. |
| `investigator.external.report.ready` | Investigator | O relatório sobre um item que enviou de fora da Didit está pronto. |
| `auth.signup` | Autenticação | Uma pessoa regista-se através da [Autenticação](https://docs.didit.me/pt/authentication/events-and-webhooks.md). Inclui `auth_event` e o resumo `authentication` da pessoa. |
| `auth.login` | Autenticação | Uma pessoa inicia sessão. Inclui `auth_event` e o resumo `authentication` da pessoa. |
| `auth.login_failed` | Autenticação | Uma tentativa de início de sessão 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 pede a redefinição da palavra-passe. Inclui `auth_event` e o resumo `authentication` da pessoa. |
| `auth.password_reset_completed` | Autenticação | Uma pessoa conclui a redefinição da palavra-passe. 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 inicia sessão a partir de um dispositivo que nunca utilizou. Inclui `auth_event` e o resumo `authentication` da pessoa. |
| `auth.account_locked` | Autenticação | Uma política de segurança bloqueia a conta, ou a sua equipa bloqueia a pessoa. Inclui `auth_event` e o resumo `authentication` da pessoa. |Os valores desta tabela são o conjunto completo de eventos a que se pode subscrever. Não existe `session.status.updated`, `kyc.completed` nem nada de semelhante. A única outra entrega feita pela Didit é o [callback `kyb.registry_search.resolved` sem assinatura](#callback-de-pesquisa-no-registo-kyb), que é enviado para o `webhook_url` indicado em cada pedido ao endpoint [`POST /v3/kyb/search/`](https://docs.didit.me/pt/standalone-apis/kyb-registry), nunca para um destino.> [!TIP]
>
> O menu Business Console > API & Webhooks > **Try Webhook** envia payloads completos para cada um destes eventos (aprovados, recusados, em análise, nomes em Unicode, KYC/KYB, entidade, atividade e transação). Use-o para validar o endpoint antes de entrar em produção.## Estrutura do payload### EnvelopeTodos os webhooks partilham o mesmo envelope. Os campos são sempre serializados com `sort_keys=True` e separadores compactos (`,` / `:`), para que o mesmo payload reproduza a mesma assinatura dos dois lados.| Campo | Tipo | Notas |
| - | - | - |
| `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 em novas tentativas. Use-o como chave de idempotência. |
| `webhook_type` | string | Um dos [tipos de evento](#tipos-de-eventos) acima. |
| `timestamp` | int (segundos Unix) | Momento em que a Didit enviou o webhook. Usado pela verificação da assinatura. Atualizado em cada nova tentativa. |
| `created_at` | int (segundos Unix) | Momento em que o registo subjacente foi atualizado pela última vez. |
| `application_id` | `uuid` | A aplicação proprietária do destino. |
| `environment` | `"live"` \| `"sandbox"` | Indica se o evento veio de uma sessão ou aplicação de produção (ambiente de produção) ou de sandbox. |
| `status` | string | Presente em todos os eventos de sessão, de entidade e de transação (incluindo `*.data.updated`); os eventos `investigator.*` não têm `status` (ver o payload do Investigator abaixo). Os eventos de sessão usam as [etiquetas exatas de estado de verificação](https://docs.didit.me/pt/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`. |Para eventos de **sessão** (`status.updated`, `data.updated` em sessões regulares e empresariais), o envelope inclui ainda:| Campo | Tipo | Notas |
| - | - | - |
| `session_id` | `uuid` | O UUID da sessão de verificação do utilizador ou da sessão empresarial. |
| `business_session_id` | `uuid` | Apenas em KYB. Igual a `session_id`. |
| `session_kind` | `"business"` | Apenas em eventos KYB; permite a um manipulador genérico distinguir os casos. |
| `workflow_id` | `uuid` | Fluxo usado para criar a sessão, quando definido. |
| `workflow_version` | int | Versão do fluxo. |
| `vendor_data` | string | O seu identificador interno, quando indicado na criação da sessão. |
| `vendor_business_id` | string | Apenas em sessões KYB, quando indicado. |
| `sandbox_scenario` | string \| null | Em todos os eventos de sessão (KYC e KYB). O slug do cenário [sandbox](https://docs.didit.me/pt/integration/sandbox-testing.md) ativado (por exemplo, `"decline_aml_hit"`) numa sessão sandbox com um cenário ativado; `null` numa sessão de produção, ou numa sessão sandbox sem cenário ativado. Não está presente em webhooks de entidade ou de transação. |
| `metadata` | object | Os metadados que associou na criação da sessão, sem as chaves internas de deduplicação. |
| `trigger` | string | Opcional. Motivo pelo qual o webhook foi disparado quando o próprio fluxo de verificação não o produziu: `"manual_review"` (edições do revisor na consola), `"manual_step_update"` (um revisor alterou o estado de uma funcionalidade), `"ongoing_monitoring"` (atualização da monitorização AML contínua), `"webhook_reconciliation"` (um `status.updated` reenviado porque o primeiro webhook terminal nunca foi criado, por isso pode receber um segundo evento para uma decisão que já recebeu: deduplique com base em `session_id` e `status`), `"manual_webhook_recovery"` (um webhook recuperado manualmente), `"kyb_registry_change"` (o registo de empresas comunicou uma alteração), `"console_registry_edit"`, `"console_key_people_skip"` e `"console_key_people_regenerate"` (edições feitas na consola numa sessão empresarial) e `"biometric_retention"` (um `data.updated` enviado quando os dados biométricos em tempo real da sessão são eliminados segundo o calendário). O conjunto pode crescer, por isso trate um valor desconhecido como informativo. |
| `decision` | object | Presente quando `status` é `Approved`, `Declined`, `In Review` ou `Abandoned`. Espelha a [decisão de sessão V3](https://docs.didit.me/pt/sessions-api/retrieve-session.md). |
| `resubmit_info` | `{ nodes_to_resubmit, reasons }` | Presente quando `status` é `Resubmitted` (sem `decision`). |
| `decided_by` | string | Apenas em `status.updated` quando o estado é `Approved`, `Declined` ou `Resubmitted` e a aplicação usa o Investigator. `"investigator"` quando o Investigator tomou a decisão por si só, `"human"` quando um utilizador da consola a tomou (incluindo ao aceitar o relatório do Investigator). Omitido quando nenhum revisor registou a decisão, quando a decisão 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 esquema devolvido por [`GET /v3/session/{id}/decision/`](https://docs.didit.me/pt/sessions-api/retrieve-session). Para a referência canónica de campos por funcionalidade (cada propriedade de `id_verifications[]`, `aml_screenings[]`, etc.), consulte [Modelos de dados](https://docs.didit.me/pt/reference/data-models.md). Não repetimos aqui as tabelas de campos por funcionalidade, para que o esquema exista num único lugar.### Arrays plurais V3 (importante)Dentro de `decision`, cada resultado por funcionalidade é um **array plural**, para que um único fluxo possa incluir várias instâncias da mesma funcionalidade (por exemplo, várias verificações de identidade em vários 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 item tem um `node_id` que corresponde ao grafo do fluxo, mais um `status` por funcionalidade. **Não** leia os campos singulares legados do V2 (`id_verification`, `nfc`, `liveness`, `face_match`, `phone`, `email`, `poa`, `aml`, `ip_analysis`, `database_validation`); só aparecem se um destino estiver explicitamente fixado em `webhook_version: "v2"`.Os resultados de **Assinatura eletrónica** estão em `signatures[]`, presentes apenas quando o fluxo tem um passo de assinatura eletrónica: um item por signatário, com o signatário, a confirmação do código, o certificado, o carimbo de data/hora, os hashes e os caminhos de transferência de cada documento e o PDF de prova. Não existe um evento de assinatura separado: o `status.updated` indica quando deve ler esses dados. Consulte o [relatório de assinatura eletrónica](https://docs.didit.me/pt/core-technology/electronic-signature/report-electronic-signature.md).Os resultados de **Verificação bancária** estão em `bank_verifications[]`, um item por passo de Verificação bancária. O `method` de cada item indica como a conta foi verificada: `open_banking` (início de sessão no banco), `account_lookup` (verificação da conta), ou uma alternativa `statement` ou `microdeposits`, e fica `null` até que uma conta seja avaliada. Quando a pessoa escolheu **Continue sem o seu banco**, `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 seu banco](https://docs.didit.me/pt/core-technology/bank-verification/fallbacks.md#ler-o-resultado). Uma conta verificada por consulta de conta não lê dados financeiros, por isso `financial_data.not_collected` indica os saldos, transações ou rendimentos que o passo pedia. Consulte [Métodos de verificação bancária](https://docs.didit.me/pt/core-technology/bank-verification/verification-methods.md#ler-o-método-no-resultado).### Bloco do InvestigatorUma `decision` V3 de uma sessão de utilizador ou de empresa inclui também `investigator`: a revisão [Investigator](https://docs.didit.me/pt/console/investigator.md) mais recente da sessão, ou `null` se 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` | texto | `running`, `completed` ou `failed`. |
| `stage` | texto | `shadow` ou `live`, consoante o modo em que a revisão foi executada. |
| `queue` | texto | `kyc`, `kyb`, `aml`, `transactions` ou `cases`. |
| `is_open` | booleano | A revisão ainda aguarda uma decisão. |
| `recommendation` | texto \| null | `approve`, `decline`, `escalate` ou `request`. |
| `recommendation_detail` | texto \| null | Uma linha sobre a ação recomendada. |
| `confidence` | texto \| null | `high`, `moderate` ou `low`. |
| `summary` | texto \| null | O resumo do relatório. |
| `ready_to_accept` | booleano | O relatório pode ser aceite tal como está. |
| `prepared_at` | data e hora \| null | Quando o relatório ficou pronto. |
| `automated` | booleano | O Investigator decidiu por conta própria, mesmo que alguém a tenha substituído depois. |
| `decided_by` | texto \| null | `investigator` enquanto a sua própria decisão se mantiver, `member` caso contrário, `null` enquanto não houver decisão. |
| `decision` | objeto \| null | Como o item saiu da fila: `outcome`, `action`, `final_status`, `agreed`, `decided_at`, `billed` e quem decidiu. |As funcionalidades de recolha de documentos, **Documentos KYB** e Document AI, não têm um array plural dedicado. Aparecem na lista `features[]` da decisão como `{ "feature": "DOCUMENT_AI", "node_id": "…" }` (uma entrada por nó). Consulte [Document AI](https://docs.didit.me/pt/core-technology/document-ai/overview.md).As decisões de Verificação de utilizadores incluem também um único objeto `biometric_consent`: indica se a sessão exigia consentimento biométrico explícito, o `trigger`, o `status` da resposta e as respetivas evidências. `status: "pending"` com `reason: "BIOMETRIC_CONSENT_UNSUPPORTED_SDK"` significa que um SDK nativo abaixo da versão 4.10.0 captou um rosto sem o passo de consentimento. Consulte [Consentimento biométrico](https://docs.didit.me/pt/getting-started/biometric-consent.md).### Chaves do método de Verificação de identidadeCada item de `id_verifications[]` indica qual [método de Verificação de identidade](https://docs.didit.me/pt/core-technology/id-verification/verification-methods.md) o produziu, para que um processador possa tratar as evidências que efetivamente recebeu:| Chave | Valor |
| - | - |
| `verification_method` | `document`, `id_lookup` ou `wallet` |
| `assurance` | `documentary`, `data_match` ou `cryptographic` |
| `wallet_provider` | ID da carteira do catálogo quando uma carteira produziu o resultado, caso contrário `null` |
| `id_lookup` | Evidências do registo quando o método foi `id_lookup`, caso contrário `null` |
| `wallet_verification` | Evidências da credencial quando o método foi `wallet`, caso contrário `null` |
| `fallback_from` | Motivo pelo qual um método que não é de documento não concluiu o passo de ID: o método, o motivo e se o utilizador passou para a captura de documento ou foi recusado |Os processadores existentes continuam a funcionar sem alterações: uma sessão de documento define `verification_method` como `document` e deixa o resto como `null`. Os tipos campo a campo estão em [Modelos de dados](https://docs.didit.me/pt/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 estados das entidades são `ACTIVE`, `FLAGGED` ou `BLOCKED`. `user.data.updated` e `business.data.updated` mantêm o campo `status` e acrescentam um array `changed_fields` e um mapa `changes` de `{ 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 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 do utilizador pendente (`type` `wallet_ownership` ou `verification_session`, com o respetivo `url`), descrita em [Ações obrigatórias do utilizador](https://docs.didit.me/pt/transaction-monitoring/sdk-transaction-submission.md#ações-necessárias-do-utilizador).As novas transações ficam `APPROVED` por predefinição. Se uma regra, uma lista de bloqueio ou um fluxo alterar o estado durante a criação, o payload inicial de `transaction.created` reflete esse estado final. `transaction.status.updated` tem a mesma estrutura, com o `status` atualizado. Os estados de transação são `APPROVED`, `IN_REVIEW`, `DECLINED` ou `AWAITING_USER`; `severity` é `UNKNOWN` / `LOW` / `MEDIUM` / `HIGH` / `CRITICAL` e `direction` é `INBOUND` / `OUTBOUND`. Os eventos `transaction.created` e `transaction.status.updated` são entregues apenas para aplicações **em produção**. As trocas de Travel Rule em sandbox emitem `travel_rule.status.updated` com `environment: sandbox`.### Callback de pesquisa no registo KYBO fluxo assíncrono [`POST /v3/kyb/search/`](https://docs.didit.me/pt/standalone-apis/kyb-registry) entrega um payload extra que **não** é um evento de destino: quando indica um `webhook_url` no pedido de pesquisa, a Didit envia um callback `kyb.registry_search.resolved` através de um pedido POST para esse URL, assim que o registo for resolvido. Difere dos webhooks de destino em três aspetos:- É **sem assinatura**: não tem cabeçalhos `X-Signature*`. Em vez disso, a Didit marca-o com `X-Didit-Unsigned-Callback: true`; valide-o comparando `request_id` com o ID devolvido pelo pedido de pesquisa.
- O nome do evento está em `event_type` (não em `webhook_type`), e não existem `application_id` nem `environment`.
- `timestamp` e `created_at` são inteiros de época (epoch), como outros webhooks, mas diferentes do `created_at` ISO do endpoint de pesquisa.```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 voltam a definir `timestamp` e continuam sem assinatura). Consulte a [API do Registo KYB](https://docs.didit.me/pt/standalone-apis/kyb-registry.md) para o esquema completo da lista de candidatos.## Verificação da assinaturaTodos os webhooks são assinados com HMAC-SHA256, usando o `secret_shared_key` do destino. São enviados três cabeçalhos de assinatura, para que possa escolher o que funciona na sua stack:| Cabeçalho | O que é assinado | Seguro para middleware | Quando usar |
| - | - | - | - |
| `X-Signature-V2` | JSON compacto com chaves ordenadas e Unicode preservado (`ensure_ascii=False`) | Sim | **Recomendado**: funciona mesmo que a sua framework volte a codificar o corpo. |
| `X-Signature` | Bytes exatos do pedido tal como a Didit os transmitiu (`sort_keys`, `ensure_ascii=True`) | Não | Apenas quando consegue ler o corpo bruto antes de qualquer parser lhe tocar. |
| `X-Signature-Simple` **(obsoleto)** | `"{timestamp}:{session_id}:{status}:{webhook_type}"` | Sim | Último recurso, que autentica **apenas o envelope**: **não** autentica `decision`. Leia o aviso de obsolescência abaixo antes de o usar. |Cabeçalhos que a Didit envia sempre:| 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 Unix epoch em que o webhook foi enviado. Rejeite se `abs(now - X-Timestamp) > 300`. |
| `X-Signature` / `X-Signature-V2` / `X-Signature-Simple` | digests hexadecimais | HMAC-SHA256 do corpo ou da string canónica acima. |Durante 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` (veja [Rotação do segredo de assinatura](#rotação-do-segredo-de-assinatura)).### Rotação do segredo de assinaturaRode o segredo de assinatura de um destino na Business Console.
Durante 24 horas após a rotação, cada entrega para esse destino inclui mais três cabeçalhos, calculados 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 manipulador que aceite uma correspondência em qualquer cabeçalho de assinatura ou no respetivo cabeçalho `-Previous` continua a verificar durante a implementação do 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 isto.
Implemente o novo segredo dentro das 24 horas, porque, depois disso, só o novo segredo permite verificar as assinaturas.
O campo `previous_secret_expires_at` em [`GET /v3/webhook/destinations/{destination_uuid}/`](https://docs.didit.me/pt/management-api/webhook/get-destination) indica quando terminam as 24 horas.As novas tentativas e os reenvios são assinados de novo com o segredo atual do destino, no momento do envio, e só incluem os cabeçalhos `-Previous` enquanto durarem as 24 horas.
Se rodar novamente dentro das 24 horas, o segredo mais antigo é retirado de imediato: apenas o segredo que estava ativo até à nova rotação continua a ser verificado através dos cabeçalhos `-Previous`, durante 24 horas a contar da nova rotação.Os cabeçalhos `-Previous` existem apenas para destinos de webhook.
O `secret_shared_key` de nível de aplicação, que assina o `webhook_url` legado, não tem um período de sobreposição: rodá-lo com `rotate_secret_key` em [`PATCH /v3/webhook/`](https://docs.didit.me/pt/sessions-api/management-api#configuração-de-webhook) invalida o segredo antigo de imediato.### `X-Signature-Simple` está obsoleto> [!WARNING]
>
> **`X-Signature-Simple` autentica o envelope, não o payload.** A sua string canónica cobre apenas `timestamp`, `session_id`, `status` e `webhook_type`. **Não** cobre `decision`, nem `resubmit_info`, `metadata`, `vendor_data` ou `event_id`. Um pedido cujo `decision` tenha sido reescrito, deixando intactos esses quatro campos, continua a passar a verificação de `X-Signature-Simple`.
>
> É ainda mais fraco nos eventos que não têm qualquer `session_id`. Os eventos de transação (`transaction_id`, `txn_id`, `amount`, `score`) e de entidade (`vendor_user_id`, `vendor_business_id`) assinam `"{timestamp}::{status}:{webhook_type}"`, o que não identifica **qual** a transação ou entidade a que o evento diz respeito: duas transações diferentes, reportadas com o mesmo estado no mesmo segundo, têm a mesma `X-Signature-Simple`.
> Os eventos `travel_rule.status.updated`, `workflow.compliance_changed` e `transaction_rule_suggestions.created` também não têm `status`, pelo que assinam `"{timestamp}:::{webhook_type}"`, por exemplo `"{timestamp}:::travel_rule.status.updated"`.
>
> Se `X-Signature-Simple` for o único cabeçalho que verifica, trate tudo o que esteja fora desses quatro campos como **não fiável** e volte a obter o registo a partir da API: [`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/pt/sessions-api/retrieve-session) para uma sessão, [`GET /v3/transactions/{transaction_id}/`](https://docs.didit.me/pt/management-api/transactions/get) para uma transação, que é o servidor de referência.Trata-se de uma restrição de compatibilidade, e não de algo que possamos corrigir diretamente. A string canónica de quatro campos é um contrato de transmissão fixo: cada recetor que verifica `X-Signature-Simple` recalcula exatamente esses quatro campos, por isso alterar o que o cabeçalho assina partiria silenciosamente todos eles no caminho da verificação. Cobrir o corpo exige também uma forma canónica especificada do JSON, que ambos os lados reproduzam byte a byte, e a Didit já disponibiliza uma, em `X-Signature-V2`. Em vez de acrescentar uma segunda variante que cubra o corpo, com regras de canonização próprias, o cabeçalho mantém-se exatamente como está e fica obsoleto a favor das variantes existentes que cobrem o payload completo.O que isto significa para si:- **Nada muda na transmissão.** `X-Signature-Simple` continua a ser enviado em todas as entregas, calculado da mesma forma. Os verificadores existentes continuam a funcionar, e anunciaremos com grande antecedência qualquer remoção.
- **As novas integrações devem usar `X-Signature-V2`.** É seguro para middleware *e* cobre todo o payload.
- **Se não puder deixar de usar `X-Signature-Simple`**, verifique-o e depois obtenha o registo através da API, em vez de o ler a partir do corpo do webhook.### Porque existem três variantes?Algumas frameworks web (parsers de corpo do Express, middleware do Django, gateways de API) voltam a codificar silenciosamente o JSON antes de o seu manipulador o ler. Se o corpo original continha `"José"` e o 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 todos os middlewares reproduzem de forma determinística. O obsoleto `X-Signature-Simple` recorre à assinatura de apenas uma string curta, independente do parser, quando até isso falha. Como assina apenas essa string curta e nunca o corpo, não pode garantir a integridade do corpo.### Ordem de verificação recomendada1) Tente primeiro **`X-Signature-V2`**. Recodifique `JSON.parse(body)` com as chaves ordenadas e o Unicode preservado, depois calcule `HMAC-SHA256(secret, canonical)` e compare com o cabeçalho usando `timingSafeEqual`.
2) Se a V2 falhar e conseguir ler os bytes em bruto, tente **`X-Signature`** contra `HMAC-SHA256(secret, rawBody)`.
3) Só se ambas falharem, recorra à **`X-Signature-Simple`**, obsoleta, contra `HMAC-SHA256(secret, "{timestamp}:{session_id}:{status}:{webhook_type}")`. Esta opção autentica apenas o envelope: trate a `decision` no corpo como não fiável e volte a obtê-la a partir de [`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/pt/sessions-api/retrieve-session) antes de agir.Em cada passo, quando estiver presente o cabeçalho `-Previous` correspondente, aceite uma correspondência em qualquer dos dois cabeçalhos (ver [Rotação do segredo de assinatura](#rotação-do-segredo-de-assinatura)).Em todos os casos, **rejeite** qualquer pedido 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 volta a tentar até **2 vezes**, com um intervalo de espera 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 (inicial e novas tentativas) é registada como uma entrada separada na Business Console, no separador **Entregas** do destino, para que possa repetir ou inspecionar qualquer tentativa.Cada tentativa no registo de entregas de um destino tem um número `attempt`: `1` para a primeira entrega de um evento e, em cada nova tentativa ou reenvio, o número da tentativa repetida mais um.
As novas tentativas automáticas são enviadas para o URL para o qual a primeira tentativa foi enviada, mesmo que tenha alterado entretanto o `url` do destino.
Um reenvio manual é enviado para o URL atual do destino, pelo que corrigir um URL também corrige o reenvio.Outras regras de entrega a conhecer:- O pedido HTTP de saída expira após **5 segundos**.
- A Didit bloqueia entregas para URLs privados ou localhost, como proteção contra SSRF, e nunca segue redirecionamentos. Use um endpoint HTTPS público que responda diretamente.
- Um `url` de destino que contenha uma barra invertida bruta, um carácter de controlo ou mais do que um `@` na autoridade é rejeitado como malformado; esse URL não pode ser criado, e um URL existente não pode ser reenviado nem repetido. Use um URL simples `https://host/path` com, no máximo, uma secção de userinfo (para URLs de webhook com autenticação básica).
- Um `2xx` é considerado sucesso. Os tempos limite e as falhas de ligação contam como passíveis de nova tentativa (aparecem como `504`/`503` no registo de entregas). As respostas `3xx` e `4xx`, exceto `404`, **não** são repetidas.
- Em cada nova tentativa, a Didit recalcula as assinaturas com um `X-Timestamp` novo, pelo que o par timestamp/assinatura fica sempre alinhado.
- As novas tentativas e os 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 pela qual os webhooks lhe chegam.
Cada entrega é colocada na fila de forma independente, e uma nova tentativa chega minutos após a primeira tentativa, pelo que um `status.updated` para `Approved` pode chegar antes de um anterior para `In Progress`.Crie o seu recetor a pensar nisso:- Trate um webhook como um sinal de que algo mudou. Quando a ordem importa, chame [`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/pt/sessions-api/retrieve-session) e atue com base no `status` atual.
- Nunca deixe um evento mais antigo substituir um estado mais recente: compare o `created_at` do evento com o último que guardou para esse registo e ignore o que for mais antigo.
- Identifique repetições pelo `event_id`, que se mantém igual em todas as novas tentativas.A Didit coloca na fila os eventos de uma transferência Travel Rule por uma ordem definida, descrita em [Alteração do estado de troca Travel Rule](#estado-da-troca-travel-rule-alterado-travel_rule-status-updated), mas uma nova tentativa pode ainda alterar a ordem pela qual chegam.## Configurar um destinoPode gerir os destinos a partir de **Business Console > API & Webhooks** ou através da API de Gestão.**1. Crie o destino**Chame [`POST /v3/webhook/destinations/`](https://docs.didit.me/pt/management-api/webhook/create-destination) (ou clique em **Adicionar destino** na consola) com um `label`, um `url` público, `webhook_version` (`"v3"`) e um array `subscribed_events` que contenha todas as famílias de eventos que este endpoint deve receber. É obrigatório pelo menos um evento, e não existe curinga.**2. Guarde o secret\_shared\_key**A resposta à criação devolve o `secret_shared_key` do destino. Guarde-o junto com os seus outros segredos de servidor: pertence apenas a este destino e é o dado de entrada das três variantes de assinatura HMAC-SHA256. [`GET /v3/webhook/destinations/{destination_uuid}/`](https://docs.didit.me/pt/management-api/webhook/get-destination) devolve-o novamente aos utilizadores que fazem chamadas com uma chave de API e aos utilizadores da consola que podem ler os webhooks.**3. Implemente o endpoint**Exponha uma rota `POST` HTTPS pública, leia o corpo em bruto, verifique a assinatura (dando preferência a `X-Signature-V2`), valide a janela do timestamp e, em seguida, encaminhe com base em `webhook_type`. Devolva `2xx` assim que tiver colocado o trabalho na fila; faça o processamento pesado de forma assíncrona.**4. Confirme as receções**Acompanhe o separador **Entregas** do destino. Os webhooks em bom estado devolvem `200` em um ou dois segundos. `5xx`/`404` aciona a política de novas tentativas da Didit; as falhas persistentes são descartadas após a segunda nova tentativa.**5. Teste com o Try Webhook**Use os cenários da consola de [**Try Webhook**](#testes) para testar o seu endpoint com payloads de verificação aprovada, recusada, em análise, KYB, entidade, atividade e transação, antes de ativar o tráfego de produção.![Funcionalidade de teste de webhooks da Didit na 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 a partir da Business Console para validar o seu endpoint antes de passar a produção.Pode criar tantos destinos quantos precisar. Um padrão comum é **um destino por consumidor**: por exemplo, eventos KYC → o seu serviço de autenticação, eventos 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 subscrições, pelo que um segredo renovado ou uma implementação com falhas num consumidor não afeta os restantes.## Exemplos de códigoPara garantir a segurança do seu endpoint de webhook, verifique a autenticidade dos pedidos recebidos com o `secret_shared_key` do destino.> [!WARNING]
>
> **Use `X-Signature-V2`.** É a variante mais resistente à recodificação feita por middleware e autentica por completo o corpo (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 conseguir ler os bytes brutos antes de qualquer parser os processar, a variante original `X-Signature` funciona da mesma forma, mas assina o corpo bruto. Mantemo-la na secção legada abaixo, por uma questão 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 valores predefinidos do JSON em PHP **não** reproduzem a forma canónica. Quatro deles têm de ser substituídos, e se faltar um único que seja, os bytes assinados mudam e o digest nunca corresponde:| Valor predefinido do PHP | O que emite | O que é necessário |
| - | - | - |
| `json_decode($raw, true)` (associativo) | objetos vazios `{}` passam a `[]` | descodificar com `$associative = false` para que os objetos JSON se mantenham como `stdClass` |
| `json_encode` escapa as 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 aspeto numérico como números | `"9"` ordena-se antes de `"10"` | `ksort($props, SORT_STRING)` |A armadilha dos objetos vazios é a que causa mais problemas. Os payloads do Didit incluem habitualmente mapas vazios, como `risk_view.countries.risk_scores`, `risk_view.crimes.risk_scores` e `risk_view.custom_list`, pelo que uma ida e volta associativa quebra a assinatura em sessões aprovadas comuns, e não num caso raro e excecional.```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]);
    }
}
```Duas outras coisas que convém saber:- **Os floats com valor inteiro não precisam de tratamento especial em PHP.** O Didit serializa-os como inteiros (`100.0` → `100`), e o `json_encode` do PHP escreve um float inteiro da mesma forma, pelo que não é necessário nenhum passo `shortenFloats`, ao contrário do exemplo de Node.js acima.
- **Mantenha `serialize_precision` em `-1`** (o predefinido desde o PHP 7.1). Se o seu alojamento o substituir por `17`, o `json_encode` escreve `87.42` como `87.420000000000002` e todas as verificações de `X-Signature-V2` falham.Se não conseguir que a forma canónica coincida, `verifySignatureRaw` acima é a alternativa mais simples: o `$request->getContent()` do Laravel devolve os bytes sem alterações, pelo que `X-Signature` funciona desde que nada antes do PHP recodifique o corpo. É exatamente o caso que o `X-Signature-V2` foi concebido para suportar, e é por isso que o V2 é tentado em primeiro lugar.## TestesNão precisa de **executar** uma verificação completa para testar o seu endpoint.- A partir de **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 o URL do destino.
- Todos os cenários são assinados 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 possa confirmar que o `X-Signature-V2` funciona através do seu middleware. As entregas de teste contêm dados de exemplo e são marcadas com um cabeçalho adicional `X-Didit-Test-Webhook: true` (e um sufixo `(Test)` no `User-Agent`), para que não afetem o estado de produção.
- Volte a reproduzir qualquer entrega histórica a partir do separador **Entregas** de um destino, se precisar de depurar uma regressão.Para testes smoke de ponta a ponta, execute uma verificação real através de um fluxo 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 que os cenários do **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
}
```### Estado da troca 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` assume um dos 13 estados de troca documentados em [Travel Rule](https://docs.didit.me/pt/transaction-monitoring/travel-rule.md#ciclo-de-vida).
`rail` é `INTERNAL`, `TRP`, `GTR`, `TRUST`, `VERIFYVASP`, `SYGNA` ou `EMAIL` (valor vazio até ser selecionado um canal, se não for possível determinar nenhum, e sempre nos registos sunrise), ou `TEST_VASP` para uma transferência de sandbox à qual o [Didit Test VASP](https://docs.didit.me/pt/transaction-monitoring/travel-rule-sandbox.md) respondeu, e `direction` é `INBOUND` ou `OUTBOUND`.
Um evento de sandbox inclui `"environment": "sandbox"`, e um evento `TEST_VASP` inclui também `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/management-api/transactions/create), um [registo sunrise após depósito](https://docs.didit.me/pt/transaction-monitoring/travel-rule.md#registo-sunrise-posterior-ao-depósito), uma consulta `TRP` de entrada ou a etapa de entrada no canal `INTERNAL`), envia primeiro o evento `transaction.created` dessa transação e, depois, os eventos `travel_rule.status.updated` da transferência.
Uma regra que altere o estado da transação durante essa etapa também pode enviar `transaction.status.updated` antes de `transaction.created`, e um registo sunrise que se associe a uma transação de depósito já criada não envia `transaction.created`.
Cada webhook é entregue e repetido de forma independente, pelo que um evento que tenha sido tentado novamente pode chegar depois de um evento posterior: use `transaction_id` como chave no seu manipulador e volte a ler a transação quando precisar do estado atual.
Uma transferência enviada por TRUST nunca recebe um evento `COMPLETED`, porque a TRUST não devolve nada que a Didit consiga ler: fica em `AWAITING_COUNTERPARTY` até ao prazo de confirmação e termina depois em `EXPIRED`. Através de GTR, VerifyVASP e Sygna Bridge, a Didit lê a resposta da contraparte na rede e o evento segue-a (ver [redes Travel Rule](https://docs.didit.me/pt/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` assumem um dos seguintes valores: `compliant`, `gaps_detected`, `pending_change`, `unknown_coverage` ou `not_evaluated`.
`severity` é `actionable` quando foram detetadas lacunas, `indicative` para uma alteração pendente e `informative` quando as lacunas foram resolvidas.
A Didit envia um evento por cada fluxo que tenha sido 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 a uma regra existente e `DISABLE` para desativar uma regra. `rule_id` é `null` em `ADD`.
`open_count` é o número de sugestões ainda abertas após a execução, incluindo as novas.
As definições das sugestões de regras ativam ou desativam este webhook; ver [Webhooks de transação](https://docs.didit.me/pt/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 que todos os outros eventos. Contêm apenas identificadores e valores numéricos: nunca um nome, um motivo escrito por um revisor, uma narrativa ou qualquer conteúdo escrito pelo utilizador final. Não têm `session_id`; o item a que dizem respeito está indicado 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` (número) |
| `investigator.decision.automated` | `run_id`, `queue`, `item`, `recommendation`, `confidence`, `provenance`, `decided_by` (`"investigator"`), `outcome`, `action`, `final_status`, `agreed` |
| `investigator.decision.overridden` | Igual a `investigator.decision.automated` com `decided_by` `"human"`, mais `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 a versão do Investigator que escreveu o relatório:- `engine` é a versão do motor do Investigator: um identificador opaco de oito caracteres. Muda sempre que o motor muda, pelo que 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 política que o relatório seguiu.
- `model_id` é um alias obsoleto que tem o mesmo valor que `engine`. Utilize `engine` em vez disso.
- `prompt_hash` já não é 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 respetivo identificador. Os carimbos de data/hora cujo nome termina em `_at` são cadeias ISO 8601. Subscreva estes eventos indicando as cadeias exatas em `subscribed_events`, como em qualquer outro evento.### Estado da entidade de utilizador 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 a ser enviado em cada entrega. É um HMAC-SHA256 calculado sobre os **bytes exatos** que a Didit transmite (`json.dumps(..., sort_keys=True, separators=(",", ":"))`). Use-o apenas quando puder ler o corpo bruto antes de qualquer middleware o modificar.> [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"`. Existe ainda uma fixação `"v1"`, ainda mais antiga (uma terceira estrutura de decisão, no singular), para destinos criados antes da V2; contacte o suporte antes de depender dela. As novas integrações devem usar a **V3** (a predefinida), documentada acima, que utiliza arrays no plural.| Aspeto | 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 singulares (`id_verification`, `liveness`, `face_match`, ...) | Arrays no plural (`id_verifications`, `liveness_checks`, `face_matches`, ...) |
| Múltiplas instâncias por funcionalidade | Não suportado | Suportado através de arrays por funcionalidade com `node_id` |
| Pontuações AML | `aml.score`, `aml.hits[].match_score`, `aml.hits[].risk_score` | Mesmos valores em `aml_screenings[]` |Os próprios objetos por funcionalidade são iguais nas duas versões: um destino V2 recebe exatamente o `match_score`, `risk_score`, `score_breakdown`, `risk_view` e `review_status` por cada correspondência, bem como o `score` agregado, tal como o V3 (ver [Relatório AML](https://docs.didit.me/pt/core-technology/aml-screening/report-aml-screening.md#três-pontuações-não-um-veredito)). Fixar a V2 altera apenas a posição do relatório AML em `decision`, e não o seu conteúdo.Se um consumidor a jusante ainda precisar das estruturas V2, crie um destino separado fixado em `webhook_version: "v2"` e mantenha os novos consumidores na V3.## Relacionado- [API de destinos de webhook](https://docs.didit.me/pt/management-api/webhook/list-destinations.md): listar, criar, atualizar, eliminar destinos e consultar os registos de entrega.
- [Criar destino de webhook](https://docs.didit.me/pt/management-api/webhook/create-destination.md): esquema de pedido e resposta, incluindo o `secret_shared_key` do destino.
- [Modelos de dados](https://docs.didit.me/pt/reference/data-models.md): esquema canónico de cada objeto por funcionalidade incorporado em `decision`.
- [Esquema de decisão](https://docs.didit.me/pt/sessions-api/retrieve-session.md): lista completa de campos do objeto `decision` incorporado nos webhooks de sessão.
- [Estados de verificação](https://docs.didit.me/pt/integration/verification-statuses.md): valores exatos de `status` e transições referidos acima.
- [Limites de pedidos](https://docs.didit.me/pt/integration/rate-limiting.md): limites aplicáveis ao fazer polling de `GET /v3/session/{id}/decision/` como alternativa.


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