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

# 거래 모니터링 웹훅: 거래 이벤트 및 서명

> Didit의 거래 모니터링 웹훅 이벤트: 트리거, 페이로드 구조, HMAC 서명 검증. 월 1,000건 거래 포함, 이후 건당 $0.001.

Transaction Monitoring은 거래 수명 주기 전체를 다루는 webhook 이벤트 3개와, 새 규칙 제안을 알리는 선택 이벤트 1개를 발생시킵니다. 모든 이벤트는 HMAC-SHA256 서명을 사용하는 표준 Didit 웹훅 인프라를 통해 전송됩니다. 대상, 서명, 테스트 방법은 [webhook 참조](https://docs.didit.me/ko/integration/webhooks.md)에서 확인하세요.

## 이벤트 목록

| 이벤트 | 발생 시점 |
| - | - |
| `transaction.created` | 새 거래가 생성되고 초기 평가가 완료되었을 때 발생합니다. Didit이 대신 생성하는 인바운드 거래에도 발생합니다. 여기에는 입금 후 [Travel Rule sunrise 등록](https://docs.didit.me/ko/transaction-monitoring/travel-rule.md#입금-후-소급-등록), 인바운드 `TRP` 조회, 다른 Didit 고객이 보낸 `INTERNAL` 레일 이체가 포함됩니다. |
| `transaction.status.updated` | 거래 상태가 변경되었을 때 발생합니다. 분석가 검토, 시정 조치 세션 해결, 또는 Travel Rule 상태 전환 후 Didit이 규칙을 다시 평가할 때의 규칙 작업(예: 지갑 소유권 증명 이후)이 원인이 될 수 있습니다. 이체가 만료될 때 `timeout_outcome`을 적용하는 상태 변경은 이 이벤트를 보내지 않습니다. 대신 `EXPIRED`인 `travel_rule.status.updated`에 반응하세요. |
| `travel_rule.status.updated` | 발신 이체와 수신 이체 모두에서 [Travel Rule](https://docs.didit.me/ko/transaction-monitoring/travel-rule.md) 교환의 상태가 전환될 때 발생합니다. 수신 이체에는 `INTERNAL` 레일 수신 구간, sunrise 등록, 인바운드 `TRP` 조회가 포함되며, 임계값 미만 전환인 `NOT_APPLICABLE`도 포함됩니다. Didit이 생성한 거래의 경우 이 이벤트는 해당 거래의 `transaction.created` 뒤에 전송됩니다. |
| `transaction_rule_suggestions.created` | 규칙 제안 실행으로 애플리케이션의 규칙 변경 사항이 새로 제안되었을 때 발생합니다. 규칙 제안 알림 설정에서 **Webhook**을 켜고, 제안이 생성된 실행에 대해서만 발생합니다. |

엔티티 수준 이벤트(`user.status.updated`, `business.status.updated`)도 거래 활동의 결과로 발생할 수 있습니다. 자세한 내용은 [엔티티 webhook](https://docs.didit.me/ko/entities/webhooks.md)을 참조하세요.

> [!NOTE]
>
> 샌드박스 애플리케이션은 `transaction.created` 또는 `transaction.status.updated` webhook을 보내지 않습니다. 이 이벤트를 테스트하려면 라이브 애플리케이션을 사용하거나 콘솔의 **API & Webhooks → Try Webhook**을 사용하세요. Travel Rule 샌드박스 교환은 `environment: sandbox`가 포함된 `travel_rule.status.updated` webhook을 보냅니다. 실서비스 전에 교환을 테스트하려면 [Didit Test VASP](https://docs.didit.me/ko/transaction-monitoring/travel-rule-sandbox.md)를 사용하세요.

## 엔벌로프

Didit webhook의 본문은 **평면** 구조입니다. 이벤트 필드가 엔벌로프 필드와 나란히 최상위 수준에 놓이며, 중첩된 `data` 객체는 없습니다.
모든 전송에는 다음 필드가 포함됩니다.

| 필드 | 설명 |
| - | - |
| `webhook_type` | 이벤트 이름입니다(예: `transaction.created`). |
| `event_id` | 이벤트의 고정 식별자입니다. 재시도 시에도 같은 `event_id`가 재사용되므로 이를 기준으로 중복을 제거하세요. |
| `timestamp` / `created_at` | 전송이 발송된 시점의 Unix epoch 초입니다. |
| `application_id` | 이벤트가 속한 애플리케이션입니다. |
| `environment` | `live` 또는 `sandbox`입니다. |

## payload 형식

### `transaction.created` 및 `transaction.status.updated`

두 이벤트는 하나의 payload를 공유합니다. 간략한 요약과 [`action_required`](https://docs.didit.me/ko/transaction-monitoring/sdk-transaction-submission#필수-사용자-작업) 블록(또는 `null`)으로 구성됩니다.

```json theme={null}
{
  "webhook_type": "transaction.created",
  "event_id": "9c0c8b8a-1111-4222-9333-666666666666",
  "timestamp": 1774970000,
  "created_at": 1774970000,
  "application_id": "11111111-2222-3333-4444-555555555555",
  "environment": "live",
  "transaction_id": "9c7d2a30-...",
  "txn_id": "tx-0001",
  "status": "IN_REVIEW",
  "score": 65,
  "severity": "MEDIUM",
  "amount": "500.00",
  "currency": "EUR",
  "direction": "OUTBOUND",
  "action_required": null
}
```

payload는 의도적으로 최소한으로 구성되어 있습니다. 수신 후에는 [`GET /v3/transactions/{transaction_id}/`](https://docs.didit.me/ko/management-api/transactions/get)로 전체 레코드를 가져오세요. 이 응답에는 전체 상세 정보(당사자, `rule_runs`, `provider_results`, `travel_rule`, `cost_breakdown`, `decision_reason_code`)가 포함되며, 상태가 *왜* 변경되었는지도 확인할 수 있습니다.

### `transaction_rule_suggestions.created`

이 이벤트는 단일 거래가 아니라 규칙 집합에 관한 것이므로 거래 ID를 포함하지 않습니다.
개인 데이터는 전혀 포함되지 않으며, 제안 ID, 종류, 제목, 개수만 담겨 있습니다.

```json theme={null}
{
  "webhook_type": "transaction_rule_suggestions.created",
  "run_id": "5b2f0c1e-0000-0000-0000-000000000000",
  "open_count": 3,
  "suggestions": [
    {
      "suggestion_id": "9a7d3e22-0000-0000-0000-000000000000",
      "kind": "...",
      "title": "...",
      "rule_id": "...",
      "expected_impact": {}
    }
  ]
}
```

| 필드 | 설명 |
| - | - |
| `run_id` | 새 제안을 생성한 제안 실행의 ID입니다. |
| `open_count` | 이번 실행 후 애플리케이션에 열려 있는 제안의 수입니다. |
| `suggestions[]` | 이번 실행으로 생성된 제안 목록입니다. 각 항목에는 `suggestion_id`, `kind`, `title`, `rule_id`(새 규칙을 제안하는 경우 null), `expected_impact`가 포함됩니다. |

본문에는 일반적인 엔벌로프 필드(`event_id`, `timestamp`, `application_id`, `environment`)가 추가됩니다.

### `travel_rule.status.updated`

```json theme={null}
{
  "webhook_type": "travel_rule.status.updated",
  "event_id": "9c0c8b8a-1111-4222-9333-777777777777",
  "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"
}
```

모든 전환에서 발생하며, `FINISHED`, `CANCELLED`, `EXPIRED`로의 전환도 포함됩니다. 이체가 이 상태 중 하나에 도달하면 해당 이체에 대해서는 더 이상 Travel Rule 이벤트가 전송되지 않습니다.
이벤트는 이전 이벤트와 같은 상태를 반복할 수 있습니다. 예를 들어 재전송 시에도 레일을 찾지 못해 이체가 `COUNTERPARTY_VASP_NOT_FOUND` 상태에 머무는 경우가 여기에 해당합니다.
`rail`은 `INTERNAL`, `TRP`, `GTR`, `TRUST`, `VERIFYVASP`, `SYGNA`, `EMAIL`, `TEST_VASP`(Didit Test VASP가 응답한 샌드박스 이체이며, `test_vasp: true`와 `test_vasp_scenario`도 함께 포함됨), 또는 레일이 선택되지 않은 동안의 `""`입니다. sunrise 등록에서는 항상 `""`입니다.

거래 생성, sunrise 등록, 인바운드 `TRP` 조회, `INTERNAL` 레일 수신 구간에서 Didit은 `transaction.created`를 먼저 보내고, 그 뒤에 해당 거래의 `travel_rule.status.updated` 이벤트를 보냅니다.
이 단계에서 거래 상태를 변경하는 규칙은 `transaction.created`보다 앞서 `transaction.status.updated`를 보낼 수도 있습니다.
이미 생성한 입금 거래에 연결되는 sunrise 등록은 `transaction.created`를 보내지 않습니다.
각 webhook은 독립적으로 전송되고 재시도되므로, 재시도된 이벤트가 이후 이벤트보다 늦게 도착할 수 있습니다. 핸들러는 `transaction_id`를 기준으로 처리하고, 현재 상태가 필요하면 거래를 다시 조회하세요.

## 상태 변화 예시

### 단순 승인

```
transaction.created (APPROVED, score 0)
```

### 검토 후 승인

```
transaction.created (IN_REVIEW, score 65)
...analyst reviews...
transaction.status.updated (status: APPROVED)
```

### 추가 인증 단계

```
transaction.created (AWAITING_USER, action_required.type: verification_session)
...user completes the biometric re-check...
transaction.status.updated (status: APPROVED, action_required: null)
```

### Travel Rule 이체

```
transaction.created (APPROVED)
travel_rule.status.updated (AWAITING_COUNTERPARTY, rail: EMAIL)
...counterparty accepts the pickup...
travel_rule.status.updated (COMPLETED)
...you broadcast on-chain and PATCH the hash...
travel_rule.status.updated (FINISHED)
```

처음 두 이벤트는 같은 요청에서 발생하며, `transaction.created`가 먼저 큐에 들어갑니다. 각 webhook은 독립적으로 전송되므로 도착 순서에 의존하지 마세요.
TRUST로 보낸 이체는 `COMPLETED` 이벤트를 받지 않습니다. TRUST는 Didit이 읽을 수 있는 응답을 보내지 않기 때문입니다. 이체는 확인 기한까지 `AWAITING_COUNTERPARTY` 상태에 머물다가 `EXPIRED`로 끝납니다. GTR, VerifyVASP, Sygna Bridge에서는 Didit이 네트워크에서 거래 상대방의 응답을 읽으며, 이벤트는 그 응답 이후에 발생합니다.

## 서명 검증

모든 전송은 대상의 공유 비밀을 사용하여 HMAC-SHA256으로 서명되며, 서명 헤더 세 개와 타임스탬프가 함께 전달됩니다.

| 헤더 | 방식 |
| - | - |
| `X-Signature-V2` | 정렬된 키와 유니코드를 유지한 정식(canonical) JSON에 대한 HMAC - **권장**. |
| `X-Signature` | ASCII 이스케이프 처리된 정식 JSON에 대한 HMAC(레거시). |
| `X-Signature-Simple` | `"{timestamp}:{session_id}:{status}:{webhook_type}"`에 대한 HMAC - **지원 중단**. 거래 페이로드에는 `session_id`가 없으므로 이 헤더는 `"{timestamp}::{status}:{webhook_type}"`에 서명합니다. 따라서 거래도, 금액도, 점수도 식별하지 못합니다. `travel_rule.status.updated` 페이로드에는 `status`도 없으므로 `"{timestamp}:::travel_rule.status.updated"`에 서명합니다. `X-Signature-V2`로 검증하십시오. [지원 중단 공지](https://docs.didit.me/ko/integration/webhooks.md#x-signature-simple은-지원-중단됨)도 참조하십시오. |
| `X-Timestamp` | 유닉스 에포크 초 - 5분보다 오래된 전송은 거부하십시오. |

```typescript theme={null}
import { createHmac, timingSafeEqual } from 'crypto';

function verify(rawBody: string, signatureV2: string, secret: string) {
  const canonical = JSON.stringify(
    Object.keys(JSON.parse(rawBody)).sort()
      .reduce((acc, k) => ({ ...acc, [k]: JSON.parse(rawBody)[k] }), {})
  );
  const expected = createHmac('sha256', secret).update(canonical).digest('hex');
  return timingSafeEqual(Buffer.from(signatureV2), Buffer.from(expected));
}
```

타임스탬프 최신성 검사를 포함한 전체 검증 코드는 [webhook 레퍼런스](https://docs.didit.me/ko/integration/webhooks.md#서명-검증)를 참조하십시오.

## 재시도 및 전송 보장

* `5xx`, `404`, 시간 초과 또는 연결 실패가 발생하면 Didit은 최대 **2회** 재시도합니다. 첫 실패 후 약 1분 뒤에 한 번, 그 후 약 4분 뒤에 한 번 더 재시도합니다.
* 재시도된 전송은 동일한 `event_id`를 재사용하므로, 핸들러에서 이 값으로 중복을 제거하십시오.
* 최종 실패 후에는 해당 이벤트의 전송이 중단되고 귀하의 조직에 알림이 전송됩니다. 대상이 조용히 비활성화되는 일은 결코 없습니다.
* `2xx` 응답은 빠르게 반환하십시오(큐에 넣은 후 처리). 응답이 느리면 시간 초과와 중복 전송이 발생할 위험이 있습니다.

## 멱등성

자체 중복 제거 저장소에 `event_id`를 저장하여 각 webhook을 정확히 한 번만 처리하십시오. 엔드포인트가 응답 확인(ACK)을 느리게 보내거나 요청 도중 연결이 끊기면 같은 이벤트가 다시 전송될 수 있습니다.

## 구독하기

```bash theme={null}
curl -X POST https://verification.didit.me/v3/webhook/destinations/ \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "label": "TM events",
    "url": "https://yourapp.com/webhooks/didit-tm",
    "subscribed_events": [
      "transaction.created",
      "transaction.status.updated",
      "travel_rule.status.updated"
    ]
  }'
```

## 다음 단계

> [card] [webhook 레퍼런스](https://docs.didit.me/ko/integration/webhooks.md)
>
> 대상, 서명, 재시도.

> [card] [상태](https://docs.didit.me/ko/transaction-monitoring/statuses.md)
>
> 전체 상태 머신.

> [card] [연동 가이드](https://docs.didit.me/ko/transaction-monitoring/integration-guide.md)
>
> 프로덕션 환경에서 webhook 처리하기.


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