> ## Documentation Index
> Fetch the complete documentation index at: https://docs.didit.me/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook：实时 KYC 与 KYB 事件、HMAC 签名

> 通过 Didit Webhook 实时接收验证、实体和交易事件，支持 HMAC-SHA256 签名、多目标分发和有限次重试。

> [!NOTE]
>
> 身份认证活动及其随版本发布而变化的 Webhook 投递，记录在 [认证事件与 Webhook](https://docs.didit.me/zh/authentication/events-and-webhooks.md) 中。请勿使用当前目标 API 注册未列出的认证事件类型。

Webhook 集成提示词
        将提示词复制到剪贴板



      
      

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



      
      

粘贴到

## 概览Webhook 是 Didit 在验证会话、企业会话、供应商用户、供应商企业或交易状态发生变化时，向您的后端推送**实时更新**的方式。为一个或多个 Webhook 目标订阅您关心的事件，您就能在结果生成的那一刻做出响应，通常在用户完成验证流程后的几秒内。Webhook 是**推荐的**集成方式。虽然支持将 [`GET /v3/session/{id}/decision/`](https://docs.didit.me/zh/sessions-api/retrieve-session) 作为备用方案进行轮询，但轮询速度更慢、请求成本更高，并且会漏掉 `data.updated`、`transaction.status.updated` 以及仅通过 Webhook 发出的实体级变更等事件。请将 Webhook 投递视为事实来源，仅在冷启动或进行对账时才使用轮询。本章是 Didit Academy 集成课程的这一部分，承接 Webhook 概览。Academy 集成课程从 3:12 开始讲解 Webhook：添加目标、选择事件，以及您将在下文中用于验证的签名密钥。[Webhooks 概览 - Didit Academy](https://www.youtube.com/embed/fX03-WEu_EI?start=192\&rel=0\&playsinline=1)> [!NOTE]
>
> **Cloudflare / WAF 用户。** Didit 从静态 IP `18.203.201.92` 投递 Webhook，其请求头为 `User-Agent: DiditWebhook/2.0 +https://didit.me`。如果您的边缘节点会拦截未知客户端，请在 **Security > WAF > Tools > IP Access Rules** 中允许此 IP，并为接收主机名选择 **允许**。## 事件类型在 Webhook 目标的 `subscribed_events` 数组中使用准确的值。没有通配符，请逐一列出您想接收的每一类事件，并且可以把事件分散到多个 Webhook 目标中。| 事件 | 资源 | 触发时机 |
| - | - | - |
| `status.updated` | KYC 会话或 KYB 企业会话 | 会话状态发生变化（未开始 → 进行中 → 已通过 / 已拒绝 / 审核中 / 已放弃 / 已重新提交）。KYB payload 包含 `session_kind: "business"` 和 `business_session_id`。 |
| `data.updated` | KYC 会话或 KYB 企业会话 | 会话创建后，验证数据被编辑（人工审核员对 KYC、地址证明（POA）、NFC、KYB 公司登记、KYB 证件等进行的更正）。 |
| `user.status.updated` | 用户实体 | 合并后的用户状态在 `ACTIVE`、`FLAGGED` 和 `BLOCKED` 之间发生变化（控制台操作、规则操作、黑名单）。包含 `previous_status`。 |
| `user.data.updated` | 用户实体 | 合并后的用户资料、计数器、元数据或已通过的标识符发生变化。包含 `changed_fields`，以及带有 `previous` / `current` 值的 `changes` 映射。 |
| `business.status.updated` | 企业实体 | 合并后的企业状态在 `ACTIVE`、`FLAGGED` 和 `BLOCKED` 之间发生变化（控制台操作、规则操作、黑名单）。包含 `previous_status`。 |
| `business.data.updated` | 企业实体 | 合并后的企业资料、注册数据、计数器、元数据或汇总验证字段发生变化。包含 `changed_fields` 和 `changes`。 |
| `activity.created` | 活动时间线 | 为活动时间线信息流预留。该值可在 `subscribed_events` 中接受，并由 **Try Webhook** 测试工具支持，但 Didit 目前不会为实时流量投递 `activity.created`。 |
| `transaction.created` | 交易监控 | 交易已创建，且其初始规则判定结果已就绪。包含 `transaction_id`、`txn_id`、`status`、`score`、`severity`、`amount`、`currency`、`direction`、`action_required`。 |
| `transaction.status.updated` | 交易监控 | 交易在规则评估、分析师操作、整改、黑名单匹配、服务商更新或 API / 控制台操作之后，状态发生变化。另外，当一次在后台重试的加密货币筛查完成时，无论状态是否变化，都会发送一次该事件（[交易哈希尚未建立索引](https://docs.didit.me/zh/transaction-monitoring/aml-screening.md#尚未索引的交易哈希)）。 |
| `travel_rule.status.updated` | Travel Rule | [Travel Rule](https://docs.didit.me/zh/transaction-monitoring/travel-rule.md) 交换的状态在以下时机发生变化：创建时（包括低于阈值时的 `NOT_APPLICABLE` 转换）、所有权确认、通过电子邮件收到响应、完成或取消，或超时到期。同时适用于转出和转入：转入的 `INTERNAL` 通道对应方、Sunrise 登记以及转入的 `TRP` 查询。对于 Didit 创建的交易，这些事件会在其 `transaction.created` 之后发送。包含 `transaction_id`、`txn_id`、`travel_rule_status`、`rail`、`direction`。 |
| `workflow.compliance_changed` | 工作流合规 | 已发布工作流的合规状态在重新评估后发生变化。每个工作流对应一个事件。包含 `workflow_id`、`workflow_version`、`workflow_label`、`transition`（包括 `from` 和 `to`）、`severity` 和 `kb_version`，不包含 `status`。 |
| `transaction_rule_suggestions.created` | 交易监控 | 一次规则建议运行提出了新的规则变更，并且规则建议设置已开启 Webhook 通知。包含 `run_id`、`open_count` 和 `suggestions[]`，不包含 `status`。 |
| `investigator.report.ready` | Investigator | Investigator 已就某个项目撰写报告。 |
| `investigator.decision.automated` | Investigator | Investigator 自行对某个项目作出了决策。 |
| `investigator.decision.overridden` | Investigator | 人员采取的操作与 Investigator 的报告不同，或推翻了它自行作出的决策。 |
| `investigator.decision.contested` | Investigator | 终端用户对某项决策提出异议，并请求人工复核。 |
| `investigator.question.raised` | Investigator | Investigator 就其策略向团队提出了问题。 |
| `investigator.automation.paused` | Investigator | 自动化停止决策，由人员暂停，或达到每月支出上限而暂停。 |
| `investigator.rfi.answered` | Investigator | 终端用户回复了信息请求。 |
| `investigator.rfi.overdue` | Investigator | 信息请求超过截止日期仍未获回复。 |
| `investigator.drift.detected` | Investigator | 队列出现偏移，自动化降低了一个等级。 |
| `investigator.sla.breached` | Investigator | 报告超过所在队列的 SLA 时限仍未作出决策。 |
| `investigator.qa.failed` | Investigator | 人员作出的决策未通过 Investigator 的质量审核。 |
| `investigator.external.report.ready` | Investigator | 您从 Didit 外部提交的项目报告已就绪。 |
| `auth.signup` | 认证 | 用户通过[认证](https://docs.didit.me/zh/authentication/events-and-webhooks.md)注册。包含 `auth_event` 和该用户的 `authentication` 摘要。 |
| `auth.login` | 认证 | 用户登录。包含 `auth_event` 和该用户的 `authentication` 摘要。 |
| `auth.login_failed` | 认证 | 登录尝试失败。当尝试未指向任何已知用户时，`vendor_data` 为 `null`。包含 `auth_event` 和该用户的 `authentication` 摘要。 |
| `auth.password_reset_requested` | 认证 | 用户请求重置密码。包含 `auth_event` 和该用户的 `authentication` 摘要。 |
| `auth.password_reset_completed` | 认证 | 用户完成密码重置。包含 `auth_event` 和该用户的 `authentication` 摘要。 |
| `auth.mfa_changed` | 认证 | 用户添加或更改了身份验证器。包含 `auth_event` 和该用户的 `authentication` 摘要。 |
| `auth.new_device` | 认证 | 用户从此前未使用过的设备登录。包含 `auth_event` 和该用户的 `authentication` 摘要。 |
| `auth.account_locked` | 认证 | 安全策略锁定账户，或您的团队屏蔽了该用户。包含 `auth_event` 和该用户的 `authentication` 摘要。 |本表中的值即为可订阅事件的完整集合，不存在 `session.status.updated`、`kyc.completed` 或类似事件。Didit 唯一的其他投递是未签名的 [`kyb.registry_search.resolved` 回调](#kyb-注册库搜索回调)，它会发送到您在调用 [`POST /v3/kyb/search/`](https://docs.didit.me/zh/standalone-apis/kyb-registry) 时传入的每次请求的 `webhook_url`，而不会发送到任何 Webhook 目标。> [!TIP]
>
> Business Console > API & Webhooks > **Try Webhook** 菜单会为以下每一种事件发送结构完整的 payload：已通过、已拒绝、审核中、Unicode 名称、KYC/KYB、实体、活动和交易。请在正式上线前使用它验证您的 endpoint。## Payload 结构### 信封结构每个 Webhook 共用同一种信封结构。字段始终使用 `sort_keys=True` 和紧凑分隔符（`,` / `:`）进行序列化，这样相同的 payload 在两端都能生成相同的签名。| 字段 | 类型 | 说明 |
| - | - | - |
| `event_id` | `uuid` | 事件的稳定标识符。同一事件分发到的每个目标都会收到**相同的** `event_id`，重试时也会复用。请以它作为幂等性的依据。 |
| `webhook_type` | string | 上文[事件类型](#事件类型)之一。 |
| `timestamp` | int（Unix 秒） | Didit 发送 Webhook 的时间。签名验证会用到它。每次重试都会刷新。 |
| `created_at` | int（Unix 秒） | 底层记录最后一次更新的时间。 |
| `application_id` | `uuid` | 拥有该目标的应用。 |
| `environment` | `"live"` \| `"sandbox"` | 事件来自生产还是沙盒会话或应用。 |
| `status` | string | 出现在每个会话、实体和交易事件中（包括 `*.data.updated`）；`investigator.*` 事件不包含 `status`（见下方 Investigator payload）。会话事件使用准确的[验证状态标签](https://docs.didit.me/zh/integration/verification-statuses.md)（例如 `"Approved"`、`"Kyc Expired"`）；实体事件使用 `ACTIVE` / `FLAGGED` / `BLOCKED`；交易事件使用 `APPROVED` / `IN_REVIEW` / `DECLINED` / `AWAITING_USER`。 |对于**会话**事件（`status.updated`，以及常规会话和企业会话上的 `data.updated`），信封还包含以下字段：| 字段 | 类型 | 说明 |
| - | - | - |
| `session_id` | `uuid` | 用户验证或企业会话的 UUID。 |
| `business_session_id` | `uuid` | 仅适用于 KYB。与 `session_id` 相同。 |
| `session_kind` | `"business"` | 仅适用于 KYB 事件；便于通用处理程序进行分支判断。 |
| `workflow_id` | `uuid` | 创建会话时所用的工作流（如已设置）。 |
| `workflow_version` | int | 工作流版本。 |
| `vendor_data` | string | 创建会话时提供的内部标识符（如已提供）。 |
| `vendor_business_id` | string | 仅适用于 KYB 会话，且已提供时。 |
| `sandbox_scenario` | string \| null | 在每个会话事件上都有（KYC 和 KYB）。在已设置场景的沙盒会话上，为已设置的[沙盒](https://docs.didit.me/zh/integration/sandbox-testing.md)场景标识符（例如 `"decline_aml_hit"`）；在生产会话上，或在未设置场景的沙盒会话上，为 `null`。不出现在实体或交易 Webhook 中。 |
| `metadata` | object | 创建会话时附加的元数据，已去除内部去重键。 |
| `trigger` | string | 可选。当验证流程本身未产生该 Webhook 时，说明触发原因：`"manual_review"`（审核员在控制台中编辑）、`"manual_step_update"`（审核员修改了某项功能的状态）、`"ongoing_monitoring"`（持续 AML 监控刷新）、`"webhook_reconciliation"`（因首个终态 Webhook 从未创建而重新发送的 `status.updated`，因此你可能会收到一个你已经获得决策的第二个事件：请基于 `session_id` 和 `status` 去重）、`"manual_webhook_recovery"`（手动恢复的 Webhook）、`"kyb_registry_change"`（公司登记报告了变更）、`"console_registry_edit"`、`"console_key_people_skip"` 和 `"console_key_people_regenerate"`（在控制台中对企业会话所做的编辑），以及 `"biometric_retention"`（会话的实时生物识别数据按计划删除时发送的 `data.updated`）。该集合可能会增加，因此请将未知值视为仅供参考。 |
| `decision` | object | 当 `status` 为 `Approved`、`Declined`、`In Review` 或 `Abandoned` 时存在。与 [V3 会话决策](https://docs.didit.me/zh/sessions-api/retrieve-session.md)的结构一致。 |
| `resubmit_info` | `{ nodes_to_resubmit, reasons }` | 当 `status` 为 `Resubmitted` 时存在（此时没有 `decision`）。 |
| `decided_by` | string | 仅出现在 `status.updated` 中，且状态为 `Approved`、`Declined` 或 `Resubmitted`，并且应用使用了 Investigator 时。Investigator 自行作出决策时为 `"investigator"`；控制台用户作出决策（包括接受 Investigator 的报告）时为 `"human"`。当没有审核员记录决策、决策由 API 密钥作出，或未使用 Investigator 时，该字段会被省略。 |### 会话信封示例```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]
>
> `decision` 对象与 [`GET /v3/session/{id}/decision/`](https://docs.didit.me/zh/sessions-api/retrieve-session) 返回的结构相同。关于每个功能的规范字段参考（`id_verifications[]`、`aml_screenings[]` 等上的每个属性），请参阅[数据模型](https://docs.didit.me/zh/reference/data-models.md)。我们有意不在此重复各功能的字段表，以便该结构只保存在一处。### V3 复数形式的数组（重要）在 `decision` 内部，每个功能的结果都以**数组（复数形式）**表示，因此单个工作流可以包含同一功能的多个实例（例如跨多份证件进行多次身份证件验证）：```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[]
```每一项都包含与工作流图谱匹配的 `node_id`，以及每个功能的 `status`。**请勿**读取旧版 V2 单数形式的字段（`id_verification`、`nfc`、`liveness`、`face_match`、`phone`、`email`、`poa`、`aml`、`ip_analysis`、`database_validation`）；这些字段仅在 Webhook 目标被明确固定为 `webhook_version: "v2"` 时才会出现。**电子签名**结果位于 `signatures[]` 中，仅当工作流包含电子签名步骤时才存在：每位签署人一项，包含签署人、验证码确认、证书、时间戳、每份文档的哈希值和下载路径，以及证据 PDF。没有单独的签名事件：`status.updated` 会告诉你何时读取这些结果。参见[电子签名报告](https://docs.didit.me/zh/core-technology/electronic-signature/report-electronic-signature.md)。**银行验证**结果位于 `bank_verifications[]` 中，每个银行验证步骤对应一项。每项的 `method` 说明账户是如何被验证的：`open_banking`（银行登录）、`account_lookup`（账户核查），或 `statement` 或 `microdeposits` 备用方式；在评估账户之前为 `null`。当用户选择**Continue without your bank**时，`continued_without_bank` 为 `true`，`continuation` 说明原因。此时 `method` 为 `none`，除非之前已评估过某次尝试：参见[用户无法验证银行账户时](https://docs.didit.me/zh/core-technology/bank-verification/fallbacks.md#在结果中读取)。通过账户核查验证的账户不会读取任何财务数据，因此 `financial_data.not_collected` 列出该步骤所要求但未收集的余额、交易或收入信息。参见[银行验证方式](https://docs.didit.me/zh/core-technology/bank-verification/verification-methods.md#在结果中读取验证方法)。### Investigator 数据块对于用户或企业会话的 V3 `decision`，还会包含 `investigator` 字段：该会话最近一次的 [Investigator](https://docs.didit.me/zh/console/investigator.md) 审核；若会话从未进行过审核，则为 `null`。| 字段 | 类型 | 含义 |
| - | - | - |
| `run_id` | uuid | 本次审核。请访问 `GET /v3/investigator/runs/{run_id}/` 查看完整报告。 |
| `status` | string | `running`、`completed` 或 `failed`。 |
| `stage` | string | 审核运行时的阶段，为 `shadow` 或 `live`。 |
| `queue` | string | `kyc`、`kyb`、`aml`、`transactions` 或 `cases`。 |
| `is_open` | boolean | 该审核仍在等待决策。 |
| `recommendation` | string \| null | `approve`、`decline`、`escalate` 或 `request`。 |
| `recommendation_detail` | string \| null | 关于建议操作的一行说明。 |
| `confidence` | string \| null | `high`、`moderate` 或 `low`。 |
| `summary` | string \| null | 报告摘要。 |
| `ready_to_accept` | boolean | 报告可以原样接受。 |
| `prepared_at` | datetime \| null | 报告准备就绪的时间。 |
| `automated` | boolean | Investigator 自行作出了决策，即使之后有人对其进行了覆盖。 |
| `decided_by` | string \| null | 在其自身决策保持有效时为 `investigator`，否则为 `member`；尚未决策时为 `null`。 |
| `decision` | object \| null | 该项离开队列的方式：`outcome`、`action`、`final_status`、`agreed`、`decided_at`、`billed` 以及作出决策的人员。 |证件收集功能（**KYB 证件**和 **Document AI**）没有专用的复数数组，它们会出现在决策的 `features[]` 列表中，格式为 `{ "feature": "DOCUMENT_AI", "node_id": "…" }`（每个节点一条记录）。参见 [Document AI](https://docs.didit.me/zh/core-technology/document-ai/overview.md)。用户验证决策还包含一个 `biometric_consent` 对象：说明会话是否要求明确的生物识别同意、`trigger`、回答的 `status` 及其证据。`status: "pending"` 且 `reason: "BIOMETRIC_CONSENT_UNSUPPORTED_SDK"` 表示低于 4.10.0 的原生 SDK 在未执行同意步骤的情况下采集了人脸。参见[生物识别同意](https://docs.didit.me/zh/getting-started/biometric-consent.md)。### 身份证件验证方法键每个 `id_verifications[]` 项都会说明它由哪种[身份证件验证方法](https://docs.didit.me/zh/core-technology/id-verification/verification-methods.md)生成，因此处理程序可以根据实际获得的证据进行分支处理：| 键 | 值 |
| - | - |
| `verification_method` | `document`、`id_lookup` 或 `wallet` |
| `assurance` | `documentary`、`data_match` 或 `cryptographic` |
| `wallet_provider` | 由钱包生成时为目录中的钱包 ID，否则为 `null` |
| `id_lookup` | 方法为 `id_lookup` 时的注册库证据，否则为 `null` |
| `wallet_verification` | 方法为 `wallet` 时的凭证证据，否则为 `null` |
| `fallback_from` | 非证件方法未结束身份证件验证步骤的原因：包括该方法、原因，以及用户是转为证件采集还是被拒绝 |现有处理程序无需修改即可继续工作：证件会话会将 `verification_method` 设为 `document`，其余字段保持为 `null`。各字段的详细类型见[数据模型](https://docs.didit.me/zh/reference/data-models.md#身份证件验证)。#### 已通过的钱包会话 (`status.updated`)```json theme={null}
{
  "event_id": "3a0c1b2c-4444-4555-8666-777777777777",
  "webhook_type": "status.updated",
  "timestamp": 1774970000,
  "created_at": 1774969994,
  "application_id": "11111111-2222-3333-4444-555555555555",
  "environment": "live",
  "session_id": "cccccccc-dddd-eeee-ffff-000000000000",
  "status": "Approved",
  "vendor_data": "user_77",
  "decision": {
    "session_id": "cccccccc-dddd-eeee-ffff-000000000000",
    "status": "Approved",
    "features": ["ID_VERIFICATION"],
    "id_verifications": [
      {
        "node_id": "id_verification_1",
        "status": "Approved",
        "issuing_state": "DNK",
        "first_name": "Freja",
        "last_name": "Nielsen",
        "full_name": "Freja Nielsen",
        "date_of_birth": "1988-03-02",
        "verification_method": "wallet",
        "assurance": "cryptographic",
        "wallet_provider": "mitid",
        "fallback_from": null,
        "id_lookup": null,
        "wallet_verification": {
          "provider": "mitid",
          "provider_name": "MitID",
          "issuing_authority": "Danish Agency for Digital Government",
          "issuing_country": "DNK",
          "credential_type": "person_identification",
          "level_of_assurance": "substantial",
          "verified_at": "2026-09-04T09:12:44Z",
          "signature_valid": true,
          "attributes": {
            "full_name": "Freja Nielsen",
            "date_of_birth": "1988-03-02",
            "cpr_alias": "b0f1c2d3-e4f5-4678-9abc-def012345678"
          },
          "portrait": null,
          "face_match_score": null
        },
        "warnings": []
      }
    ],
    "reviews": [],
    "created_at": "2026-09-04T09:12:20.443172Z"
  }
}
```### 实体事件 payload```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"
}
```实体状态为 `ACTIVE`、`FLAGGED` 或 `BLOCKED`。`user.data.updated` 和 `business.data.updated` 保留 `status` 字段，并新增 `changed_fields` 数组以及 `changes` 映射，其格式为 `{ field: { previous, current } }`。`business.*` 事件使用 `vendor_business_id`，而不是 `vendor_user_id`。### 交易事件 payload交易 Webhook 使用相同的分发基础设施和相同的信封结构，但用交易标识替代会话标识：```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`，或为待处理的用户操作（`type` 为 `wallet_ownership` 或 `verification_session`，并附带其 `url`），详见[所需用户操作](https://docs.didit.me/zh/transaction-monitoring/sdk-transaction-submission.md#用户所需操作)。新交易默认为 `APPROVED`。如果规则、黑名单或工作流在创建过程中更改了状态，则初始的 `transaction.created` payload 会反映该最终状态。`transaction.status.updated` 的结构相同，但 `status` 为更新后的值。交易状态包括 `APPROVED`、`IN_REVIEW`、`DECLINED` 或 `AWAITING_USER`；`severity` 取值为 `UNKNOWN`、`LOW`、`MEDIUM`、`HIGH` 或 `CRITICAL`，`direction` 取值为 `INBOUND` 或 `OUTBOUND`。`transaction.created` 和 `transaction.status.updated` 仅投递到**生产环境**应用。沙盒中的 Travel Rule 交换确实会发出 `travel_rule.status.updated`，其中 `environment: sandbox`。### KYB 注册库搜索回调异步的 [`POST /v3/kyb/search/`](https://docs.didit.me/zh/standalone-apis/kyb-registry) 流程还会投递一个额外的 payload，它**不是**目标事件：当你在搜索请求中传入 `webhook_url` 时，注册库完成解析后，Didit 会以 POST 方式向该 URL 发送 `kyb.registry_search.resolved` 回调。它与目标 Webhook 有三点不同：- 它是**未签名**的：不包含 `X-Signature*` 请求头。Didit 改用 `X-Didit-Unsigned-Callback: true` 进行标记；请将 `request_id` 与搜索请求返回的 ID 进行比对，以验证该回调。
- 事件名称位于 `event_type` 中（而不是 `webhook_type`），且不包含 `application_id` 或 `environment`。
- `timestamp` 和 `created_at` 为 epoch 整数，与其他 Webhook 相同，但与搜索端点中 ISO 格式的 `created_at` 不同。```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
}
```该回调遵循与目标 Webhook 相同的[重试策略](#重试策略)（重试会更新时间戳 `timestamp`，并保持未签名状态）。完整的候选列表结构请参见 [KYB Registry API](https://docs.didit.me/zh/standalone-apis/kyb-registry.md)。## 签名验证每个 Webhook 都使用目标的 `secret_shared_key` 进行 HMAC-SHA256 签名。我们会发送三个签名请求头，您可以选择最适合自己技术栈的一个：| 请求头 | 签名内容 | 兼容中间件 | 使用场景 |
| - | - | - | - |
| `X-Signature-V2` | 键排序、保留 Unicode 的紧凑 JSON（`ensure_ascii=False`） | 是 | **推荐**：即使您的框架重新编码请求体，也能正常工作。 |
| `X-Signature` | Didit 发送时请求的原始字节（`sort_keys`，`ensure_ascii=True`） | 否 | 仅当您能在任何解析器处理请求体之前读取原始请求体时使用。 |
| `X-Signature-Simple` **（已弃用）** | `"{timestamp}:{session_id}:{status}:{webhook_type}"` | 是 | 最后的回退方案，只验证信封，**不**验证 `decision`。使用前请先阅读下方的弃用说明。 |Didit 始终发送的请求头：| 请求头 | 示例 | 含义 |
| - | - | - |
| `Content-Type` | `application/json` | 请求体为 JSON。 |
| `User-Agent` | `DiditWebhook/2.0 +https://didit.me` | 标识 Didit 的 Webhook 工作进程。 |
| `X-Timestamp` | `1774970000` | Webhook 发出时的 Unix 时间戳（秒）。如果 `abs(now - X-Timestamp) > 300`，请拒绝该请求。 |
| `X-Signature` / `X-Signature-V2` / `X-Signature-Simple` | 十六进制摘要 | 对请求体或上述规范字符串进行 HMAC-SHA256 计算的结果。 |在目标的签名密钥轮换后的 24 小时内，投递还会携带 `X-Signature-Previous`、`X-Signature-V2-Previous` 和 `X-Signature-Simple-Previous`（参见[签名密钥轮换](#签名密钥轮换)）。### 签名密钥轮换您可以在 Business Console 中轮换目标的签名密钥。
轮换后的 24 小时内，发往该目标的每次投递都会额外携带三个请求头，它们使用被替换的密钥生成：| 请求头 | 对应的签名 | 签名所用密钥 |
| - | - | - |
| `X-Signature-V2-Previous` | `X-Signature-V2` | 被替换的密钥 |
| `X-Signature-Previous` | `X-Signature` | 被替换的密钥 |
| `X-Signature-Simple-Previous` | `X-Signature-Simple`（已弃用） | 被替换的密钥 |只要处理程序接受普通签名请求头或其 `-Previous` 请求头中的任一匹配结果，在部署新密钥期间就能持续完成验证：旧密钥与 `-Previous` 请求头匹配，新密钥与普通请求头匹配。下方的代码示例即采用这种做法。
请在 24 小时内部署新密钥，因为此后只有新密钥能通过验证。[`GET /v3/webhook/destinations/{destination_uuid}/`](https://docs.didit.me/zh/management-api/webhook/get-destination) 上的 `previous_secret_expires_at` 会告诉您这 24 小时何时结束。重试和重发在发送时会使用目标当前的密钥重新签名，并且只在这 24 小时内携带 `-Previous` 请求头。
在 24 小时内再次轮换，会立即让最旧的密钥失效：只有在新一轮轮换前处于当前状态的密钥，才会在新一轮轮换后的 24 小时内继续通过 `-Previous` 请求头验证。`-Previous` 请求头仅适用于 Webhook 目标。用于签名旧版 `webhook_url` 的应用级 `secret_shared_key` 没有重叠期：使用 [`PATCH /v3/webhook/`](https://docs.didit.me/zh/sessions-api/management-api#webhook-配置) 上的 `rotate_secret_key` 轮换它后，旧密钥会立即失效。### `X-Signature-Simple` 已弃用> [!WARNING]
>
> **`X-Signature-Simple` 验证的是信封，而不是 payload。** 其规范字符串只包含 `timestamp`、`session_id`、`status` 和 `webhook_type`。它**不**包含 `decision`，也不包含 `resubmit_info`、`metadata`、`vendor_data` 或 `event_id`。如果 `decision` 被改写，而这四个字段保持不变，请求仍会通过 `X-Signature-Simple` 验证。
>
> 对于完全不包含 `session_id` 的事件，它的保护更弱。交易事件（`transaction_id`、`txn_id`、`amount`、`score`）和实体事件（`vendor_user_id`、`vendor_business_id`）的签名内容为 `"{timestamp}::{status}:{webhook_type}"`，它无法标识事件涉及的是**哪一笔**交易或**哪个**实体。同一秒内以相同状态上报的两笔不同交易，会得到相同的 `X-Signature-Simple`。
> `travel_rule.status.updated`、`workflow.compliance_changed` 和 `transaction_rule_suggestions.created` 事件也不包含 `status`，因此它们的签名内容为 `"{timestamp}:::{webhook_type}"`，例如 `"{timestamp}:::travel_rule.status.updated"`。
>
> 如果 `X-Signature-Simple` 是您唯一检查的请求头，请将这四个字段之外的所有内容视为**不可信**，并通过 API 重新获取记录：会话请使用 [`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/zh/sessions-api/retrieve-session)，交易请使用 [`GET /v3/transactions/{transaction_id}/`](https://docs.didit.me/zh/management-api/transactions/get)。这些 API 才是权威记录的来源。这是兼容性约束，而不是我们可以原地修补的问题。四字段规范字符串是一份线上协议：每个验证 `X-Signature-Simple` 的接收方都会精确地重新计算这四个字段，因此改变该请求头的签名内容，会让所有接收方在签名验证环节同时失效。要让签名覆盖请求体，还需要一种双方都能逐字节复现的规范 JSON 格式，而 Didit 已经以 `X-Signature-V2` 的形式提供了这种格式。与其再增加一个带有独立规范化规则的、覆盖请求体的变体，我们保持该请求头不变，并将其标记为已弃用，转而推荐现有的完整 payload 变体。这对您意味着：- **线上行为不变。** `X-Signature-Simple` 会在每次投递中继续发送，计算方式保持不变。现有验证程序将继续正常工作。如需移除，我们会提前充分通知。
- **新集成必须使用 `X-Signature-V2`。** 它兼容中间件，**并且**覆盖整个 payload。
- **如果您无法停止使用 `X-Signature-Simple`**，请先验证它，然后通过 API 获取记录，而不是从 Webhook 的请求体中读取数据。### 为什么有三种变体？一些 Web 框架（Express 请求体解析器、Django 中间件、API 网关）会在您的处理程序读取请求体之前，悄悄地重新编码 JSON。如果原始请求体包含 `"José"`，而您的中间件将其改写为 `"José"`，那么即使数据相同，字节也变了，`X-Signature` 就会验证失败。`X-Signature-V2` 基于规范 JSON 形式计算（键排序、紧凑分隔符、**不转义 Unicode**），几乎所有中间件都能确定性地复现它。而已弃用的 `X-Signature-Simple` 在连这一步也失败时，会退而只对一个简短、与解析器无关的字符串签名。由于它只签名这个短字符串，从不签名请求体，因此无法验证请求体的内容。### 推荐的验证顺序1) 首先尝试 **`X-Signature-V2`**。将 `JSON.parse(body)` 按排序后的键重新编码，并保留 Unicode 字符，然后计算 `HMAC-SHA256(secret, canonical)`，并使用 `timingSafeEqual` 与请求头进行比较。
2) 如果 V2 验证失败，且你可以读取原始字节，则尝试用 `HMAC-SHA256(secret, rawBody)` 验证 **`X-Signature`**。
3) 仅当两者都失败时，才回退到已弃用的 **`X-Signature-Simple`**，即验证 `HMAC-SHA256(secret, "{timestamp}:{session_id}:{status}:{webhook_type}")`。这种方式只能验证外层信封。请将正文中的 `decision` 视为不可信，并在据此采取行动之前，通过 [`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/zh/sessions-api/retrieve-session) 重新获取。每一步中，如果存在对应的 `-Previous` 请求头，则两个请求头中任意一个匹配即可接受（参见 [签名密钥轮换](#签名密钥轮换)）。在所有情况下，对于时间戳距当前时间超过 **5 分钟** 的任何请求（`abs(now - X-Timestamp) > 300`），都应 **拒绝**，以防御重放攻击。## 重试策略如果你的 endpoint 返回 `5xx` 或 `404`，Didit 会以指数退避方式最多重试 **2 次**：- **第 1 次重试**：约在首次失败后 **1 分钟**。
- **第 2 次重试**：约在第 1 次重试后 **4 分钟**。第二次重试之后，该次投递将被丢弃。每次投递尝试（首次投递及重试）都会在 Business Console 中该目标的 **投递** 标签页下记录为单独的条目，因此你可以重放或检查任何一次尝试。投递日志中的每次尝试都带有 `attempt` 编号：事件的首次投递为 `1`，之后每次重试或重新发送，编号比其所重复的那次尝试多 1。
自动重试会发送到首次尝试所使用的 URL，即使之后你已修改了目标的 `url`。
手动重新发送会发送到目标当前的 URL，因此修正 URL 也会修正重新发送的结果。需要了解的其他投递规则：- 出站 HTTP 请求在 **5 秒** 后超时。
- 出于 SSRF 防护的考虑，Didit 会阻止向私有地址或 localhost URL 投递，并且从不跟随重定向。请使用可直接响应的公共 HTTPS endpoint。
- 目标 `url` 中如果包含原始反斜杠、控制字符，或 URL 的权限部分中出现多个 `@`，将被视为格式错误而拒绝。此类 URL 无法创建，已有的 URL 也无法重新投递或重放。请使用普通的 `https://host/path` 格式 URL，并且最多只包含一个用户信息段（用于基本认证风格的 Webhook URL）。
- `2xx` 视为成功。超时和连接失败可以重试（在投递日志中显示为 `504`/`503`）；除 `404` 以外的 `3xx` 和 `4xx` 响应**不会**重试。
- 重试时，Didit 会使用新的 `X-Timestamp` 重新计算签名，因此时间戳与签名始终保持一致。
- 重试和重新发送均使用目标在发送时的当前签名密钥进行签名。### 投递顺序Didit 不保证 Webhook 到达你这里的顺序。
每次投递都是独立排队的，且重试会在首次尝试之后数分钟才到达，因此，针对 `Approved` 的 `status.updated` 可能会在针对 `In Progress` 的较早事件之前到达。据此构建你的接收端：- 将 Webhook 视为某些内容已发生变化的信号。当顺序很重要时，调用 [`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/zh/sessions-api/retrieve-session) 并根据当前的 `status` 采取行动。
- 决不允许较旧的事件覆盖较新的状态：将事件的 `created_at` 与你为该记录存储的上一个事件的时间戳进行比较，忽略较旧的事件。
- 以 `event_id` 作为重复事件的键，该值在重试时保持不变。Didit 会按固定顺序对 Travel Rule 转账的事件进行排队，具体描述见 [Travel Rule 交换状态变更](#travel-rule-交换状态变更-travel_rule-status-updated)，但重试仍可能改变事件到达的顺序。## 设置目标你可以在 **Business Console > API & Webhooks** 中管理目标，也可以通过 Management API 进行管理。**1. 创建目标**调用 [`POST /v3/webhook/destinations/`](https://docs.didit.me/zh/management-api/webhook/create-destination)（或在控制台中点击 **添加目标**），并提供 `label`、公共 `url`、`webhook_version`（`"v3"`），以及一个 `subscribed_events` 数组，其中包含该 endpoint 应接收的每个事件类别。至少需要一个事件，且不存在通配符。**2. 保存 secret\_shared\_key**创建响应会返回该目标的 `secret_shared_key`。请将其与其他服务器密钥一起保存：它只属于此目标，并且是所有三种 HMAC-SHA256 签名变体的输入。[`GET /v3/webhook/destinations/{destination_uuid}/`](https://docs.didit.me/zh/management-api/webhook/get-destination) 会再次将其返回给 API 密钥调用方，以及有权读取 Webhook 的控制台用户。**3. 实现 endpoint**公开一个 HTTPS `POST` 路由，读取原始请求体，验证签名（优先使用 `X-Signature-V2`），检查时间戳窗口，然后根据 `webhook_type` 进行分派。一旦工作已加入队列，即返回 `2xx`，繁重的处理请异步进行。**4. 确认接收**在该目标的 **投递** 标签页中查看状态。正常的 Webhook 会在一两秒内返回 `200`。`5xx`/`404` 会触发 Didit 的重试策略；持续失败的投递在第二次重试后被丢弃。**5. 使用 Try Webhook 进行迭代**使用 [**Try Webhook**](#测试) 控制台场景，驱动你的 endpoint 依次处理已通过、已拒绝、审核中、KYB、实体、活动和交易的 payload，然后再开启生产流量。![Business Console 中的 Didit Webhook 测试功能](https://mintcdn.com/didit-0f962782/z6T2GHM4Zh-iSj-K/images/test-webhook.png?fit=max\&auto=format\&n=z6T2GHM4Zh-iSj-K\&q=85\&s=2787ef3ed7a8016798f951e5ae898e4e)直接从 Business Console 发送测试 Webhook，在正式上线前验证你的 endpoint。你可以根据需要创建任意数量的目标。常见的做法是**每个使用方一个目标**：例如，KYC 事件发送到你的认证服务，KYB 事件发送到你的合规运营服务，交易事件发送到你的欺诈处理队列。每个目标都有自己的密钥、版本和订阅列表，因此一个使用方的密钥轮换或错误部署，不会影响其他使用方。## 代码示例为确保您的 Webhook endpoint 安全，请使用目标的 `secret_shared_key` 验证传入请求的真实性。> [!WARNING]
>
> **请使用 `X-Signature-V2`。** 它最能抵御中间件重新编码带来的影响，并且能够完整验证请求体（包括 `decision`）。### Node.js / Express (推荐使用 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}`));
```如果您能够在任何解析器处理原始字节之前读取它们，原始的 `X-Signature` 变体同样可以使用，但它对原始请求体进行签名。为完整起见，我们将其保留在下方的旧版章节中。### Python / FastAPI (推荐使用 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 (推荐使用 X-Signature-V2)PHP 的 JSON 默认设置**无法**生成规范形式。其中有四项需要覆盖，缺少任何一项都会改变被签名的字节，导致摘要永远无法匹配：| PHP 默认行为 | 实际输出 | 需要的设置 |
| - | - | - |
| `json_decode($raw, true)`（关联数组模式） | 空对象 `{}` 会被折叠为 `[]` | 使用 `$associative = false` 解码，使 JSON 对象保持为 `stdClass` |
| `json_encode` 会转义正斜杠 | `https:\/\/media.didit.me` | `JSON_UNESCAPED_SLASHES` |
| `json_encode` 会转义非 ASCII 字符 | `Jos\u00e9` 而不是 `José` | `JSON_UNESCAPED_UNICODE` |
| `ksort` 会将看似数字的键按数值比较 | `"9"` 排在 `"10"` 之前 | `ksort($props, SORT_STRING)` |空对象陷阱是其中最棘手的一项。Didit 的 payload 中经常包含空映射，例如 `risk_view.countries.risk_scores`、`risk_view.crimes.risk_scores` 和 `risk_view.custom_list`，因此关联数组的往返转换会在普通的已通过会话上破坏签名，而不只是在少数罕见的边缘情况下出错。```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]);
    }
}
```另外两点需要注意：- **在 PHP 中，整数值的浮点数无需特殊处理。** Didit 将它们序列化为整数（`100.0` → `100`），PHP 的 `json_encode` 对整数值浮点数的输出方式相同，因此不需要像上面的 Node.js 示例那样执行 `shortenFloats` 步骤。
- **请保持 `serialize_precision` 为 `-1`**（自 PHP 7.1 起的默认值）。如果您的主机将其覆盖为 `17`，`json_encode` 会将 `87.42` 输出为 `87.420000000000002`，每次 `X-Signature-V2` 校验都会失败。如果您无法使规范形式匹配，上文中的 `verifySignatureRaw` 是更简单的备选方案：Laravel 的 `$request->getContent()` 会返回未经修改的原始字节，因此只要 PHP 前面的任何环节都没有重新编码请求体，`X-Signature` 就能正常工作。这正是 `X-Signature-V2` 设计用来应对的情况，也是 V2 优先尝试的原因。## 测试您**无需**运行完整的验证流程，即可测试您的 endpoint。- 在 **Business Console > API & Webhooks > Try Webhook** 中选择一个场景（例如 `approved_full_features`、`declined_face_mismatch`、`in_review_aml_hit`、`business_session_approved`、`transaction_created`、`activity_created`、`user_status_updated`），然后将一个完整构造的 Webhook 发送到您的 Webhook 目标 URL。
- 每个场景都使用与生产流量相同的三个签名请求头和规范编码进行签名，包括用于测试 Unicode 数据的 `approved_kyc_with_unicode`，以便您确认 `X-Signature-V2` 能够通过您的中间件正常工作。测试投递包含示例数据，并带有额外的 `X-Didit-Test-Webhook: true` 请求头（同时 `User-Agent` 中带有 `(Test)` 后缀），以便您将其排除在生产状态之外。
- 如需调试回归问题，可以从 Webhook 目标的 **Deliveries** 标签页重放任何历史投递。对于端到端的冒烟测试，请在您自己的应用中通过测试工作流运行一次真实验证。由此产生的 Webhook 与 **Try Webhook** 场景使用相同的信封、请求头和重试策略。## Examples### 已通过的 KYC 会话 (`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"
  }
}
```### 已拒绝且带警告的会话 (`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": []
  }
}
```### 已通过的企业会话 (`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": []
  }
}
```### 已创建的交易 (`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
}
```### Travel Rule 交换状态变更 (`travel_rule.status.updated`)```json theme={null}
{
  "event_id": "9c0c8b8a-1111-4222-9333-666666666666",
  "webhook_type": "travel_rule.status.updated",
  "timestamp": 1774970000,
  "created_at": 1774970000,
  "application_id": "11111111-2222-3333-4444-555555555555",
  "environment": "live",
  "transaction_id": "22222222-3333-4444-5555-666666666666",
  "txn_id": "trv-2026-07-05-001",
  "travel_rule_status": "COMPLETED",
  "rail": "INTERNAL",
  "direction": "OUTBOUND"
}
````travel_rule_status` 的值为 [Travel Rule](https://docs.didit.me/zh/transaction-monitoring/travel-rule.md#生命周期) 中记录的 13 种交换状态之一。
`rail` 的值为 `INTERNAL`、`TRP`、`GTR`、`TRUST`、`VERIFYVASP`、`SYGNA` 或 `EMAIL` 之一（在选定通道之前为空字符串；如果无法确定通道，或在 sunrise 注册中，始终为空字符串），或者当 [Didit Test VASP](https://docs.didit.me/zh/transaction-monitoring/travel-rule-sandbox.md) 响应沙盒转账时为 `TEST_VASP`。`direction` 的值为 `INBOUND` 或 `OUTBOUND`。
沙盒事件包含 `"environment": "sandbox"`。`TEST_VASP` 事件还包含 `test_vasp: true` 和 `test_vasp_scenario`（即做出响应的场景）。当 Didit 为 Travel Rule 转账创建交易时（包括调用 [`POST /v3/transactions/`](https://docs.didit.me/zh/management-api/transactions/create)、[存款后 sunrise 注册](https://docs.didit.me/zh/transaction-monitoring/travel-rule.md#存款后的-sunrise-登记)、入站 `TRP` 查询，或 `INTERNAL` 通道的入站环节），会先发送该交易的 `transaction.created` 事件，随后再发送该转账的 `travel_rule.status.updated` 事件。
如果某条规则在此步骤中更改了交易状态，`transaction.status.updated` 可能会在 `transaction.created` 之前发送。如果 sunrise 注册关联到您已创建的存款交易，则不会发送 `transaction.created`。
每个 Webhook 都是独立投递和重试的，因此，重试的事件仍可能晚于后续事件到达。请以 `transaction_id` 为键处理事件；需要了解交易当前状态时，请重新读取该交易。
通过 TRUST 发送的转账永远不会收到 `COMPLETED` 事件，因为 TRUST 不会返回 Didit 能够读取的任何内容。该转账会保持在 `AWAITING_COUNTERPARTY` 状态，直到确认截止时间，然后以 `EXPIRED` 结束。通过 GTR、VerifyVASP 和 Sygna Bridge 发送的转账，Didit 会从网络中读取交易对手的答复，事件随之发出（参见 [Travel Rule 网络](https://docs.didit.me/zh/transaction-monitoring/travel-rule-networks.md)）。### 工作流合规状态变更 (`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` 和 `transition.to` 的值为 `compliant`、`gaps_detected`、`pending_change`、`unknown_coverage` 或 `not_evaluated` 之一。
检测到缺口时，`severity` 为 `actionable`；待变更时为 `indicative`；缺口已解决时为 `informative`。
Didit 会为每个发生变化的工作流发送一个事件。### 已创建的交易规则建议 (`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`，修改现有规则时为 `TWEAK`，关闭规则时为 `DISABLE`。`ADD` 时 `rule_id` 为 `null`。
`open_count` 是本次运行后仍处于待处理状态的建议数量，包括新增的建议。
规则建议设置用于开启或关闭此 Webhook，参见 [交易 Webhook](https://docs.didit.me/zh/transaction-monitoring/webhooks.md)。### Investigator 事件 (`investigator.*`)十二个 `investigator.*` 事件使用与其他所有事件相同的通用字段（`event_id`、`webhook_type`、`timestamp`、`created_at`、`application_id`、`environment`）以及相同的签名请求头。它们只包含 ID 和数值：绝不包含姓名、审核员输入的理由、叙述内容或终端用户填写的任何内容。它们没有 `session_id`，所涉及的项目在 `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
}
```| 事件 | 除通用字段外的字段 |
| - | - |
| `investigator.report.ready` | `run_id`、`queue`、`item`、`recommendation`、`confidence`、`provenance`、`ready_to_accept`、`questions`（数量） |
| `investigator.decision.automated` | `run_id`、`queue`、`item`、`recommendation`、`confidence`、`provenance`、`decided_by`（`"investigator"`）、`outcome`、`action`、`final_status`、`agreed` |
| `investigator.decision.overridden` | 与 `investigator.decision.automated` 相同，但 `decided_by` 为 `"human"`，并另外包含 `override_category`、`overrode`（`"report"` 或 `"automated"`），以及当 `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"` 或 `"spend_limit"`）；当为 `"spend_limit"` 时还包括 `month`（`YYYY-MM`）和 `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`（风险代码）、`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` 说明编写报告的 Investigator 版本：- `engine` 是 Investigator 引擎的版本，为一个不透明的八个字符 ID。每当引擎变化时它都会改变，因此 `engine` 相同的两份报告由同一引擎编写。
- `agent_version` 是代理的版本。
- `policy_version` 是报告所遵循的您自己的策略版本。
- `model_id` 是已弃用的别名，其值与 `engine` 相同。请改读 `engine`。
- 不再发送 `prompt_hash`。`item.kind` 说明项目的类型（会话、企业会话、交易或案件），`item.id` 为其 ID。以 `_at` 结尾的时间戳为 ISO 8601 字符串。与订阅其他事件一样，请在 `subscribed_events` 中列出确切的字符串来订阅这些事件。### 用户实体状态变更 (`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"
}
```### 重新提交的会话```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" }
  }
}
```## 旧版 X-Signature 与原始请求体每次投递仍会发送 `X-Signature`。它是对 Didit 传输的**完全一致的字节**计算的 HMAC-SHA256（`json.dumps(..., sort_keys=True, separators=(",", ":"))`）。仅当你能在任何中间件修改请求体之前读取原始请求体时才使用它。> [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}
> ```## 旧版 V2 Webhook 格式> [!WARNING]
>
> 下列字段仅在 Webhook 目标固定为 `webhook_version: "v2"` 时适用。还存在更早的 `"v1"` 固定版本（第三种单数形式的决策结构），适用于 V2 之前创建的目标，依赖它之前请先联系支持团队。新集成应使用 **V3**（默认版本），其文档见上文，且使用复数数组。| 项目 | V2 | V3 |
| - | - | - |
| 签名请求头 | `X-Signature`、`X-Signature-V2`、`X-Signature-Simple`（无论版本如何，三者均会发送；Simple 已[弃用](#x-signature-simple-已弃用)） | 同上 |
| 决策字段名 | 单数对象（`id_verification`、`liveness`、`face_match`、...） | 复数数组（`id_verifications`、`liveness_checks`、`face_matches`、...） |
| 多个功能实例 | 不支持 | 通过带 `node_id` 的按功能数组支持 |
| AML 分数 | `aml.score`、`aml.hits[].match_score`、`aml.hits[].risk_score` | 相同的值位于 `aml_screenings[]` 下 |两个版本中的按功能对象完全相同：V2 目标接收的单条命中的 `match_score`、`risk_score`、`score_breakdown`、`risk_view` 和 `review_status`，以及汇总的 `score`，与 V3 完全一致（参见 [AML 报告](https://docs.didit.me/zh/core-technology/aml-screening/report-aml-screening.md#三项评分而非单一结论)）。固定为 V2 只会改变 AML 报告在 `decision` 中的位置，不会改变其包含的内容。如果下游使用方仍需要 V2 结构，请创建一个单独的、固定为 `webhook_version: "v2"` 的目标，并让新的使用方继续使用 V3。## 相关内容- [Webhook 目标 API](https://docs.didit.me/zh/management-api/webhook/list-destinations.md)：列出、创建、更新、删除目标，并查看投递日志。
- [创建 Webhook 目标](https://docs.didit.me/zh/management-api/webhook/create-destination.md)：请求与响应结构，包括目标的 `secret_shared_key`。
- [数据模型](https://docs.didit.me/zh/reference/data-models.md)：嵌入 `decision` 中每个按功能对象的标准结构。
- [决策结构](https://docs.didit.me/zh/sessions-api/retrieve-session.md)：会话 Webhook 中嵌入的 `decision` 对象的完整字段列表。
- [验证状态](https://docs.didit.me/zh/integration/verification-statuses.md)：上文提到的确切 `status` 值及其状态流转。
- [速率限制](https://docs.didit.me/zh/integration/rate-limiting.md)：将轮询 `GET /v3/session/{id}/decision/` 作为备用方案时适用的限制。


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