> ## 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/ja/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 がバックエンドへ**リアルタイムの更新**をプッシュする仕組みです。1つ以上の送信先を必要なイベントに登録しておけば、結果が出た瞬間に対応できます。通常は、ユーザーがフローを完了してから数秒以内です。Webhookは**推奨**される連携パターンです。[`GET /v3/session/{id}/decision/`](https://docs.didit.me/ja/sessions-api/retrieve-session)のポーリングもフォールバックとしてサポートしていますが、速度が遅く、リクエスト数が増えるうえ、`data.updated`、`transaction.status.updated`、エンティティレベルの変更など、Webhookでのみ送信されるイベントを取得できません。Webhook配信を信頼できる唯一の情報源として扱い、ポーリングはコールドスタート時または照合のためにのみ使用してください。Didit Academyの連携レッスンのこのチャプターは、Webhookの概要から始まります。連携に関するAcademyレッスンでは、3:12からWebhookを取り上げています。送信先の追加、イベントの選択、そして以下で検証する署名シークレットについて説明しています。[Webhookの概要 — 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` から`User-Agent: DiditWebhook/2.0 +https://didit.me`でWebhookを配信します。エッジで不明なクライアントをブロックしている場合は、**Security > WAF > Tools > IP Access Rules**でこのIPを許可し、受信側のホスト名に対して**許可**を選択してください。## イベントタイプWebhookの送信先の`subscribed_events`配列には、以下の値をそのまま指定してください。ワイルドカードはないため、受信したいイベントファミリーをすべて列挙する必要があります。また、イベントを複数の送信先に振り分けることもできます。| イベント | リソース | 発生するタイミング |
| - | - | - |
| `status.updated` | KYCセッションまたはKYB企業セッション | セッションのステータスが変わったとき(未開始 → 進行中 → 承認済み / 却下 / レビュー中 / 放棄 / 再提出)。KYBのペイロードには`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/コンソールでのアクションによって取引のステータスが変わったとき。また、バックグラウンドで再試行された暗号資産スクリーニングが完了したときにも、ステータスの変化の有無にかかわらず1回送信されます([まだインデックスされていないトランザクションハッシュ](https://docs.didit.me/ja/transaction-monitoring/aml-screening.md#インデックス未登録の取引ハッシュ))。 |
| `travel_rule.status.updated` | トラベルルール | [トラベルルール](https://docs.didit.me/ja/transaction-monitoring/travel-rule.md)のやり取りのステータスが変わったとき。作成時(しきい値未満による`NOT_APPLICABLE`への遷移を含む)、所有構造の確認時、メールでの受け取りに対する応答時、完了/キャンセル時、またはタイムアウトによる期限切れ時に発生します。送信取引と受信取引の両方で発生します。受信取引には、`INTERNAL`レールの受信側の対応取引、サンライズ登録、受信した`TRP`照会が含まれます。Diditが作成する取引の場合、これらのイベントはその取引の`transaction.created`の後に送信されます。`transaction_id`、`txn_id`、`travel_rule_status`、`rail`、`direction`が含まれます。 |
| `workflow.compliance_changed` | ワークフローのコンプライアンス | 公開済みワークフローのコンプライアンスステータスが再評価によって変わったとき。ワークフローごとに1件のイベントが送信されます。`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が自律的に行った判定を覆したとき。 |
| `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 | キューにドリフトが発生し、自動化のレベルが1段階下がったとき。 |
| `investigator.sla.breached` | Investigator | レポートが判定されないまま、キューのSLAを超えて待機したとき。 |
| `investigator.qa.failed` | Investigator | 担当者が行った判定がInvestigatorの品質レビューで不合格になったとき。 |
| `investigator.external.report.ready` | Investigator | Didit外部から送信したアイテムに関するレポートの準備ができたとき。 |
| `auth.signup` | 認証 | ユーザーが[認証](https://docs.didit.me/ja/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/ja/standalone-apis/kyb-registry)にリクエストごとに渡す`webhook_url`に送られ、送信先に送られることはありません。> [!TIP]
>
> Business Console > API & Webhooks > **Try Webhook**メニューは、これらすべてのイベントについて完全なペイロードを送信します（承認済み、却下、レビュー中、Unicode文字を含む名前、KYC/KYB、エンティティ、アクティビティ、取引）。本番環境に移行する前に、このメニューでエンドポイントを検証してください。## ペイロードの構造### エンベロープすべてのWebhookは同じエンベロープを共有します。フィールドは常に`sort_keys=True`とコンパクトな区切り文字（`,` / `:`）でシリアライズされます。そのため、同じペイロードから送信側と受信側の双方で同じ署名が再現されます。| フィールド | 型 | 備考 |
| - | - | - |
| `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ペイロードを参照）。セッションイベントは[検証ステータスのラベル](https://docs.didit.me/ja/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/ja/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`。すでに受け取った判定について2件目のイベントを受信する可能性があるため、`session_id`と`status`で重複排除してください）、`"manual_webhook_recovery"`（手動で復旧されたWebhook）、`"kyb_registry_change"`（法人登記で変更が報告された）、`"console_registry_edit"`、`"console_key_people_skip"`、`"console_key_people_regenerate"`（企業セッションに対してConsoleで行われた編集）、`"biometric_retention"`（セッションのライブ生体データが予定どおり削除された際に送信される`data.updated`）があります。値の種類は今後増える可能性があるため、未知の値は参考情報として扱ってください。 |
| `decision` | object | `status`が`Approved`、`Declined`、`In Review`、`Abandoned`のいずれかの場合に含まれます。[V3セッションの判定](https://docs.didit.me/ja/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/ja/sessions-api/retrieve-session)が返すものと同じスキーマです。機能ごとのフィールドの正式なリファレンス（`id_verifications[]`、`aml_screenings[]`などのすべてのプロパティ）については、[データモデル](https://docs.didit.me/ja/reference/data-models.md)を参照してください。スキーマを1か所でのみ管理するため、ここでは機能ごとのフィールド表をあえて掲載していません。### V3の複数形の配列 (重要)`decision`内では、機能ごとの結果はすべて**複数形の配列**になっています。そのため、1つのワークフローに同じ機能の複数のインスタンスを含めることができます（例: 複数の書類にわたる複数のID確認）。```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_version: "v2"`に固定されている場合にのみ表示されます。**電子署名**の結果は`signatures[]`に含まれ、ワークフローに電子署名ステップがある場合にのみ存在します。署名者ごとに1件の要素があり、署名者、コード確認、証明書、タイムスタンプ、各書類のハッシュとダウンロードパス、エビデンスPDFが含まれます。署名専用のイベントはありません。読み取るタイミングは`status.updated`で判断してください。[電子署名レポート](https://docs.didit.me/ja/core-technology/electronic-signature/report-electronic-signature.md)を参照してください。**銀行口座確認**の結果は`bank_verifications[]`に含まれ、銀行口座確認ステップごとに1件の要素があります。各要素の`method`は口座の確認方法を示し、`open_banking`（銀行へのサインイン）、`account_lookup`（口座チェック）、またはフォールバックとしての`statement`か`microdeposits`のいずれかで、口座が評価されるまでは`null`です。本人が**Continue without your bank**を選択した場合、`continued_without_bank`は`true`となり、`continuation`にその理由が示されます。この場合、`method`は`none`になります。ただし、それ以前に試行が評価されていた場合は除きます。[本人が銀行口座を確認できない場合](https://docs.didit.me/ja/core-technology/bank-verification/fallbacks.md#結果で確認する)を参照してください。口座チェックで確認された口座では財務データは読み取られないため、ステップで要求された残高、取引、収入は`financial_data.not_collected`に列挙されます。[銀行口座確認の方法](https://docs.didit.me/ja/core-technology/bank-verification/verification-methods.md#結果で方法を確認する)を参照してください。### Investigator ブロックユーザーセッションまたは企業セッションの V3 `decision` には `investigator` も含まれます。これはそのセッションの最新の[Investigator](https://docs.didit.me/ja/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 | 推奨アクションについての1行の説明。 |
| `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自身の判定が有効な間は `investigator`、それ以外は `member`、未判定の間は `null`。 |
| `decision` | object \| null | 項目がキューからどのように外れたか: `outcome`、`action`、`final_status`、`agreed`、`decided_at`、`billed`、および判定者。 |書類収集系の機能(**KYB書類**と**Document AI**)には専用の複数形配列はありません。これらは判定の `features[]` リストに `{ "feature": "DOCUMENT_AI", "node_id": "…" }` として表示されます(ノードごとに1エントリ)。詳しくは[Document AI](https://docs.didit.me/ja/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/ja/getting-started/biometric-consent.md)を参照してください。### 本人確認方法のキーすべての `id_verifications[]` 項目には、その項目を生成した[本人確認方法](https://docs.didit.me/ja/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/ja/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"
  }
}
```### エンティティイベントのペイロード```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` 配列と `{ field: { previous, current } }` 形式の `changes` マップを追加します。`business.*` イベントでは `vendor_user_id` の代わりに `vendor_business_id` を使用します。### 取引イベントのペイロード取引の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`、または[必要なユーザーアクション](https://docs.didit.me/ja/transaction-monitoring/sdk-transaction-submission.md#ユーザーに必要なアクション)で説明している保留中のユーザーアクション(`type` が `wallet_ownership` または `verification_session` で、その `url` を含む)です。新しい取引のデフォルトは `APPROVED` です。作成中にルール、ブロックリスト、またはワークフローがステータスを変更した場合、最初の `transaction.created` ペイロードにはその最終ステータスが反映されます。`transaction.status.updated` は同じ形式で、更新後の `status` を含みます。取引のステータスは `APPROVED`、`IN_REVIEW`、`DECLINED`、`AWAITING_USER` のいずれかです。`severity` は `UNKNOWN` / `LOW` / `MEDIUM` / `HIGH` / `CRITICAL`、`direction` は `INBOUND` / `OUTBOUND` です。`transaction.created` と `transaction.status.updated` は**本番環境**のアプリケーションに対してのみ配信されます。サンドボックスでのトラベルルールのやり取りでは、`environment: sandbox` 付きで `travel_rule.status.updated` が送信されます。### KYB登記検索のコールバック非同期の[`POST /v3/kyb/search/`](https://docs.didit.me/ja/standalone-apis/kyb-registry)フローでは、送信先イベント**ではない**追加のペイロードが1つ配信されます。検索リクエストに `webhook_url` を渡すと、登記の照会が完了した時点で、Didit がその URL に `kyb.registry_search.resolved` コールバックを POST します。このコールバックは、送信先Webhookと次の3点で異なります。- **署名なし**です。`X-Signature*` ヘッダーはありません。代わりに Didit は `X-Didit-Unsigned-Callback: true` を付与します。検証するには、`request_id` を検索リクエストが返したIDと照合してください。
- イベント名は `event_type` に入り(`webhook_type` ではありません)、`application_id` と `environment` はありません。
- `timestamp` と `created_at` はエポック整数です。これは他の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/ja/standalone-apis/kyb-registry.md)を参照してください。## 署名検証すべてのWebhookは、送信先の`secret_shared_key`を使ってHMAC-SHA256で署名されます。3種類の署名ヘッダーが送信されるため、お使いのスタックで損なわれずに届くものを選べます:| ヘッダー | 署名対象 | ミドルウェア耐性 | 使用する場面 |
| - | - | - | - |
| `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` | 16進数のダイジェスト | 上記のボディまたは正規化文字列のHMAC-SHA256。 |送信先の署名シークレットがローテーションされてから24時間は、配信に`X-Signature-Previous`、`X-Signature-V2-Previous`、`X-Signature-Simple-Previous`も付与されます([署名シークレットのローテーション](#署名シークレットのローテーション)を参照)。### 署名シークレットのローテーション送信先の署名シークレットはBusiness Consoleでローテーションします。
ローテーション後24時間は、その送信先へのすべての配信に、置き換えられたシークレットで作成された3つの追加ヘッダーが付与されます:| ヘッダー | 対応する署名 | 署名に使うシークレット |
| - | - | - |
| `X-Signature-V2-Previous` | `X-Signature-V2` | 置き換えられたシークレット |
| `X-Signature-Previous` | `X-Signature` | 置き換えられたシークレット |
| `X-Signature-Simple-Previous` | `X-Signature-Simple`(非推奨) | 置き換えられたシークレット |署名ヘッダーとその`-Previous`ヘッダーのどちらかが一致すれば受け入れるハンドラーであれば、新しいシークレットをデプロイしている間も検証を継続できます。古いシークレットは`-Previous`ヘッダーと一致し、新しいシークレットは通常のヘッダーと一致するためです。
以下のコードサンプルはこの処理を行っています。
24時間を過ぎると新しいシークレットでしか検証できなくなるため、24時間以内に新しいシークレットをデプロイしてください。
[`GET /v3/webhook/destinations/{destination_uuid}/`](https://docs.didit.me/ja/management-api/webhook/get-destination)の`previous_secret_expires_at`で、24時間が終了する時刻を確認できます。再試行と再送は、送信時点の送信先の現在のシークレットで改めて署名され、`-Previous`ヘッダーが付与されるのは24時間の間だけです。
24時間以内に再度ローテーションすると、最も古いシークレットは即座に無効になります。新しいローテーションの直前まで現在のシークレットだったものだけが、新しいローテーションから24時間、`-Previous`ヘッダーで検証され続けます。`-Previous`ヘッダーはWebhookの送信先にのみ存在します。
レガシーの`webhook_url`に署名するアプリケーションレベルの`secret_shared_key`には重複期間がありません。[`PATCH /v3/webhook/`](https://docs.didit.me/ja/sessions-api/management-api#webhook設定)の`rotate_secret_key`でローテーションすると、古いシークレットは即座に無効になります。### `X-Signature-Simple`は非推奨> [!WARNING]
>
> **`X-Signature-Simple`が認証するのはエンベロープであり、ペイロードではありません。** その正規化文字列が対象とするのは`timestamp`、`session_id`、`status`、`webhook_type`のみです。`decision`は対象に**含まれません**。`resubmit_info`、`metadata`、`vendor_data`、`event_id`も同様です。これら4つのフィールドはそのままで`decision`だけが書き換えられたリクエストでも、`X-Signature-Simple`の検証を通過してしまいます。
>
> `session_id`をまったく持たないイベントでは、さらに脆弱になります。取引イベント(`transaction_id`、`txn_id`、`amount`、`score`)とエンティティイベント(`vendor_user_id`、`vendor_business_id`)は`"{timestamp}::{status}:{webhook_type}"`に署名しますが、これではイベントが**どの**取引またはエンティティに関するものかを特定できません。同じ秒に同じステータスで報告された2つの異なる取引は、同じ`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`だけを確認する場合は、これら4つのフィールド以外のすべてを**信頼できない**ものとして扱い、信頼できる情報源であるAPIからレコードを再取得してください。セッションの場合は[`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/ja/sessions-api/retrieve-session)、取引の場合は[`GET /v3/transactions/{transaction_id}/`](https://docs.didit.me/ja/management-api/transactions/get)を使用します。これは互換性上の制約であり、その場で修正できるものではありません。4フィールドの正規化文字列は通信上の取り決めです。`X-Signature-Simple`を検証するすべての受信側はまさにこの4フィールドを再計算するため、ヘッダーの署名対象を変更すると、署名検証時にすべての受信側で気付かないうちに動作しなくなります。また、ボディを署名対象にするには、双方がバイト単位で再現できるJSONの正規形式を定める必要がありますが、Diditはすでにそれを`X-Signature-V2`として提供しています。独自の正規化ルールを持つ、ボディを対象とする2つ目の方式を追加するのではなく、このヘッダーは現状のまま維持し、ペイロード全体を対象とする既存の方式を推奨して非推奨としています。利用者への影響:- **ワイヤー上の変更はありません。** `X-Signature-Simple`は引き続きすべての配信で、同じ方法で計算されて送信されます。既存の検証処理はそのまま動作し、削除する場合は十分前もってお知らせします。
- **新規の連携では`X-Signature-V2`を使用する必要があります。** ミドルウェア耐性があり、*かつ*ペイロード全体を対象とします。
- **`X-Signature-Simple`から移行できない場合**は、それを検証したうえで、Webhookのボディから読み取るのではなくAPIからレコードを取得してください。### 3つの方式がある理由一部の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/ja/sessions-api/retrieve-session)から再取得してください。各ステップで、対応する`-Previous`ヘッダーが存在する場合は、どちらのヘッダーとの一致も受け入れてください（[署名シークレットのローテーション](#署名シークレットのローテーション)を参照）。いずれの場合も、リプレイ攻撃を防ぐため、**5分**より古いリクエスト（`abs(now - X-Timestamp) > 300`）は**拒否**してください。## 再試行ポリシーエンドポイントが`5xx`または`404`を返した場合、Diditは指数バックオフで最大**2回**再試行します。- **1回目の再試行**: 最初の失敗から約**1分**後。
- **2回目の再試行**: 1回目の再試行から約**4分**後。2回目の再試行の後、その配信は破棄されます。各配信試行（初回と再試行）は、Business Consoleの送信先の**Deliveries**タブにそれぞれ別のエントリとして記録されるため、どの試行もリプレイや確認ができます。送信先の配信ログの各試行には`attempt`番号が付きます。イベントの初回配信は`1`で、再試行または再送のたびに、繰り返す元の試行の番号に1を加えた値になります。
自動再試行は、その後に送信先の`url`を変更した場合でも、初回の試行を送信したURLに送られます。
手動の再送は送信先の現在のURLに送られるため、URLを修正すれば再送も正しく届きます。その他に知っておくべき配信ルール:- 送信HTTPリクエストは**5秒**でタイムアウトします。
- DiditはSSRF対策として、プライベートURLやlocalhostのURLへの配信をブロックし、リダイレクトには一切従いません。直接応答する公開HTTPSエンドポイントを使用してください。
- 生のバックスラッシュ、制御文字、またはオーソリティ部に複数の`@`を含む送信先`url`は、不正な形式として拒否されます。このようなURLは作成できず、既存のものも再配信やリプレイができません。ユーザー情報部分が最大1つのシンプルな`https://host/path`形式のURLを使用してください（Basic認証形式の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/ja/sessions-api/retrieve-session)を呼び出し、現在の`status`に基づいて処理してください。
- 古いイベントで新しい状態を上書きしないでください。イベントの`created_at`を、そのレコードについて最後に保存したものと比較し、古いイベントは無視してください。
- 重複の判定には`event_id`をキーとして使用してください。この値は再試行の間も変わりません。Diditは、トラベルルールの送金のイベントを[トラベルルールの交換ステータスの変更](#トラベルルールの交換ステータス変更-travel_rule-status-updated)で説明されている決まった順序でキューに入れますが、再試行によって届く順序が変わる可能性はあります。## 送信先の設定送信先は**Business Console > API & Webhooks**またはManagement APIから管理できます。**1. 送信先を作成する**[`POST /v3/webhook/destinations/`](https://docs.didit.me/ja/management-api/webhook/create-destination)を呼び出し（またはコンソールで**宛先を追加**をクリックし）、`label`、公開`url`、`webhook_version`（`"v3"`）、およびこのエンドポイントが受信すべきすべてのイベントファミリーを含む`subscribed_events`配列を指定します。イベントは少なくとも1つ必要で、ワイルドカードはありません。**2. secret\_shared\_keyを保存する**作成時のレスポンスで送信先の`secret_shared_key`が返されます。他のサーバーシークレットと一緒に保存してください。これはこの送信先専用のもので、3種類すべてのHMAC-SHA256署名の入力になります。[`GET /v3/webhook/destinations/{destination_uuid}/`](https://docs.didit.me/ja/management-api/webhook/get-destination)は、APIキーを使う呼び出し元と、Webhookの閲覧権限を持つコンソールユーザーにこの値を再度返します。**3. エンドポイントを実装する**公開のHTTPS `POST`ルートを用意し、生のリクエスト本文を読み取り、署名を検証し（`X-Signature-V2`を優先）、タイムスタンプの許容範囲を確認してから、`webhook_type`に応じて処理を振り分けます。処理をキューに入れたらすぐに`2xx`を返し、重い処理は非同期で行ってください。**4. 受信を確認する**送信先の**Deliveries**タブを確認します。正常なWebhookは1〜2秒以内に`200`を返します。`5xx`/`404`はDiditの再試行ポリシーのトリガーとなり、失敗が続く場合は2回目の再試行の後に破棄されます。**5. Try Webhookで繰り返し確認する**本番環境のトラフィックを有効にする前に、コンソールの[**Try Webhook**](#テスト)シナリオを使って、承認済み、却下、レビュー中、KYB、エンティティ、アクティビティ、取引のペイロードでエンドポイントを検証してください。![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を送信してエンドポイントを検証できます。送信先は必要な数だけ作成できます。よくあるパターンは**利用側ごとに1つの送信先**を用意する方法です。例: KYCイベント → 認証サービス、KYBイベント → コンプライアンス業務サービス、取引 → 不正検知キュー。各送信先はそれぞれ独自のシークレット、バージョン、サブスクリプションリストを持つため、ある利用側でシークレットをローテーションしたりデプロイに問題が起きたりしても、他の利用側には影響しません。## コードサンプルWebhookエンドポイントのセキュリティを確保するため、送信先の`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のデフォルト設定では、正規形を再現**できません**。4つの設定を上書きする必要があり、1つでも漏れると署名対象のバイト列が変わり、ダイジェストが一致しなくなります。| PHPのデフォルト | 出力される内容 | 必要な対応 |
| - | - | - |
| `json_decode($raw, true)`(連想配列) | 空オブジェクト`{}`が`[]`に変換される | JSONオブジェクトが`stdClass`のまま保持されるよう、`$associative = false`でデコードする |
| `json_encode`がスラッシュをエスケープする | `https:\/\/media.didit.me` | `JSON_UNESCAPED_SLASHES` |
| `json_encode`が非ASCII文字をエスケープする | `José`ではなく`Jos\u00e9` | `JSON_UNESCAPED_UNICODE` |
| `ksort`が数値に見えるキーを数値として比較する | `"9"`が`"10"`より前に並ぶ | `ksort($props, SORT_STRING)` |最も問題になりやすいのは空オブジェクトの落とし穴です。Diditのペイロードには`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]);
    }
}
```ほかにも知っておくべき点が2つあります。- **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を最初に試すのはそのためです。## テストエンドポイントの動作確認のために、完全な認証を実行する必要は**ありません**。- **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を送信先URLに送信します。
- すべてのシナリオは本番環境のトラフィックと同じ3つの署名ヘッダーと正規エンコーディングで署名されており、Unicodeのテストデータ(`approved_kyc_with_unicode`)も含まれているため、ミドルウェアを経由しても`X-Signature-V2`が機能することを確認できます。テスト配信にはサンプルデータが含まれ、追加の`X-Didit-Test-Webhook: true`ヘッダー(および`User-Agent`の`(Test)`サフィックス)が付与されるため、本番環境の状態から除外できます。
- リグレッションをデバッグする必要がある場合は、送信先の**Deliveries**タブから過去の任意の配信を再送できます。エンドツーエンドのスモークテストには、自社のアプリケーションでテスト用ワークフローを通じて実際の認証を実行してください。その結果送信されるWebhookは、**Try Webhook**のシナリオと同じエンベロープ、ヘッダー、再試行ポリシーを使用します。## 例### 承認済みの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.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`には、[トラベルルール](https://docs.didit.me/ja/transaction-monitoring/travel-rule.md#ライフサイクル)に記載されている13種類の交換ステータスのいずれかが入ります。
`rail`は`INTERNAL`、`TRP`、`GTR`、`TRUST`、`VERIFYVASP`、`SYGNA`、`EMAIL`のいずれか（レールが選択されるまでは空文字列で、レールを特定できなかった場合と、サンライズ登録では常に空文字列です）、または[Didit Test VASP](https://docs.didit.me/ja/transaction-monitoring/travel-rule-sandbox.md)が応答したサンドボックス送金の場合は`TEST_VASP`です。`direction`は`INBOUND`または`OUTBOUND`です。
サンドボックスのイベントには`"environment": "sandbox"`が含まれ、`TEST_VASP`イベントにはさらに`test_vasp: true`と`test_vasp_scenario`（応答したシナリオ）が含まれます。Diditがトラベルルール送金のために取引を作成する場合（[`POST /v3/transactions/`](https://docs.didit.me/ja/management-api/transactions/create)の呼び出し、[入金後のサンライズ登録](https://docs.didit.me/ja/transaction-monitoring/travel-rule.md#入金後のサンライズ登録)、インバウンドの`TRP`照会、または`INTERNAL`レールのインバウンド側）、まずその取引の`transaction.created`イベントを送信し、その後に送金の`travel_rule.status.updated`イベントを送信します。
そのステップ中に取引のステータスを変更するルールがある場合は、`transaction.created`より前に`transaction.status.updated`が送信されることもあります。また、すでに作成済みの入金取引に紐づくサンライズ登録では、`transaction.created`は送信されません。
各Webhookは個別に配信・再試行されるため、再試行されたイベントが後続のイベントより後に届くことがあります。ハンドラーは`transaction_id`をキーにし、現在の状態が必要な場合は取引を再取得してください。
TRUST経由で送信された送金には`COMPLETED`イベントが届きません。TRUSTはDiditが読み取れる応答を何も返さないためです。送金は確認期限まで`AWAITING_COUNTERPARTY`のままとなり、その後`EXPIRED`で終了します。GTR、VerifyVASP、Sygna Bridgeでは、Diditがネットワークから取引相手の応答を読み取り、イベントはそれに従います（[トラベルルールのネットワーク](https://docs.didit.me/ja/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は、変更があったワークフローごとに1件のイベントを送信します。### 作成された取引ルールの提案 (`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/ja/transaction-monitoring/webhooks.md)を参照してください。### Investigatorイベント (`investigator.*`)12種類の`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エンジンのバージョンで、8文字の不透明なIDです。エンジンが変更されるたびに変わるため、`engine`が同じ2つのレポートは同じエンジンによって作成されたものです。
- `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が送信する**正確なバイト列**(`json.dumps(..., sort_keys=True, separators=(",", ":"))`)に対するHMAC-SHA256です。ミドルウェアがボディを変更する前に生のボディを読み取れる場合にのみ使用してください。> [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_version: "v2"`に固定されている場合にのみ適用されます。さらに古い`"v1"`への固定も存在します(3つ目の、単数形の判定形式)。これはV2より前に作成された送信先向けのものです。利用する前にサポートへお問い合わせください。新規の連携では、上記で説明している、複数形の配列を使う**V3**(デフォルト)を使用してください。| 項目 | V2 | V3 |
| - | - | - |
| 署名ヘッダー | `X-Signature`、`X-Signature-V2`、`X-Signature-Simple`(バージョンに関係なく3つすべてが送信されます。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の送信先は、V3と同じくヒットごとの`match_score`、`risk_score`、`score_breakdown`、`risk_view`、`review_status`と、集計値の`score`をそのまま受け取ります([AMLレポート](https://docs.didit.me/ja/core-technology/aml-screening/report-aml-screening.md#1つの判定ではなく3つのスコア)を参照)。V2に固定すると、`decision`内でAMLレポートが置かれる場所が変わりますが、その内容は変わりません。下流のコンシューマーがまだV2形式を必要とする場合は、`webhook_version: "v2"`に固定した別の送信先を作成し、新しいコンシューマーはV3のままにしてください。## 関連ページ- [Webhook送信先API](https://docs.didit.me/ja/management-api/webhook/list-destinations.md): 送信先の一覧取得、作成、更新、削除と、配信ログの確認。
- [Webhook送信先の作成](https://docs.didit.me/ja/management-api/webhook/create-destination.md): 送信先の`secret_shared_key`を含むリクエスト/レスポンスのスキーマ。
- [データモデル](https://docs.didit.me/ja/reference/data-models.md): `decision`に埋め込まれる機能ごとのすべてのオブジェクトの正式なスキーマ。
- [判定スキーマ](https://docs.didit.me/ja/sessions-api/retrieve-session.md): セッションのWebhookに埋め込まれる`decision`オブジェクトの全フィールド一覧。
- [認証ステータス](https://docs.didit.me/ja/integration/verification-statuses.md): 上記で参照されている正確な`status`の値と遷移。
- [レート制限](https://docs.didit.me/ja/integration/rate-limiting.md): フォールバックとして`GET /v3/session/{id}/decision/`をポーリングする際に適用される制限。


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