> ## 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: Event KYC & KYB Real-Time, Tanda Tangan HMAC

> Terima event verifikasi, entitas, dan transaksi real-time dari Didit. Penandatanganan HMAC-SHA256, tujuan fan-out, dan retry terbatas.

> [!NOTE]
>
> Aktivitas autentikasi dan pengiriman webhook-nya yang bergantung pada rilis didokumentasikan di [Event autentikasi dan webhook](https://docs.didit.me/id/authentication/events-and-webhooks.md). Jangan mendaftarkan tipe event autentikasi yang tidak tercantum pada API tujuan saat ini.

Prompt Integrasi Webhook
        Salin prompt ke papan klip



      
      

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



      
      

Tempel ke

## RingkasanWebhook adalah cara Didit mengirim **pembaruan real-time** ke backend Anda setiap kali sesi verifikasi, sesi bisnis, pengguna vendor, bisnis vendor, atau transaksi berubah status. Dengan berlangganan satu atau beberapa tujuan webhook ke event yang Anda butuhkan, Anda dapat merespons begitu hasilnya siap, biasanya dalam hitungan detik setelah pengguna yang sedang diverifikasi menyelesaikan alurnya.Webhook adalah pola integrasi yang **direkomendasikan**. Polling [`GET /v3/session/{id}/decision/`](https://docs.didit.me/id/sessions-api/retrieve-session) didukung sebagai cadangan, tetapi lebih lambat, memerlukan lebih banyak permintaan, dan melewatkan event seperti `data.updated`, `transaction.status.updated`, serta perubahan pada tingkat entitas yang hanya dikirim melalui webhook. Perlakukan pengiriman webhook sebagai sumber kebenaran dan hanya lakukan polling saat cold start atau untuk rekonsiliasi.Bab ini dari pelajaran integrasi Didit Academy melanjutkan dari ikhtisar webhook.Pelajaran integrasi Academy membahas webhook mulai dari 3:12: menambahkan tujuan, memilih event, dan rahasia penandatanganan yang akan Anda verifikasi di bawah.[Ikhtisar Webhook - Didit Academy](https://www.youtube.com/embed/fX03-WEu_EI?start=192\&rel=0\&playsinline=1)> [!NOTE]
>
> **Pengguna Cloudflare / WAF.** Didit mengirim webhook dari IP statis `18.203.201.92` dengan `User-Agent: DiditWebhook/2.0 +https://didit.me`. Jika edge Anda memblokir klien yang tidak dikenal, izinkan IP ini di **Security > WAF > Tools > IP Access Rules** dan pilih **Izinkan** untuk nama host penerima.## Jenis EventGunakan nilai persis di array `subscribed_events` pada tujuan. Tidak ada wildcard, jadi cantumkan setiap keluarga event yang ingin Anda terima. Anda juga dapat membagi event ke beberapa tujuan.| Event | Sumber daya | Saat dipicu |
| - | - | - |
| `status.updated` | Sesi KYC atau sesi bisnis KYB | Status sesi berubah (Belum Dimulai → Sedang Berlangsung → Disetujui / Ditolak / Dalam Peninjauan / Ditinggalkan / Diajukan Ulang). Payload KYB menyertakan `session_kind: "business"` dan `business_session_id`. |
| `data.updated` | Sesi KYC atau sesi bisnis KYB | Data verifikasi diubah setelah dibuat (koreksi manual dari peninjau untuk KYC, POA, NFC, registri KYB, dokumen KYB, dll.). |
| `user.status.updated` | Entitas pengguna | Status pengguna gabungan berubah di antara `ACTIVE`, `FLAGGED`, dan `BLOCKED` (tindakan di konsol, tindakan aturan, daftar blokir). Menyertakan `previous_status`. |
| `user.data.updated` | Entitas pengguna | Profil, penghitung, metadata, atau pengidentifikasi yang disetujui dari pengguna gabungan berubah. Menyertakan `changed_fields` dan peta `changes` berisi nilai `previous` / `current`. |
| `business.status.updated` | Entitas bisnis | Status bisnis gabungan berubah di antara `ACTIVE`, `FLAGGED`, dan `BLOCKED` (tindakan di konsol, tindakan aturan, daftar blokir). Menyertakan `previous_status`. |
| `business.data.updated` | Entitas bisnis | Profil, data registrasi, penghitung, metadata, atau kolom verifikasi agregat dari bisnis gabungan berubah. Menyertakan `changed_fields` dan `changes`. |
| `activity.created` | Linimasa aktivitas | Disediakan untuk umpan linimasa aktivitas. Nilainya diterima di `subscribed_events` dan dicakup oleh penguji **Try Webhook**, tetapi Didit saat ini tidak mengirim `activity.created` untuk lalu lintas produksi. |
| `transaction.created` | Pemantauan Transaksi | Sebuah transaksi dibuat dan hasil awal aturannya sudah siap. Menyertakan `transaction_id`, `txn_id`, `status`, `score`, `severity`, `amount`, `currency`, `direction`, `action_required`. |
| `transaction.status.updated` | Pemantauan Transaksi | Status transaksi berubah setelah evaluasi aturan, tindakan analis, remediasi, kecocokan daftar blokir, pembaruan dari penyedia, atau tindakan API/konsol. Event ini juga dikirim sekali, baik statusnya berubah maupun tidak, ketika skrining kripto yang dicoba ulang di latar belakang selesai ([hash transaksi belum diindeks](https://docs.didit.me/id/transaction-monitoring/aml-screening.md#hash-transaksi-yang-belum-diindeks)). |
| `travel_rule.status.updated` | Travel Rule | Status pertukaran [Travel Rule](https://docs.didit.me/id/transaction-monitoring/travel-rule.md) berubah saat dibuat (termasuk transisi `NOT_APPLICABLE` di bawah ambang batas), saat kepemilikan dikonfirmasi, saat respons pengambilan email diterima, saat selesai atau dibatalkan, atau saat batas waktu berakhir. Event ini berlaku untuk transfer keluar dan transfer masuk: padanan transfer masuk pada jalur `INTERNAL`, pendaftaran sunrise, dan permintaan `TRP` masuk. Untuk transaksi yang dibuat Didit, event ini dikirim setelah `transaction.created`-nya. Menyertakan `transaction_id`, `txn_id`, `travel_rule_status`, `rail`, `direction`. |
| `workflow.compliance_changed` | Kepatuhan alur kerja | Status kepatuhan alur kerja yang dipublikasikan berubah setelah evaluasi ulang. Satu event per alur kerja. Membawa `workflow_id`, `workflow_version`, `workflow_label`, `transition` (`from` dan `to`), `severity`, dan `kb_version`, tanpa `status`. |
| `transaction_rule_suggestions.created` | Pemantauan Transaksi | Sebuah proses saran aturan mengusulkan perubahan aturan baru dan pengaturan saran aturan memiliki notifikasi webhook yang aktif. Membawa `run_id`, `open_count`, dan `suggestions[]`, tanpa `status`. |
| `investigator.report.ready` | Investigator | Investigator menulis laporan untuk sebuah item. |
| `investigator.decision.automated` | Investigator | Investigator memutuskan sebuah item sendiri. |
| `investigator.decision.overridden` | Investigator | Seseorang mengambil tindakan yang berbeda dari laporan Investigator, atau membatalkan keputusan yang diambil Investigator sendiri. |
| `investigator.decision.contested` | Investigator | Pengguna akhir menyanggah keputusan dan meminta peninjauan oleh manusia. |
| `investigator.question.raised` | Investigator | Investigator mengajukan pertanyaan kepada tim tentang kebijakannya. |
| `investigator.automation.paused` | Investigator | Otomatisasi berhenti memutuskan, dijeda oleh seseorang atau karena batas pengeluaran bulanan tercapai. |
| `investigator.rfi.answered` | Investigator | Pengguna akhir menjawab permintaan informasi. |
| `investigator.rfi.overdue` | Investigator | Permintaan informasi melewati batas waktunya tanpa jawaban. |
| `investigator.drift.detected` | Investigator | Sebuah antrean mengalami pergeseran dan otomatisasi turun satu tingkat. |
| `investigator.sla.breached` | Investigator | Sebuah laporan menunggu melebihi SLA antreannya tanpa keputusan. |
| `investigator.qa.failed` | Investigator | Keputusan yang diambil seseorang gagal dalam tinjauan kualitas Investigator. |
| `investigator.external.report.ready` | Investigator | Laporan untuk item yang Anda kirim dari luar Didit sudah siap. |
| `auth.signup` | Autentikasi | Seseorang mendaftar melalui [Autentikasi](https://docs.didit.me/id/authentication/events-and-webhooks.md). Menyertakan `auth_event` dan ringkasan `authentication` orang tersebut. |
| `auth.login` | Autentikasi | Seseorang masuk. Menyertakan `auth_event` dan ringkasan `authentication` orang tersebut. |
| `auth.login_failed` | Autentikasi | Percobaan masuk gagal. `vendor_data` bernilai `null` ketika percobaan tidak menyebutkan orang yang dikenal. Menyertakan `auth_event` dan ringkasan `authentication` orang tersebut. |
| `auth.password_reset_requested` | Autentikasi | Seseorang meminta pengaturan ulang kata sandi. Menyertakan `auth_event` dan ringkasan `authentication` orang tersebut. |
| `auth.password_reset_completed` | Autentikasi | Seseorang menyelesaikan pengaturan ulang kata sandi. Menyertakan `auth_event` dan ringkasan `authentication` orang tersebut. |
| `auth.mfa_changed` | Autentikasi | Seseorang menambahkan atau mengubah autentikator. Menyertakan `auth_event` dan ringkasan `authentication` orang tersebut. |
| `auth.new_device` | Autentikasi | Seseorang masuk dari perangkat yang belum pernah digunakan sebelumnya. Menyertakan `auth_event` dan ringkasan `authentication` orang tersebut. |
| `auth.account_locked` | Autentikasi | Kebijakan keamanan mengunci akun, atau tim Anda memblokir orang tersebut. Menyertakan `auth_event` dan ringkasan `authentication` orang tersebut. |Nilai dalam tabel ini adalah kumpulan lengkap event webhook yang dapat Anda langgani. Tidak ada `session.status.updated`, `kyc.completed`, atau event serupa. Satu-satunya pengiriman lain yang dilakukan Didit adalah [callback `kyb.registry_search.resolved` yang tidak ditandatangani](#callback-pencarian-registri-kyb), yang dikirim ke `webhook_url` per permintaan yang Anda sertakan di [`POST /v3/kyb/search/`](https://docs.didit.me/id/standalone-apis/kyb-registry), dan tidak pernah ke tujuan webhook.> [!TIP]
>
> Menu **Try Webhook** di Business Console > API & Webhooks mengirim payload lengkap untuk setiap event berikut: disetujui, ditolak, dalam peninjauan, nama Unicode, KYC/KYB, entitas, aktivitas, dan transaksi. Gunakan menu ini untuk memvalidasi endpoint Anda sebelum beralih ke lingkungan produksi.## Struktur payload### AmplopSetiap webhook memakai amplop yang sama. Kolom selalu diserialisasi dengan `sort_keys=True` dan pemisah ringkas (`,` / `:`) agar payload yang sama menghasilkan tanda tangan yang sama di kedua sisi.| Kolom | Tipe | Keterangan |
| - | - | - |
| `event_id` | `uuid` | Identifier stabil untuk event. `event_id` yang **sama** dikirim ke setiap tujuan tempat event ini didistribusikan dan digunakan ulang pada percobaan ulang. Jadikan idempotensi Anda berdasarkan kolom ini. |
| `webhook_type` | string | Salah satu dari [jenis event](#jenis-event) di atas. |
| `timestamp` | int (detik Unix) | Waktu Didit mengirim webhook. Digunakan oleh verifikasi tanda tangan. Diperbarui pada setiap percobaan ulang. |
| `created_at` | int (detik Unix) | Waktu rekaman yang mendasarinya terakhir diperbarui. |
| `application_id` | `uuid` | Aplikasi yang memiliki tujuan tersebut. |
| `environment` | `"live"` \| `"sandbox"` | Apakah event berasal dari sesi atau aplikasi live atau sandbox. |
| `status` | string | Ada pada setiap event sesi, entitas, dan transaksi (termasuk `*.data.updated`). Event `investigator.*` tidak memiliki `status` (lihat payload Investigator di bawah). Event sesi menggunakan [label status verifikasi](https://docs.didit.me/id/integration/verification-statuses.md) yang persis sama (misalnya `"Approved"`, `"Kyc Expired"`). Event entitas menggunakan `ACTIVE` / `FLAGGED` / `BLOCKED`. Event transaksi menggunakan `APPROVED` / `IN_REVIEW` / `DECLINED` / `AWAITING_USER`. |Untuk event **sesi** (`status.updated`, `data.updated` pada sesi reguler dan sesi bisnis), amplop juga memuat:| Kolom | Tipe | Keterangan |
| - | - | - |
| `session_id` | `uuid` | UUID verifikasi pengguna atau sesi bisnis. |
| `business_session_id` | `uuid` | Hanya pada KYB. Sama dengan `session_id`. |
| `session_kind` | `"business"` | Hanya pada event KYB. Memungkinkan handler generik menentukan cabang. |
| `workflow_id` | `uuid` | Alur kerja yang digunakan untuk membuat sesi, jika diatur. |
| `workflow_version` | int | Versi alur kerja. |
| `vendor_data` | string | Identifier internal Anda, jika diberikan saat pembuatan sesi. |
| `vendor_business_id` | string | Hanya pada sesi KYB, jika diberikan. |
| `sandbox_scenario` | string \| null | Ada pada setiap event sesi (KYC dan KYB). Slug skenario [sandbox](https://docs.didit.me/id/integration/sandbox-testing.md) yang aktif (misalnya `"decline_aml_hit"`) pada sesi sandbox dengan skenario yang aktif. Bernilai `null` pada sesi live, atau pada sesi sandbox tanpa skenario yang aktif. Tidak ada pada webhook entitas atau transaksi. |
| `metadata` | object | Metadata apa pun yang Anda lampirkan saat pembuatan sesi, dengan kunci deduplikasi internal dihapus. |
| `trigger` | string | Opsional. Alasan webhook dipicu ketika alur verifikasi itu sendiri tidak menghasilkannya: `"manual_review"` (edit peninjau dari Konsol), `"manual_step_update"` (peninjau mengubah status fitur), `"ongoing_monitoring"` (penyegaran pemantauan AML berkelanjutan), `"webhook_reconciliation"` (`status.updated` dikirim ulang karena webhook terminal pertama tidak pernah dibuat, sehingga Anda dapat menerima event kedua untuk keputusan yang sudah Anda terima; lakukan deduplikasi berdasarkan `session_id` dan `status`), `"manual_webhook_recovery"` (webhook yang dipulihkan secara manual), `"kyb_registry_change"` (registri perusahaan melaporkan perubahan), `"console_registry_edit"`, `"console_key_people_skip"` dan `"console_key_people_regenerate"` (edit yang dibuat di Konsol pada sesi bisnis), serta `"biometric_retention"` (`data.updated` yang dikirim ketika data biometrik live sesi dihapus sesuai jadwal). Daftar ini dapat bertambah, jadi perlakukan nilai yang tidak dikenal sebagai informasi saja. |
| `decision` | object | Ada saat `status` adalah `Approved`, `Declined`, `In Review`, atau `Abandoned`. Mencerminkan [keputusan sesi V3](https://docs.didit.me/id/sessions-api/retrieve-session.md). |
| `resubmit_info` | `{ nodes_to_resubmit, reasons }` | Ada saat `status` adalah `Resubmitted` (tanpa `decision`). |
| `decided_by` | string | Hanya pada `status.updated` saat status adalah `Approved`, `Declined` atau `Resubmitted` dan aplikasi menggunakan Investigator. `"investigator"` saat Investigator mengambil keputusan sendiri, `"human"` saat pengguna Konsol mengambilnya (termasuk ketika menerima laporan Investigator). Tidak disertakan saat tidak ada peninjau yang mencatat keputusan, saat kunci API yang mengambil keputusan, atau saat Investigator tidak digunakan. |### Contoh amplop sesi```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]
>
> Objek `decision` memiliki skema yang sama dengan yang dikembalikan oleh [`GET /v3/session/{id}/decision/`](https://docs.didit.me/id/sessions-api/retrieve-session). Untuk referensi kolom kanonik per fitur (setiap properti pada `id_verifications[]`, `aml_screenings[]`, dll.), lihat [Model data](https://docs.didit.me/id/reference/data-models.md). Kami sengaja tidak menduplikasi tabel kolom per fitur di sini agar skema hanya ada di satu tempat.### Array jamak V3 (penting)Di dalam `decision`, setiap hasil per fitur adalah **array jamak** sehingga satu alur kerja dapat menyertakan beberapa instans fitur yang sama (misalnya, beberapa pemeriksaan ID pada beberapa dokumen):```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[]
```Setiap item memuat `node_id` yang cocok dengan graf alur kerja serta `status` per fitur. **Jangan** membaca kolom tunggal V2 lama (`id_verification`, `nfc`, `liveness`, `face_match`, `phone`, `email`, `poa`, `aml`, `ip_analysis`, `database_validation`). Kolom tersebut hanya muncul jika tujuan secara eksplisit ditetapkan ke `webhook_version: "v2"`.Hasil **Tanda Tangan Elektronik** ada di `signatures[]`, dan hanya tersedia jika alur kerja memiliki langkah Tanda Tangan Elektronik: satu item per penandatangan, dengan penandatangan, konfirmasi kode, sertifikat, stempel waktu, hash dan jalur unduh setiap dokumen, serta PDF bukti. Tidak ada event tanda tangan terpisah. `status.updated` memberi tahu Anda kapan harus membacanya. Lihat [laporan Tanda Tangan Elektronik](https://docs.didit.me/id/core-technology/electronic-signature/report-electronic-signature.md).Hasil **Verifikasi bank** ada di `bank_verifications[]`, satu item per langkah Verifikasi bank. `method` pada setiap item menunjukkan cara rekening diverifikasi: `open_banking` (masuk ke bank), `account_lookup` (pemeriksaan rekening), atau fallback `statement` atau `microdeposits`. Nilainya `null` hingga rekening dievaluasi. Ketika pengguna memilih **Continue without your bank**, `continued_without_bank` bernilai `true` dan `continuation` menjelaskan alasannya. `method` kemudian bernilai `none`, kecuali sebuah percobaan sudah dievaluasi sebelumnya: lihat [Ketika pengguna tidak dapat memverifikasi bank mereka](https://docs.didit.me/id/core-technology/bank-verification/fallbacks.md#baca-di-hasil). Rekening yang diverifikasi melalui pemeriksaan rekening tidak membaca data keuangan apa pun, sehingga `financial_data.not_collected` mencantumkan saldo, transaksi, atau penghasilan yang diminta langkah tersebut. Lihat [Metode verifikasi bank](https://docs.didit.me/id/core-technology/bank-verification/verification-methods.md#membaca-metode-dalam-hasil).### Investigator block`decision` V3 untuk sesi pengguna atau bisnis juga memuat `investigator`: tinjauan [Investigator](https://docs.didit.me/id/console/investigator.md) terbaru untuk sesi tersebut, atau `null` jika sesi tidak pernah memilikinya.| Kolom | Tipe | Arti |
| - | - | - |
| `run_id` | uuid | Tinjauan tersebut. Baca laporan lengkapnya di `GET /v3/investigator/runs/{run_id}/`. |
| `status` | string | `running`, `completed` atau `failed`. |
| `stage` | string | `shadow` atau `live` saat tinjauan berjalan. |
| `queue` | string | `kyc`, `kyb`, `aml`, `transactions` atau `cases`. |
| `is_open` | boolean | Tinjauan masih menunggu keputusan. |
| `recommendation` | string \| null | `approve`, `decline`, `escalate` atau `request`. |
| `recommendation_detail` | string \| null | Satu baris tentang tindakan yang direkomendasikan. |
| `confidence` | string \| null | `high`, `moderate` atau `low`. |
| `summary` | string \| null | Ringkasan laporan. |
| `ready_to_accept` | boolean | Laporan dapat diterima apa adanya. |
| `prepared_at` | datetime \| null | Kapan laporan siap. |
| `automated` | boolean | Investigator memutuskan sendiri, meskipun keputusannya kemudian ditimpa oleh seseorang. |
| `decided_by` | string \| null | `investigator` selama keputusannya sendiri masih berlaku, `member` jika tidak, `null` selama belum diputuskan. |
| `decision` | object \| null | Cara item keluar dari antrean: `outcome`, `action`, `final_status`, `agreed`, `decided_at`, `billed` dan siapa yang memutuskan. |Fitur pengumpulan dokumen, yaitu **Dokumen KYB** dan **Document AI**, tidak memiliki array khusus. Fitur ini muncul dalam daftar `features[]` pada keputusan sebagai `{ "feature": "DOCUMENT_AI", "node_id": "…" }` (satu entri per node). Lihat [Document AI](https://docs.didit.me/id/core-technology/document-ai/overview.md).Keputusan Verifikasi Pengguna juga memuat satu objek `biometric_consent` tunggal: apakah sesi mewajibkan persetujuan biometrik eksplisit, `trigger`, `status` dari jawaban, dan buktinya. `status: "pending"` dengan `reason: "BIOMETRIC_CONSENT_UNSUPPORTED_SDK"` berarti SDK native di bawah 4.10.0 merekam wajah tanpa langkah persetujuan. Lihat [Persetujuan biometrik](https://docs.didit.me/id/getting-started/biometric-consent.md).### Kunci metode Verifikasi IdentitasSetiap item `id_verifications[]` menyebutkan [metode Verifikasi Identitas](https://docs.didit.me/id/core-technology/id-verification/verification-methods.md) yang menghasilkannya, sehingga handler dapat bercabang berdasarkan bukti yang benar-benar diterimanya:| Kunci | Nilai |
| - | - |
| `verification_method` | `document`, `id_lookup` atau `wallet` |
| `assurance` | `documentary`, `data_match` atau `cryptographic` |
| `wallet_provider` | ID dompet dari katalog ketika dihasilkan oleh dompet, jika tidak maka `null` |
| `id_lookup` | Bukti registri ketika metodenya `id_lookup`, jika tidak maka `null` |
| `wallet_verification` | Bukti kredensial ketika metodenya `wallet`, jika tidak maka `null` |
| `fallback_from` | Alasan metode non-dokumen tidak mengakhiri langkah ID: metodenya, alasannya, dan apakah pengguna beralih kembali ke pengambilan dokumen atau ditolak |Handler yang sudah ada tetap berfungsi tanpa perubahan: sesi dokumen mengatur `verification_method` menjadi `document` dan membiarkan sisanya `null`. Tipe data per kolom ada di [Model data](https://docs.didit.me/id/reference/data-models.md#verifikasi-identitas).#### Sesi dompet yang disetujui (`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 entitas```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"
}
```Status entitas adalah `ACTIVE`, `FLAGGED`, atau `BLOCKED`. `user.data.updated` dan `business.data.updated` tetap memuat kolom `status` dan menambahkan array `changed_fields` serta map `changes` berupa `{ field: { previous, current } }`. Event `business.*` menggunakan `vendor_business_id` sebagai pengganti `vendor_user_id`.### Payload event transaksiWebhook transaksi menggunakan infrastruktur fan-out yang sama dan format envelope yang sama, tetapi mengidentifikasi transaksi, bukan sesi:```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` berisi `null`, atau tindakan pengguna yang tertunda (`type` `wallet_ownership` atau `verification_session`, dengan `url`-nya) yang dijelaskan di [Tindakan pengguna yang diperlukan](https://docs.didit.me/id/transaction-monitoring/sdk-transaction-submission.md#tindakan-yang-diperlukan-pengguna).Transaksi baru secara default berstatus `APPROVED`. Jika aturan, daftar blokir, atau alur kerja mengubah status saat pembuatan, payload `transaction.created` awal mencerminkan status akhir tersebut. `transaction.status.updated` memuat bentuk yang sama dengan `status` yang diperbarui. Status transaksi adalah `APPROVED`, `IN_REVIEW`, `DECLINED`, atau `AWAITING_USER`; `severity` adalah `UNKNOWN` / `LOW` / `MEDIUM` / `HIGH` / `CRITICAL` dan `direction` adalah `INBOUND` / `OUTBOUND`. `transaction.created` dan `transaction.status.updated` hanya dikirim untuk aplikasi **live**. Pertukaran Travel Rule di sandbox tetap mengirim `travel_rule.status.updated` dengan `environment: sandbox`.### Callback pencarian registri KYBAlur [`POST /v3/kyb/search/`](https://docs.didit.me/id/standalone-apis/kyb-registry) yang asinkron mengirim satu payload tambahan yang **bukan** event tujuan: ketika Anda meneruskan `webhook_url` ke permintaan pencarian, Didit mengirim POST callback `kyb.registry_search.resolved` ke URL tersebut setelah registri selesai diproses. Callback ini berbeda dari webhook tujuan dalam tiga hal:- Callback ini **tanpa tanda tangan**: tidak ada header `X-Signature*`. Sebagai gantinya, Didit memberi tanda `X-Didit-Unsigned-Callback: true`. Validasi dilakukan dengan mencocokkan `request_id` dengan id yang dikembalikan oleh permintaan pencarian.
- Nama event berada di `event_type` (bukan `webhook_type`), dan tidak ada `application_id` ataupun `environment`.
- `timestamp` dan `created_at` berupa integer epoch, seperti webhook lain tetapi tidak seperti `created_at` ISO pada endpoint pencarian.```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
}
```Callback mengikuti [kebijakan coba lagi](#kebijakan-coba-lagi) yang sama dengan webhook tujuan (percobaan ulang memperbarui `timestamp` dan tetap tanpa tanda tangan). Lihat [API Registri KYB](https://docs.didit.me/id/standalone-apis/kyb-registry.md) untuk skema daftar kandidat lengkap.## Verifikasi tanda tanganSetiap webhook ditandatangani dengan HMAC-SHA256 menggunakan `secret_shared_key` milik tujuan. Tiga header tanda tangan dikirim agar Anda dapat memilih header yang tetap berfungsi pada stack Anda:| Header | Yang ditandatangani | Aman untuk middleware | Kapan digunakan |
| - | - | - | - |
| `X-Signature-V2` | JSON compact yang diurutkan dan mempertahankan Unicode (`ensure_ascii=False`) | Ya | **Direkomendasikan**: tetap berfungsi meskipun framework Anda melakukan encode ulang pada body. |
| `X-Signature` | Byte mentah persis dari request sebagaimana Didit mengirimkannya (`sort_keys`, `ensure_ascii=True`) | Tidak | Hanya jika Anda dapat membaca body mentah sebelum parser mana pun menyentuhnya. |
| `X-Signature-Simple` **(tidak lagi direkomendasikan)** | `"{timestamp}:{session_id}:{status}:{webhook_type}"` | Ya | Fallback terakhir yang hanya mengautentikasi **envelope** saja, dan **tidak** mengautentikasi `decision`. Baca pemberitahuan tentang penghentian penggunaan di bawah sebelum menggunakannya. |Header yang selalu dikirim Didit:| Header | Contoh | Arti |
| - | - | - |
| `Content-Type` | `application/json` | Body berupa JSON. |
| `User-Agent` | `DiditWebhook/2.0 +https://didit.me` | Mengidentifikasi worker webhook Didit. |
| `X-Timestamp` | `1774970000` | Detik Unix epoch saat webhook dikirim. Tolak jika `abs(now - X-Timestamp) > 300`. |
| `X-Signature` / `X-Signature-V2` / `X-Signature-Simple` | digest hex | HMAC-SHA256 dari body atau string kanonis di atas. |Selama 24 jam setelah secret penandatanganan tujuan dirotasi, pengiriman juga membawa `X-Signature-Previous`, `X-Signature-V2-Previous` dan `X-Signature-Simple-Previous` (lihat [Rotasi secret penandatanganan](#rotasi-secret-penandatanganan)).### Rotasi secret penandatangananAnda merotasi secret penandatanganan tujuan di Business Console.
Selama 24 jam setelah rotasi, setiap pengiriman ke tujuan tersebut membawa tiga header tambahan yang dibuat dengan secret yang digantikan:| Header | Sama dengan | Ditandatangani dengan |
| - | - | - |
| `X-Signature-V2-Previous` | `X-Signature-V2` | Secret yang digantikan |
| `X-Signature-Previous` | `X-Signature` | Secret yang digantikan |
| `X-Signature-Simple-Previous` | `X-Signature-Simple` (tidak lagi direkomendasikan) | Secret yang digantikan |Handler yang menerima kecocokan pada header tanda tangan atau pada header `-Previous`-nya akan tetap memverifikasi saat Anda men-deploy secret baru: secret lama cocok dengan header `-Previous`, dan secret baru cocok dengan header biasa.
Contoh kode di bawah melakukan hal ini.
Deploy secret baru dalam 24 jam, karena setelah itu hanya secret baru yang lolos verifikasi.
`previous_secret_expires_at` pada [`GET /v3/webhook/destinations/{destination_uuid}/`](https://docs.didit.me/id/management-api/webhook/get-destination) memberi tahu kapan 24 jam tersebut berakhir.Percobaan ulang dan pengiriman ulang ditandatangani ulang dengan secret tujuan yang berlaku saat dikirim, dan hanya membawa header `-Previous` selama 24 jam masih berjalan.
Merotasi lagi dalam 24 jam akan langsung mencabut secret tertua: hanya secret yang berlaku sampai rotasi baru yang tetap lolos verifikasi melalui header `-Previous`, selama 24 jam sejak rotasi baru.Header `-Previous` hanya ada untuk tujuan webhook.
`secret_shared_key` tingkat aplikasi yang menandatangani `webhook_url` lama tidak memiliki masa tumpang tindih: merotasinya dengan `rotate_secret_key` pada [`PATCH /v3/webhook/`](https://docs.didit.me/id/sessions-api/management-api#konfigurasi-webhook) langsung membatalkan secret lama.### `X-Signature-Simple` sudah tidak digunakan lagi> [!WARNING]
>
> **`X-Signature-Simple` mengautentikasi envelope, bukan payload.** String kanonisnya hanya mencakup `timestamp`, `session_id`, `status` dan `webhook_type`. String ini **tidak** mencakup `decision`, serta `resubmit_info`, `metadata`, `vendor_data` atau `event_id`. Permintaan yang `decision`-nya ditulis ulang, sementara keempat kolom tersebut dibiarkan tidak berubah, tetap lolos verifikasi `X-Signature-Simple`.
>
> Kondisinya lebih lemah lagi pada event yang sama sekali tidak membawa `session_id`. Event transaksi (`transaction_id`, `txn_id`, `amount`, `score`) dan event entitas (`vendor_user_id`, `vendor_business_id`) ditandatangani dengan `"{timestamp}::{status}:{webhook_type}"`, yang tidak mengidentifikasi **transaksi atau entitas mana** yang dimaksud event tersebut. Dua transaksi berbeda yang dilaporkan dengan status yang sama dalam detik yang sama akan memiliki `X-Signature-Simple` yang sama.
> Event `travel_rule.status.updated`, `workflow.compliance_changed` dan `transaction_rule_suggestions.created` juga tidak membawa `status`, sehingga ditandatangani dengan `"{timestamp}:::{webhook_type}"`, misalnya `"{timestamp}:::travel_rule.status.updated"`.
>
> Jika `X-Signature-Simple` adalah satu-satunya header yang Anda periksa, perlakukan semua data di luar keempat field tersebut sebagai **tidak tepercaya**, lalu ambil ulang record dari API: [`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/id/sessions-api/retrieve-session) untuk sesi, [`GET /v3/transactions/{transaction_id}/`](https://docs.didit.me/id/management-api/transactions/get) untuk transaksi. API adalah sumber data resmi.Ini adalah batasan kompatibilitas, bukan sesuatu yang dapat kami perbaiki di tempat. String kanonis empat field adalah kontrak wire: setiap penerima yang memverifikasi `X-Signature-Simple` menghitung ulang persis keempat field tersebut, sehingga mengubah apa yang ditandatangani header ini akan diam-diam merusak semua verifikasi tersebut. Mencakup body juga memerlukan bentuk kanonis JSON yang ditetapkan dan dapat direproduksi byte demi byte oleh kedua sisi, dan Didit sudah menyediakannya sebagai `X-Signature-V2`. Daripada menambahkan varian kedua yang mencakup body dengan aturan kanonikalisasinya sendiri, header ini dibiarkan seperti sekarang dan dinyatakan sudah tidak digunakan lagi demi varian yang mencakup seluruh payload dan sudah ada.Artinya bagi Anda:- **Tidak ada yang berubah di wire.** `X-Signature-Simple` tetap dikirim pada setiap pengiriman, dihitung dengan cara yang sama. Verifier yang ada tetap berfungsi, dan kami akan mengumumkan setiap penghapusan jauh sebelumnya.
- **Integrasi baru wajib menggunakan `X-Signature-V2`.** Header ini aman untuk middleware *dan* mencakup seluruh payload.
- **Jika Anda belum bisa beralih dari `X-Signature-Simple`**, verifikasi header tersebut, lalu ambil record dari API, bukan membacanya dari body webhook.### Mengapa tiga varian?Beberapa framework web (body parser Express, middleware Django, API gateway) diam-diam melakukan encode ulang JSON sebelum handler Anda membacanya. Jika body asli berisi `"José"` dan middleware Anda menulisnya ulang menjadi `"José"`, byte-nya berubah meskipun datanya identik. Akibatnya `X-Signature` gagal. `X-Signature-V2` dihitung dari bentuk JSON kanonis (kunci diurutkan, separator compact, **Unicode tanpa escape**) yang hampir semua middleware dapat mereproduksi secara deterministik. `X-Signature-Simple` yang tidak lagi direkomendasikan kembali ke penandatanganan string pendek yang tidak bergantung pada parser ketika bahkan itu pun gagal. Karena hanya menandatangani string pendek tersebut dan tidak pernah body, ia tidak dapat menjamin keaslian body.### Urutan verifikasi yang direkomendasikan1) Coba **`X-Signature-V2`** terlebih dahulu. Enkode ulang `JSON.parse(body)` dengan kunci yang diurutkan dan Unicode tetap dipertahankan, lalu `HMAC-SHA256(secret, canonical)` dan `timingSafeEqual` terhadap header.
2) Jika V2 gagal dan Anda dapat membaca byte mentah, coba **`X-Signature`** terhadap `HMAC-SHA256(secret, rawBody)`.
3) Hanya jika kedua langkah sebelumnya gagal, gunakan **`X-Signature-Simple`** yang sudah usang sebagai cadangan terhadap `HMAC-SHA256(secret, "{timestamp}:{session_id}:{status}:{webhook_type}")`. Cara ini hanya mengautentikasi amplop, jadi perlakukan `decision` di body sebagai tidak tepercaya dan ambil ulang dari [`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/id/sessions-api/retrieve-session) sebelum Anda bertindak berdasarkannya.Pada setiap langkah, jika header `-Previous` yang sesuai ada, terima kecocokan dari salah satu header (lihat [Rotasi rahasia penandatanganan](#rotasi-secret-penandatanganan)).Dalam semua kasus, **tolak** permintaan apa pun yang berusia lebih dari **5 menit** (`abs(now - X-Timestamp) > 300`) untuk melindungi dari replay.## Kebijakan coba lagiJika endpoint Anda merespons dengan `5xx` atau `404`, Didit melakukan coba lagi hingga **2 kali** dengan backoff eksponensial:- **Coba lagi ke-1**, sekitar **1 menit** setelah kegagalan awal.
- **Coba lagi ke-2**, sekitar **4 menit** setelah coba lagi ke-1.Setelah coba lagi kedua, pengiriman dibuang. Setiap percobaan pengiriman (awal + coba lagi) dicatat sebagai entri terpisah di Business Console pada tab **Pengiriman** milik tujuan, sehingga Anda dapat mengulang atau memeriksa percobaan mana pun.Setiap percobaan dalam log pengiriman tujuan memuat nomor `attempt`: `1` untuk pengiriman pertama sebuah event, dan satu angka lebih besar daripada nomor percobaan yang diulang untuk setiap coba lagi atau pengiriman ulang.
Coba lagi otomatis dikirim ke URL tempat percobaan pertama dikirim, meskipun Anda sudah mengubah `url` tujuan setelahnya.
Pengiriman ulang manual dikirim ke URL tujuan yang berlaku saat ini, sehingga memperbaiki URL juga memperbaiki pengiriman ulang.Aturan pengiriman lain yang perlu diketahui:- Permintaan HTTP keluar habis waktu (timeout) setelah **5 detik**.
- Didit memblokir pengiriman ke URL privat atau localhost sebagai pengaman SSRF, dan tidak pernah mengikuti pengalihan. Gunakan endpoint HTTPS publik yang merespons langsung.
- `url` tujuan yang memuat backslash mentah, karakter kontrol, atau lebih dari satu `@` pada bagian otoritas akan ditolak sebagai tidak valid. URL seperti itu tidak dapat dibuat, dan URL yang sudah ada tidak dapat dikirim ulang atau diputar ulang. Gunakan URL `https://host/path` biasa dengan paling banyak satu bagian userinfo (untuk URL webhook gaya basic-auth).
- `2xx` dianggap berhasil. Timeout dan kegagalan koneksi dianggap dapat dicoba lagi (muncul sebagai `504`/`503` di log pengiriman). Respons `3xx` dan `4xx` selain `404` **tidak** dicoba lagi.
- Saat coba lagi, Didit menghitung ulang tanda tangan dengan `X-Timestamp` yang baru, sehingga pasangan timestamp dan tanda tangan selalu cocok.
- Coba lagi dan pengiriman ulang ditandatangani dengan rahasia penandatanganan tujuan yang berlaku pada saat dikirim.### Urutan pengirimanDidit tidak menjamin urutan webhook akan sampai kepada Anda.
Setiap pengiriman diantrekan secara terpisah, dan coba lagi tiba beberapa menit setelah percobaan pertama, sehingga `status.updated` untuk `Approved` dapat sampai kepada Anda sebelum event yang lebih awal untuk `In Progress`.Rancang penerima webhook Anda untuk kondisi itu:- Perlakukan webhook sebagai sinyal bahwa sesuatu telah berubah. Jika urutan penting, panggil [`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/id/sessions-api/retrieve-session) dan bertindak berdasarkan `status` terkini.
- Jangan biarkan event yang lebih lama menimpa status yang lebih baru: bandingkan `created_at` event dengan yang terakhir Anda simpan untuk rekaman tersebut, dan abaikan event yang lebih lama.
- Deduplikasi berdasarkan `event_id`, yang tetap sama di seluruh coba lagi.Didit mengantrekan event dari transfer Travel Rule dalam urutan tertentu, yang dijelaskan di [Travel Rule exchange status changed](#status-pertukaran-travel-rule-berubah-travel_rule-status-updated), tetapi coba lagi tetap dapat mengubah urutan kedatangannya.## Mengatur tujuanAnda dapat mengelola tujuan dari **Business Console > API & Webhooks** atau melalui Management API.**1. Buat tujuan**Panggil [`POST /v3/webhook/destinations/`](https://docs.didit.me/id/management-api/webhook/create-destination) (atau klik **Tambah tujuan** di konsol) dengan `label`, `url` publik, `webhook_version` (`"v3"`), dan array `subscribed_events` yang berisi setiap keluarga event yang seharusnya diterima endpoint ini. Minimal satu event wajib diisi, dan tidak ada wildcard.**2. Simpan secret\_shared\_key**Respons pembuatan mengembalikan `secret_shared_key` milik tujuan. Simpan bersama rahasia server Anda yang lain: kunci ini hanya milik tujuan ini dan menjadi input untuk ketiga varian tanda tangan HMAC-SHA256. [`GET /v3/webhook/destinations/{destination_uuid}/`](https://docs.didit.me/id/management-api/webhook/get-destination) mengembalikannya lagi kepada pemanggil dengan kunci API dan kepada pengguna konsol yang dapat membaca webhook.**3. Implementasikan endpoint**Sediakan rute `POST` HTTPS publik, baca isi mentah, verifikasi tanda tangan (lebih baik gunakan `X-Signature-V2`), periksa jendela timestamp, lalu tentukan penanganan berdasarkan `webhook_type`. Kembalikan `2xx` segera setelah Anda mengantrekan pekerjaan, dan lakukan pemrosesan berat secara asinkron.**4. Konfirmasi penerimaan**Pantau tab **Pengiriman** pada tujuan. Webhook yang sehat mengembalikan `200` dalam satu atau dua detik. `5xx`/`404` memicu kebijakan coba lagi Didit, dan kegagalan yang terus berlanjut dibuang setelah coba lagi kedua.**5. Iterasi dengan Try Webhook**Gunakan skenario konsol [**Try Webhook**](#pengujian) untuk menguji endpoint Anda dengan payload untuk skenario disetujui, ditolak, dalam peninjauan, KYB, entitas, aktivitas, dan transaksi sebelum mengaktifkan lalu lintas produksi.![Fitur pengujian webhook Didit di Business Console](https://mintcdn.com/didit-0f962782/z6T2GHM4Zh-iSj-K/images/test-webhook.png?fit=max\&auto=format\&n=z6T2GHM4Zh-iSj-K\&q=85\&s=2787ef3ed7a8016798f951e5ae898e4e)Kirim webhook uji langsung dari Business Console untuk memvalidasi endpoint Anda sebelum ditayangkan ke produksi.Anda dapat membuat tujuan sebanyak yang dibutuhkan. Pola yang umum adalah **satu tujuan per konsumen**, misalnya event KYC ke layanan autentikasi Anda, event KYB ke layanan operasi kepatuhan Anda, dan transaksi ke antrean penipuan Anda. Setiap tujuan memiliki rahasia, versi, dan daftar langganan sendiri, sehingga rahasia yang dirotasi atau penerapan yang bermasalah pada satu konsumen tidak memengaruhi konsumen lain.## Contoh kodeUntuk memastikan keamanan endpoint webhook Anda, verifikasi keaslian setiap permintaan masuk menggunakan `secret_shared_key` milik tujuan webhook.> [!WARNING]
>
> **Gunakan `X-Signature-V2`.** Ini adalah varian yang paling tahan terhadap encoding ulang oleh middleware, dan varian ini sepenuhnya mengautentikasi isi (termasuk `decision`).### Node.js / Express (disarankan: 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}`));
```Jika Anda dapat membaca byte mentah sebelum parser mana pun memprosesnya, varian `X-Signature` asli bekerja dengan cara yang sama, tetapi menandatangani isi mentah. Kami menyimpannya di bagian lama di bawah untuk kelengkapan.### Python / FastAPI (disarankan: 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 (disarankan: X-Signature-V2)Default JSON PHP **tidak** menghasilkan bentuk kanonik. Empat default harus ditimpa, dan jika salah satunya terlewat, byte yang ditandatangani akan berubah sehingga digest tidak pernah cocok:| Default PHP | Yang dihasilkan | Yang dibutuhkan |
| - | - | - |
| `json_decode($raw, true)` (asosiatif) | objek kosong `{}` menjadi `[]` | decode dengan `$associative = false` agar objek JSON tetap `stdClass` |
| `json_encode` meng-escape garis miring | `https:\/\/media.didit.me` | `JSON_UNESCAPED_SLASHES` |
| `json_encode` meng-escape karakter non-ASCII | `Jos\u00e9` alih-alih `José` | `JSON_UNESCAPED_UNICODE` |
| `ksort` membandingkan kunci yang menyerupai angka sebagai angka | `"9"` diurutkan sebelum `"10"` | `ksort($props, SORT_STRING)` |Jebakan objek kosong adalah yang paling merugikan. Payload Didit rutin memuat map kosong seperti `risk_view.countries.risk_scores`, `risk_view.crimes.risk_scores`, dan `risk_view.custom_list`, sehingga round-trip asosiatif akan merusak tanda tangan pada sesi yang disetujui pada umumnya, bukan hanya pada kasus langka.```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]);
    }
}
```Dua hal lain yang perlu diketahui:- **Float bernilai bulat tidak memerlukan penanganan khusus di PHP.** Didit menyerialisasinya sebagai integer (`100.0` → `100`), dan `json_encode` PHP menuliskan float bulat dengan cara yang sama, sehingga langkah `shortenFloats` tidak diperlukan, berbeda dengan contoh Node.js di atas.
- **Pertahankan `serialize_precision` pada `-1`** (default sejak PHP 7.1). Jika host Anda menimpanya menjadi `17`, `json_encode` akan menuliskan `87.42` sebagai `87.420000000000002` dan setiap pemeriksaan `X-Signature-V2` akan gagal.Jika Anda tidak dapat membuat bentuk kanonik cocok, `verifySignatureRaw` di atas adalah opsi cadangan yang lebih sederhana: `$request->getContent()` milik Laravel mengembalikan byte asli, sehingga `X-Signature` bekerja selama tidak ada yang di depan PHP yang meng-encode ulang isi. Itulah kondisi yang memang dirancang untuk diatasi oleh `X-Signature-V2`, sehingga V2 dicoba terlebih dahulu.## PengujianAnda **tidak** perlu menjalankan verifikasi penuh untuk menguji endpoint Anda.- Dari **Business Console > API & Webhooks > Try Webhook**, pilih skenario (misalnya `approved_full_features`, `declined_face_mismatch`, `in_review_aml_hit`, `business_session_approved`, `transaction_created`, `activity_created`, `user_status_updated`) dan kirim webhook lengkap ke URL tujuan webhook Anda.
- Setiap skenario ditandatangani dengan tiga header tanda tangan dan encoding kanonik yang sama dengan lalu lintas produksi, termasuk data uji unicode (`approved_kyc_with_unicode`) agar Anda dapat memastikan `X-Signature-V2` berfungsi melalui middleware Anda. Pengiriman uji membawa data contoh dan ditandai dengan header tambahan `X-Didit-Test-Webhook: true` (serta sufiks `(Test)` pada `User-Agent`) agar Anda dapat memastikan pengiriman tersebut tidak memengaruhi status produksi.
- Putar ulang pengiriman historis mana pun dari tab **Pengiriman** pada tujuan webhook jika Anda perlu men-debug regresi.Untuk smoke test end-to-end, jalankan verifikasi nyata melalui alur kerja uji di aplikasi Anda sendiri. Webhook yang dihasilkan menggunakan format pesan, header, dan kebijakan coba lagi yang sama dengan skenario **Try Webhook**.## Contoh### Sesi KYC disetujui (`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"
  }
}
```### Sesi ditolak dengan peringatan (`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": []
  }
}
```### Sesi bisnis disetujui (`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": []
  }
}
```### Transaksi dibuat (`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
}
```### Status pertukaran Travel Rule berubah (`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` berisi salah satu dari 13 status pertukaran yang didokumentasikan di [Travel Rule](https://docs.didit.me/id/transaction-monitoring/travel-rule.md#siklus-hidup).
`rail` bernilai `INTERNAL`, `TRP`, `GTR`, `TRUST`, `VERIFYVASP`, `SYGNA`, atau `EMAIL` (string kosong sebelum jalur dipilih, jika tidak ada jalur yang dapat ditentukan, dan selalu untuk registrasi sunrise), atau `TEST_VASP` untuk transfer sandbox yang dijawab oleh [Didit Test VASP](https://docs.didit.me/id/transaction-monitoring/travel-rule-sandbox.md), dan `direction` bernilai `INBOUND` atau `OUTBOUND`.
Event sandbox memuat `"environment": "sandbox"`, dan event `TEST_VASP` juga memuat `test_vasp: true` dan `test_vasp_scenario` (skenario yang memberikan jawaban).Saat Didit membuat transaksi untuk transfer Travel Rule (panggilan [`POST /v3/transactions/`](https://docs.didit.me/id/management-api/transactions/create), [registrasi sunrise setelah deposit](https://docs.didit.me/id/transaction-monitoring/travel-rule.md#pendaftaran-sunrise-setelah-deposit), permintaan masuk `TRP`, atau tahap masuk jalur `INTERNAL`), Didit mengirim event `transaction.created` untuk transaksi tersebut terlebih dahulu, lalu event `travel_rule.status.updated` untuk transfer tersebut setelahnya.
Aturan yang mengubah status transaksi selama langkah ini juga dapat mengirim `transaction.status.updated` sebelum `transaction.created`, dan registrasi sunrise yang dilampirkan ke transaksi deposit yang sudah Anda buat tidak mengirim `transaction.created`.
Setiap webhook dikirim dan dicoba ulang secara terpisah, sehingga event yang dicoba ulang tetap dapat tiba setelah event berikutnya: gunakan `transaction_id` sebagai kunci di handler Anda, dan baca ulang transaksi saat Anda membutuhkan status terkininya.
Transfer yang dikirim melalui TRUST tidak pernah menerima event `COMPLETED`, karena TRUST tidak mengirimkan apa pun yang dapat dibaca Didit: transfer tetap berada di `AWAITING_COUNTERPARTY` hingga batas waktu konfirmasi, lalu berakhir `EXPIRED`. Melalui GTR, VerifyVASP dan Sygna Bridge, Didit membaca jawaban pihak lawan dari jaringan dan event mengikuti jawaban tersebut (lihat [jaringan Travel Rule](https://docs.didit.me/id/transaction-monitoring/travel-rule-networks.md)).### Kepatuhan alur kerja berubah (`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` dan `transition.to` berisi salah satu dari `compliant`, `gaps_detected`, `pending_change`, `unknown_coverage`, atau `not_evaluated`.
`severity` bernilai `actionable` saat celah terdeteksi, `indicative` untuk perubahan yang tertunda, dan `informative` saat celah sudah teratasi.
Didit mengirim satu event untuk setiap alur kerja yang berubah.### Saran aturan transaksi dibuat (`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` bernilai `ADD` untuk aturan baru, `TWEAK` untuk perubahan pada aturan yang sudah ada, dan `DISABLE` untuk mematikan satu aturan. `rule_id` bernilai `null` untuk `ADD`.
`open_count` adalah jumlah saran yang masih terbuka setelah proses berjalan, termasuk saran baru.
Pengaturan saran aturan menentukan apakah webhook ini aktif atau nonaktif; lihat [Webhook transaksi](https://docs.didit.me/id/transaction-monitoring/webhooks.md).### Event Investigator (`investigator.*`)Dua belas event `investigator.*` menggunakan amplop yang sama (`event_id`, `webhook_type`, `timestamp`, `created_at`, `application_id`, `environment`) dan header tanda tangan yang sama dengan setiap event lainnya. Event ini hanya berisi ID dan angka: tidak pernah berisi nama, alasan yang diketik peninjau, narasi, atau apa pun yang ditulis pengguna akhir. Event ini tidak memiliki `session_id`; item yang dimaksud disebutkan di `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
}
```| Event | Kolom selain amplop |
| - | - |
| `investigator.report.ready` | `run_id`, `queue`, `item`, `recommendation`, `confidence`, `provenance`, `ready_to_accept`, `questions` (jumlah) |
| `investigator.decision.automated` | `run_id`, `queue`, `item`, `recommendation`, `confidence`, `provenance`, `decided_by` (`"investigator"`), `outcome`, `action`, `final_status`, `agreed` |
| `investigator.decision.overridden` | Sama seperti `investigator.decision.automated` dengan `decided_by` `"human"`, ditambah `override_category`, `overrode` (`"report"` atau `"automated"`) dan, saat `overrode` bernilai `"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"` atau `"spend_limit"`); untuk `"spend_limit"` juga `month` (`YYYY-MM`) dan `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` (kode risiko), `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` menyatakan versi Investigator yang menulis laporan:- `engine` adalah versi engine Investigator: ID delapan karakter yang tidak transparan. ID ini berubah setiap kali engine berubah, sehingga dua laporan dengan `engine` yang sama ditulis oleh engine yang sama.
- `agent_version` adalah versi agen.
- `policy_version` adalah versi kebijakan Anda sendiri yang diikuti laporan.
- `model_id` adalah alias usang yang memuat nilai yang sama dengan `engine`. Baca `engine` sebagai gantinya.
- `prompt_hash` tidak lagi dikirim.`item.kind` menyebutkan jenis item (sesi, sesi bisnis, transaksi, atau kasus) dan `item.id` menyebutkan ID-nya. Timestamp yang berakhiran `_at` adalah string ISO 8601. Untuk berlangganan event ini, cantumkan string persisnya di `subscribed_events`, sama seperti event lainnya.### Status entitas pengguna berubah (`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"
}
```### Sesi yang diajukan ulang```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" }
  }
}
```## Lama: X-Signature dengan isi mentah`X-Signature` tetap dikirim pada setiap pengiriman. Nilainya adalah HMAC-SHA256 atas **byte persis** yang dikirim Didit (`json.dumps(..., sort_keys=True, separators=(",", ":"))`). Gunakan hanya ketika Anda dapat membaca body mentah sebelum middleware mana pun mengubahnya.> [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}
> ```## Lama: format webhook V2> [!WARNING]
>
> Bidang di bawah ini hanya berlaku ketika tujuan dikunci ke `webhook_version: "v2"`. Ada juga penguncian `"v1"` yang lebih lama (bentuk keputusan tunggal ketiga) untuk tujuan yang dibuat sebelum V2, jadi hubungi dukungan sebelum mengandalkannya. Integrasi baru sebaiknya menggunakan **V3** (default), yang didokumentasikan di atas dan menggunakan array dalam bentuk jamak.| Aspek | V2 | V3 |
| - | - | - |
| Header tanda tangan | `X-Signature`, `X-Signature-V2`, `X-Signature-Simple` (ketiganya dikirim terlepas dari versi; Simple [tidak digunakan lagi](#x-signature-simple-sudah-tidak-digunakan-lagi)) | Sama |
| Nama bidang keputusan | Objek tunggal (`id_verification`, `liveness`, `face_match`, ...) | Array jamak (`id_verifications`, `liveness_checks`, `face_matches`, ...) |
| Beberapa instans fitur | Tidak didukung | Didukung melalui array per fitur dengan `node_id` |
| Skor AML | `aml.score`, `aml.hits[].match_score`, `aml.hits[].risk_score` | Nilai yang sama di bawah `aml_screenings[]` |Objek per fitur itu sendiri sama di kedua versi: tujuan V2 menerima persis `match_score`, `risk_score`, `score_breakdown`, `risk_view` dan `review_status` untuk setiap hasil, serta skor agregat `score` seperti yang ada pada V3 (lihat [laporan AML](https://docs.didit.me/id/core-technology/aml-screening/report-aml-screening.md#tiga-skor-bukan-satu-keputusan-tunggal)). Mengunci ke V2 mengubah letak laporan AML dalam `decision`, bukan isinya.Jika konsumen hilir Anda masih memerlukan bentuk V2, buat tujuan terpisah yang dikunci ke `webhook_version: "v2"` dan tetap gunakan V3 untuk konsumen baru Anda.## Terkait- [API tujuan webhook](https://docs.didit.me/id/management-api/webhook/list-destinations.md): daftar, buat, perbarui, hapus tujuan, dan periksa log pengiriman.
- [Buat tujuan webhook](https://docs.didit.me/id/management-api/webhook/create-destination.md): skema permintaan dan respons, termasuk `secret_shared_key` milik tujuan.
- [Model data](https://docs.didit.me/id/reference/data-models.md): skema kanonik untuk setiap objek per fitur yang disematkan dalam `decision`.
- [Skema keputusan](https://docs.didit.me/id/sessions-api/retrieve-session.md): daftar bidang lengkap untuk objek `decision` yang disematkan dalam webhook sesi.
- [Status verifikasi](https://docs.didit.me/id/integration/verification-statuses.md): nilai `status` yang tepat dan transisinya seperti yang dirujuk di atas.
- [Batas laju](https://docs.didit.me/id/integration/rate-limiting.md): batas yang berlaku saat melakukan polling `GET /v3/session/{id}/decision/` sebagai cadangan.


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