> ## 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.

# 웹훅: 실시간 KYC·KYB 이벤트 및 HMAC 서명

> Didit에서 인증, 기업, 거래 이벤트를 실시간으로 수신하세요. HMAC-SHA256 서명, 다중 webhook 대상 전송, 제한된 재시도를 지원합니다.

> [!NOTE]
>
> 인증 활동과 릴리스에 따라 달라지는 웹훅 전송은 [인증 이벤트 및 웹훅](https://docs.didit.me/ko/authentication/events-and-webhooks.md)에 문서화되어 있습니다. 현재 웹훅 대상 API에는 목록에 없는 인증 이벤트 유형을 등록하지 마십시오.

웹훅 통합 프롬프트
        프롬프트를 클립보드에 복사



      
      

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



      
      

붙여넣을 곳

## 개요웹훅은 검증 세션, 기업 세션, 벤더 사용자, 벤더 기업 또는 거래의 상태가 변경될 때마다 Didit이 **실시간 업데이트**를 백엔드로 전송하는 방식입니다. 관심 있는 이벤트에 하나 이상의 웹훅 대상을 구독하면 결과가 준비되는 즉시 반응할 수 있으며, 보통 인증 중인 사용자가 절차를 마친 후 몇 초 이내입니다.웹훅은 **권장되는** 통합 방식입니다. [`GET /v3/session/{id}/decision/`](https://docs.didit.me/ko/sessions-api/retrieve-session)을 통한 폴링도 대체 수단으로 지원되지만 속도가 느리고 요청을 더 많이 사용하며, `data.updated`, `transaction.status.updated` 및 웹훅으로만 전송되는 엔티티 수준의 변경 같은 이벤트를 건너뜁니다. 웹훅 전송을 정보의 기준으로 삼고, 콜드 스타트 시 또는 데이터 대조를 위해서만 폴링하십시오.이 장은 Didit Academy 통합 강의의 웹훅 개요부터 이어집니다.Academy 통합 강의는 3:12부터 웹훅을 다룹니다. 웹훅 대상 추가, 이벤트 선택, 그리고 아래에서 검증에 사용할 서명용 비밀을 설명합니다.[웹훅 개요 - 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`를 사용해 웹훅을 전송합니다. 엣지에서 알 수 없는 클라이언트를 차단하는 경우, **Security > WAF > Tools > IP Access Rules**에서 이 IP를 허용하고 수신 호스트 이름에 대해 **허용**을 선택하십시오.## 이벤트 유형대상의 `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 또는 콘솔 작업 이후 거래의 상태가 변경될 때. 백그라운드에서 재시도된 암호화폐 스크리닝이 완료되면 상태 변경 여부와 관계없이 한 번 전송됩니다([아직 인덱싱되지 않은 트랜잭션 해시](https://docs.didit.me/ko/transaction-monitoring/aml-screening.md#아직-인덱싱되지-않은-거래-해시)). |
| `travel_rule.status.updated` | Travel Rule | [Travel Rule](https://docs.didit.me/ko/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` | 거래 모니터링 | 규칙 제안 실행으로 새로운 규칙 변경이 제안되고 규칙 제안 설정에서 웹훅 알림이 켜져 있을 때. `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 | 대기열에서 드리프트가 발생하여 자동화가 한 단계 낮은 등급으로 내려갔을 때. |
| `investigator.sla.breached` | Investigator | 보고서가 결정 없이 대기열의 SLA를 넘겼을 때. |
| `investigator.qa.failed` | Investigator | 사람이 내린 결정이 Investigator의 품질 검토에서 실패했을 때. |
| `investigator.external.report.ready` | Investigator | Didit 외부에서 전송한 항목의 보고서가 준비되었을 때. |
| `auth.signup` | [인증](https://docs.didit.me/ko/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/ko/standalone-apis/kyb-registry)를 호출할 때 전달하는 요청별 `webhook_url`로 전송되고, webhook 대상으로는 전송되지 않습니다.> [!TIP]
>
> Business Console > API & Webhooks > **Try Webhook** 메뉴는 이러한 이벤트 각각에 대해 완성된 페이로드를 전송합니다(승인됨, 거부됨, 검토 중, 유니코드 이름, KYC/KYB, 엔티티, 활동, 거래 포함). 실제 서비스를 시작하기 전에 이 기능으로 엔드포인트를 검증하세요.## payload 구조### 엔벨로프모든 webhook은 동일한 엔벨로프를 공유합니다. 필드는 항상 `sort_keys=True`와 compact separators(`,` / `:`)로 직렬화되므로, 같은 payload는 양쪽에서 같은 서명을 재현합니다.| 필드 | 타입 | 설명 |
| - | - | - |
| `event_id` | `uuid` | 이벤트의 고정 식별자입니다. 이벤트가 여러 webhook 대상으로 전달될 때 **동일한** `event_id`가 모든 대상에 전송되며, 재시도 시에도 재사용됩니다. 멱등성은 이 값을 기준으로 처리하세요. |
| `webhook_type` | string | [이벤트 유형](#이벤트-유형) 중 하나입니다. |
| `timestamp` | int (Unix 초) | Didit이 webhook을 전송한 시점입니다. 서명 검증에 사용됩니다. 재시도할 때마다 갱신됩니다. |
| `created_at` | int (Unix 초) | 기반 레코드가 마지막으로 업데이트된 시점입니다. |
| `application_id` | `uuid` | webhook 대상을 소유한 애플리케이션입니다. |
| `environment` | `"live"` \| `"sandbox"` | 이벤트가 라이브 세션/애플리케이션에서 발생했는지 샌드박스 세션/애플리케이션에서 발생했는지를 나타냅니다. |
| `status` | string | 모든 세션, 엔티티, 거래 이벤트(`*.data.updated` 포함)에 있습니다. `investigator.*` 이벤트에는 `status`가 없습니다(아래 Investigator payload 참조). 세션 이벤트는 정확한 [검증 상태 레이블](https://docs.didit.me/ko/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` | 세션을 생성할 때 사용한 워크플로의 ID입니다(설정된 경우). |
| `workflow_version` | int | 워크플로 버전입니다. |
| `vendor_data` | string | 세션 생성 시 제공한 내부 식별자입니다(제공된 경우). |
| `vendor_business_id` | string | KYB 세션에만 있으며, 제공된 경우에만 포함됩니다. |
| `sandbox_scenario` | string \| null | 모든 세션 이벤트(KYC 및 KYB)에 있습니다. 시나리오가 설정된 샌드박스 세션에서는 설정된 [샌드박스](https://docs.didit.me/ko/integration/sandbox-testing.md) 시나리오 slug(예: `"decline_aml_hit"`)이며, 라이브 세션이거나 시나리오가 설정되지 않은 샌드박스 세션에서는 `null`입니다. 엔티티 또는 거래 webhook에는 없습니다. |
| `metadata` | object | 세션 생성 시 첨부한 메타데이터입니다. 내부 중복 제거 키는 제거되어 있습니다. |
| `trigger` | string | 선택 항목입니다. 검증 흐름 자체가 webhook을 생성하지 않은 경우, 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/ko/sessions-api/retrieve-session.md)과 같은 스키마를 따릅니다. |
| `resubmit_info` | `{ nodes_to_resubmit, reasons }` | `status`가 `Resubmitted`일 때 존재합니다(이 경우 `decision`은 없습니다). |
| `decided_by` | string | `status.updated`에만 있으며, status가 `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/ko/sessions-api/retrieve-session)이 반환하는 것과 같은 스키마입니다. 기능별 속성의 전체 참조(`id_verifications[]`, `aml_screenings[]` 등의 모든 속성)는 [데이터 모델](https://docs.didit.me/ko/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/ko/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/ko/core-technology/bank-verification/fallbacks.md#결과에서-확인하기)를 참조하세요. 계좌 확인 방식으로 검증된 계좌는 금융 데이터를 읽지 않으므로, `financial_data.not_collected`에는 해당 단계가 요청했지만 수집하지 않은 잔액, 거래 내역, 소득 정보가 나열됩니다. [은행 계좌 확인 방식](https://docs.didit.me/ko/core-technology/bank-verification/verification-methods.md#결과에서-인증-방법-확인)을 참조하세요.### Investigator 블록사용자 또는 기업 세션의 V3 `decision`에는 `investigator`도 포함됩니다. 이 값은 세션의 최신 [Investigator](https://docs.didit.me/ko/console/investigator.md) 검토 결과이며, 검토가 한 번도 없었다면 `null`입니다.| 필드 | 타입 | 의미 |
| - | - | - |
| `run_id` | uuid | 검토 실행 ID입니다. 전체 보고서는 `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/ko/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/ko/getting-started/biometric-consent.md)를 참조하세요.### 신분증 인증 방법 키모든 `id_verifications[]` 항목에는 해당 항목을 생성한 [신분증 인증 방법](https://docs.didit.me/ko/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/ko/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` 배열과 `{ field: { previous, current } }` 형태의 `changes` 맵을 추가로 포함합니다. `business.*` 이벤트는 `vendor_user_id` 대신 `vendor_business_id`를 사용합니다.### 거래 이벤트 payload거래 webhook은 다른 webhook과 같은 팬아웃 인프라를 사용하고 동일한 envelope 형식을 사용합니다. 다만 세션 대신 거래를 식별합니다.```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/ko/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 교환은 `environment: sandbox`와 함께 `travel_rule.status.updated`를 전송합니다.### KYB 레지스트리 검색 콜백비동기 [`POST /v3/kyb/search/`](https://docs.didit.me/ko/standalone-apis/kyb-registry) 흐름은 **webhook 대상 이벤트가 아닌** 추가 payload를 하나 전달합니다. 검색 요청에 `webhook_url`을 전달하면, 레지스트리 조회가 끝난 뒤 Didit이 해당 URL로 `kyb.registry_search.resolved` 콜백을 POST합니다. 이 콜백은 webhook 대상과 세 가지 점에서 다릅니다.- **서명되지 않음**입니다. `X-Signature*` 헤더가 없습니다. 대신 Didit은 `X-Didit-Unsigned-Callback: true`를 사용해 서명되지 않은 콜백임을 표시합니다. 검증하려면 `request_id`를 검색 요청이 반환한 ID와 비교하세요.
- 이벤트 이름은 `webhook_type`이 아니라 `event_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 레지스트리 API](https://docs.didit.me/ko/standalone-apis/kyb-registry.md)를 참조하세요.## 서명 검증모든 웹훅은 대상의 `secret_shared_key`를 사용하는 HMAC-SHA256으로 서명됩니다. 사용 중인 스택에서 잘 동작하는 헤더를 고를 수 있도록 세 가지 서명 헤더를 보냅니다.| 헤더 | 서명 대상 | 미들웨어 안전 여부 | 사용 시기 |
| - | - | - | - |
| `X-Signature-V2` | 키를 정렬하고 유니코드를 보존한 압축 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 웹훅 워커를 식별합니다. |
| `X-Timestamp` | `1774970000` | 웹훅이 전송된 시점의 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시간 동안 해당 대상으로 가는 모든 전송에는 교체 전 비밀로 생성된 헤더 세 개가 추가로 포함됩니다.| 헤더 | 동일한 서명 | 서명에 사용된 비밀 |
| - | - | - |
| `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/ko/management-api/webhook/get-destination)의 `previous_secret_expires_at`에서 24시간이 언제 끝나는지 확인할 수 있습니다.재시도와 재전송은 전송될 때 대상의 현재 비밀로 다시 서명되며, 24시간이 유지되는 동안에만 `-Previous` 헤더를 포함합니다.
24시간 안에 다시 교체하면 가장 오래된 비밀은 즉시 폐기됩니다. `-Previous` 헤더를 통해 검증되는 것은 새 교체 직전까지 현재였던 비밀뿐이며, 이 역시 새 교체 시점부터 24시간 동안만 유지됩니다.`-Previous` 헤더는 웹훅 대상에만 존재합니다.
기존 `webhook_url`에 서명하는 애플리케이션 수준의 `secret_shared_key`에는 중복 기간이 없습니다. [`PATCH /v3/webhook/`](https://docs.didit.me/ko/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/ko/sessions-api/retrieve-session), 거래는 [`GET /v3/transactions/{transaction_id}/`](https://docs.didit.me/ko/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에서 레코드를 가져오십시오.### 세 가지 변형이 있는 이유일부 웹 프레임워크(Express 본문 파서, Django 미들웨어, API 게이트웨이)는 핸들러가 읽기 전에 JSON을 조용히 다시 인코딩합니다. 원본 본문에 `"José"`가 있었는데 미들웨어가 이를 `"José"`로 다시 쓰면, 데이터는 동일하더라도 바이트가 바뀝니다. 그러면 `X-Signature`는 검증에 실패합니다. `X-Signature-V2`는 거의 모든 미들웨어가 결정적으로 재현하는 정규 JSON 형식(키 정렬, 압축 구분자, 이스케이프되지 않은 유니코드)으로 계산됩니다. 지원 중단된 `X-Signature-Simple`은 그마저 실패할 때 파서와 무관한 짧은 문자열에만 서명하는 방식으로 폴백합니다. 이 헤더는 그 짧은 문자열에만 서명하고 본문은 전혀 서명하지 않으므로, 본문을 보증할 수 없습니다.### 권장 검증 순서1) **`X-Signature-V2`**를 먼저 시도합니다. `JSON.parse(body)`를 키 정렬과 유니코드 보존 방식으로 다시 인코딩한 다음, `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/ko/sessions-api/retrieve-session)에서 다시 가져오십시오.각 단계에서 대응하는 `-Previous` 헤더가 있으면, 두 헤더 중 어느 쪽과 일치하더라도 승인합니다([서명 비밀 교체](#서명-비밀-교체) 참조).모든 경우에 리플레이 공격을 막기 위해 **5분**보다 오래된 요청(`abs(now - X-Timestamp) > 300`)은 **거부**하십시오.## 재시도 정책endpoint가 `5xx` 또는 `404`로 응답하면, Didit은 지수 백오프를 적용하여 최대 **2회** 재시도합니다.- **첫 번째 재시도**: 최초 실패 후 약 **1분** 뒤에 수행됩니다.
- **두 번째 재시도**: 첫 번째 재시도 후 약 **4분** 뒤에 수행됩니다.두 번째 재시도 후에도 실패하면 해당 전송은 버려집니다. 각 전송 시도(최초 시도와 재시도)는 대상의 **전송** 탭에 별도 항목으로 Business Console에 기록되므로, 어떤 시도든 다시 보내거나 확인할 수 있습니다.대상의 전송 로그에 있는 각 시도에는 `attempt` 번호가 포함됩니다. 이벤트의 첫 번째 전송은 `1`이며, 재시도나 다시 보내기는 반복하는 시도 번호에 1을 더한 값을 가집니다.
자동 재시도는 대상의 `url`을 그 후에 변경했더라도 최초 시도가 전송된 URL로 전송됩니다.
수동 다시 보내기는 대상의 현재 URL로 전송되므로, URL을 수정하면 다시 보내기도 함께 해결됩니다.알아 두어야 할 기타 전송 규칙은 다음과 같습니다.- 아웃바운드 HTTP 요청은 **5초** 후에 시간 초과됩니다.
- Didit은 SSRF 방어를 위해 비공개 또는 localhost URL로의 전송을 차단하며, 리디렉션을 따르지 않습니다. 직접 응답하는 공개 HTTPS endpoint를 사용하십시오.
- 원시 백슬래시, 제어 문자, 또는 URL의 권한 부분(authority)에 `@`가 둘 이상 포함된 대상 `url`은 잘못된 형식으로 거부됩니다. 이러한 URL은 생성할 수 없으며, 이미 존재하는 URL도 다시 전송하거나 재생할 수 없습니다. 사용자 정보 구간이 최대 하나만 포함된 일반 `https://host/path` URL을 사용하십시오(기본 인증 방식의 webhook URL에 해당합니다).
- `2xx`는 성공으로 처리됩니다. 시간 초과와 연결 실패는 재시도 대상이며(전송 로그에는 `504`/`503`으로 표시됩니다), `404`를 제외한 `3xx`와 `4xx` 응답은 **재시도되지 않습니다**.
- 재시도할 때 Didit은 새 `X-Timestamp`로 서명을 다시 계산하므로, 타임스탬프와 서명 쌍은 항상 일치합니다.
- 재시도와 다시 보내기는 전송되는 시점의 대상 현재 서명 비밀로 서명됩니다.### 전송 순서Didit은 webhook이 도착하는 순서를 보장하지 않습니다. 각 전송은 개별적으로 대기열에 들어가며 재시도는 최초 시도 몇 분 뒤에 도착하므로, `In Progress`에 대한 더 이전 이벤트보다 `Approved`에 대한 `status.updated`가 먼저 도착할 수 있습니다.이 점을 고려하여 수신기를 구현하십시오.- webhook은 무언가 변경되었다는 신호로 취급하십시오. 순서가 중요한 경우에는 [`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/ko/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/ko/management-api/webhook/create-destination)를 호출하거나(콘솔에서 **대상 추가**를 클릭해도 됩니다), `label`, 공개 `url`, `webhook_version`(`"v3"`), 그리고 이 endpoint가 수신해야 하는 모든 이벤트 유형을 담은 `subscribed_events` 배열을 지정하십시오. 최소 하나의 이벤트가 필요하며 와일드카드는 지원되지 않습니다.**2. secret\_shared\_key 저장**생성 응답에는 대상의 `secret_shared_key`가 반환됩니다. 이 값을 다른 서버 비밀 값과 함께 저장하십시오. 이 값은 이 대상에만 속하며, 세 가지 HMAC-SHA256 서명 방식 모두의 입력값입니다. [`GET /v3/webhook/destinations/{destination_uuid}/`](https://docs.didit.me/ko/management-api/webhook/get-destination)는 API 키 호출자와 webhook을 읽을 수 있는 콘솔 사용자에게 이 값을 다시 반환합니다.**3. endpoint 구현**공개 HTTPS `POST` 경로를 노출하고, 원시 본문을 읽고, 서명을 검증하고(`X-Signature-V2`를 우선으로 사용합니다), 타임스탬프 범위를 확인한 다음 `webhook_type`에 따라 분기 처리하십시오. 작업을 대기열에 넣자마자 `2xx`를 반환하고, 무거운 처리는 비동기로 수행하십시오.**4. 수신 확인**대상의 **전송** 탭을 확인하십시오. 정상적인 webhook은 1\~2초 안에 `200`을 반환합니다. `5xx`/`404`는 Didit의 재시도 정책을 실행하며, 지속적으로 실패하면 두 번째 재시도 후에 버려집니다.**5. Try Webhook으로 반복 테스트**[**Try Webhook**](#테스트) 콘솔 시나리오를 사용하여 승인됨, 거부됨, 검토 중, KYB, 엔터티, 활동, 거래 페이로드로 endpoint를 테스트한 다음 운영 트래픽을 켜십시오.![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 이벤트는 컴플라이언스 운영 서비스로, 거래 이벤트는 사기 탐지 대기열로 보낼 수 있습니다. 각 대상은 고유한 비밀, 버전, 구독 목록을 가지므로, 한 소비자의 비밀을 교체하거나 잘못 배포하더라도 다른 소비자에게는 영향을 주지 않습니다.## 코드 샘플웹훅 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é` 대신 `Jos\u00e9` | `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를 먼저 시도합니다.## 테스트엔드포인트를 점검하기 위해 전체 검증을 실행 **할 필요는 없습니다**.- **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`). 그런 다음 완전한 형태의 웹훅을 대상 URL로 전송합니다.
- 모든 시나리오는 프로덕션 트래픽과 동일한 세 가지 서명 헤더와 정규 인코딩으로 서명되며, 유니코드 테스트 데이터(`approved_kyc_with_unicode`)도 포함됩니다. 따라서 미들웨어를 거쳐도 `X-Signature-V2`가 동작하는지 확인할 수 있습니다. 테스트 전송에는 샘플 데이터가 포함되며, 프로덕션 상태에 반영되지 않도록 추가 헤더 `X-Didit-Test-Webhook: true`가 붙고 `User-Agent`에 `(Test)` 접미사가 붙습니다.
- 회귀 문제를 디버깅해야 한다면, 대상의 **전송** 탭에서 과거 전송 건을 재생할 수 있습니다.엔드투엔드 스모크 테스트를 하려면 자체 애플리케이션의 테스트 워크플로를 통해 실제 인증을 실행하십시오. 이때 발생하는 웹훅은 **Try Webhook** 시나리오와 동일한 envelope, 헤더, 재시도 정책을 사용합니다.## 예시### 승인된 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/ko/transaction-monitoring/travel-rule.md#수명-주기)에 문서화된 13개 교환 상태 중 하나가 담깁니다.
`rail`은 `INTERNAL`, `TRP`, `GTR`, `TRUST`, `VERIFYVASP`, `SYGNA`, `EMAIL` 중 하나입니다. 레일이 선택되기 전이거나 레일을 확인할 수 없는 경우, 그리고 sunrise 등록에서는 항상 빈 문자열입니다. [Didit Test VASP](https://docs.didit.me/ko/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/ko/management-api/transactions/create) 호출, [입금 후 sunrise 등록](https://docs.didit.me/ko/transaction-monitoring/travel-rule.md#입금-후-소급-등록), 인바운드 `TRP` 문의, 또는 `INTERNAL` 레일의 인바운드 구간), 해당 거래에 대한 `transaction.created` 이벤트를 먼저 보내고, 그 뒤에 전송의 `travel_rule.status.updated` 이벤트를 보냅니다.
해당 단계에서 규칙이 거래 상태를 바꾸면 `transaction.created` 앞에 `transaction.status.updated`가 올 수도 있습니다. 이미 생성한 입금 거래에 sunrise 등록이 연결되는 경우에는 `transaction.created`가 전송되지 않습니다.
각 webhook은 개별적으로 전송되고 재시도되므로, 재시도된 이벤트가 이후 이벤트보다 늦게 도착할 수 있습니다. `transaction_id`를 기준으로 핸들러를 구현하고, 현재 상태가 필요할 때는 거래를 다시 조회하십시오.
TRUST로 보낸 전송은 `COMPLETED` 이벤트를 받지 않습니다. TRUST는 Didit이 읽을 수 있는 응답을 보내지 않으므로, 전송은 확인 기한까지 `AWAITING_COUNTERPARTY` 상태를 유지하다가 `EXPIRED`로 끝납니다. GTR, VerifyVASP, Sygna Bridge에서는 Didit이 네트워크에서 상대방의 응답을 읽고, 이벤트는 그 응답 뒤에 전송됩니다([Travel Rule 네트워크](https://docs.didit.me/ko/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`(정보 제공용)입니다.
변경된 워크플로마다 이벤트 하나가 전송됩니다.### 거래 규칙 제안 생성됨 (`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/ko/transaction-monitoring/webhooks.md)을 참조하십시오.### Investigator 이벤트 (`investigator.*`)`investigator.*` 이벤트 12개는 다른 모든 이벤트와 같은 봉투(`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` | `decided_by`가 `"human"`인 것을 제외하면 `investigator.decision.automated`와 같습니다. 추가로 `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` 값이 같은 두 보고서는 같은 엔진이 작성한 것입니다.
- `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_version: "v2"`로 고정된 경우에만 적용됩니다. V2 이전에 생성된 대상을 위한 더 오래된 `"v1"` 고정 설정도 있습니다(단수형 결정 구조라는 세 번째 형식). 이에 의존하기 전에 지원팀에 문의하십시오. 새 연동에는 위에서 설명한 **V3**(기본값)을 사용하십시오. 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 대상은 V3와 똑같이 히트별 `match_score`, `risk_score`, `score_breakdown`, `risk_view`, `review_status`와 집계 `score`를 받습니다([AML 보고서](https://docs.didit.me/ko/core-technology/aml-screening/report-aml-screening.md#하나의-판정이-아닌-세-가지-점수) 참조). V2로 고정하면 AML 보고서가 `decision`에서 놓이는 위치만 바뀌며, 포함되는 내용은 바뀌지 않습니다.하위 소비자에게 여전히 V2 형식이 필요하다면 `webhook_version: "v2"`로 고정한 별도의 대상을 만들고, 새 소비자는 V3를 사용하십시오.## 관련 문서- [webhook 대상 API](https://docs.didit.me/ko/management-api/webhook/list-destinations.md) — 대상 목록 조회, 생성, 수정, 삭제 및 전송 로그 확인.
- [webhook 대상 생성](https://docs.didit.me/ko/management-api/webhook/create-destination.md) — 대상의 `secret_shared_key`를 포함한 요청 및 응답 스키마.
- [데이터 모델](https://docs.didit.me/ko/reference/data-models.md) — `decision`에 포함된 모든 기능별 객체의 표준 스키마.
- [결정 스키마](https://docs.didit.me/ko/sessions-api/retrieve-session.md) — 세션 webhook에 포함된 `decision` 객체의 전체 필드 목록.
- [인증 상태](https://docs.didit.me/ko/integration/verification-statuses.md) — 위에서 참조한 정확한 `status` 값과 상태 전이.
- [속도 제한](https://docs.didit.me/ko/integration/rate-limiting.md) — `GET /v3/session/{id}/decision/`를 대체 수단으로 폴링할 때 적용되는 제한.


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