> ## 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: события KYC и KYB в реальном времени

> Получайте в реальном времени события верификации, организаций и транзакций Didit. Подпись HMAC-SHA256, рассылка и повторные попытки.

> [!NOTE]
>
> Активность аутентификации и доставка связанных с ней webhook, которая зависит от версии релиза, описаны в разделе [События аутентификации и webhook](https://docs.didit.me/ru/authentication/events-and-webhooks.md). Не регистрируйте в текущем API адресов назначения типы событий аутентификации, которых нет в списке.

Промпт для интеграции webhook
        Скопировать промпт в буфер обмена



      
      

\<pre>

\`Add a Didit webhook endpoint to my application to receive real-time identity verification, entity, and transaction events.

\## What to Build
A POST endpoint (e.g., /api/webhooks/didit) that:
1\. Reads the raw request body (do NOT parse JSON before signature verification)
2\. Verifies the HMAC-SHA256 signature (X-Signature-V2 recommended)
3\. Validates the timestamp is fresh (within 5 minutes)
4\. Parses the JSON body and processes the event by webhook_type
5\. Returns 2xx ASAP and updates state idempotently

\## Signature Headers
\- X-Signature-V2: HMAC-SHA256 over the sorted, Unicode-preserved canonical JSON (recommended).
\- X-Signature: HMAC-SHA256 over the exact raw bytes (works only if your stack does not re-encode the body).
\- X-Signature-Simple: HMAC-SHA256 over "{timestamp}:{session_id}:{status}:{webhook_type}" (DEPRECATED last-resort fallback; authenticates the envelope only, NOT the decision body — if you verify with it, re-fetch the decision from GET /v3/session/{session_id}/decision/ instead of trusting the payload).
\- X-Timestamp: Unix epoch seconds when Didit dispatched the webhook.
\- X-Signature-V2-Previous, X-Signature-Previous, X-Signature-Simple-Previous: sent only for 24 hours after a destination's signing secret is rotated in the Business Console. Same signatures, made with the replaced secret. Never sent for the legacy application webhook_url.

Verification:
1\. abs(now - X-Timestamp) <= 300 seconds
2\. Recompute the signature using your destination secret
3\. Compare with constant-time comparison against the header and, when present, its -Previous header (accept a match on either), so verification keeps working while you deploy a rotated secret

\## Webhook Event Types (use exact values in destination.subscribed_events)
\- "status.updated": session or business session status changed
\- "data.updated": session or business-session verification data was edited
\- "user.status.updated": User entity status changed ("ACTIVE" / "FLAGGED" / "BLOCKED")
\- "user.data.updated": User entity profile or aggregate data changed
\- "business.status.updated": Business entity status changed ("ACTIVE" / "FLAGGED" / "BLOCKED")
\- "business.data.updated": Business entity profile or aggregate data changed
\- "activity.created": reserved for the activity timeline (currently only sent by Try Webhook tests)
\- "transaction.created": transaction created and initial verdict ready
\- "transaction.status.updated": transaction status changed
\- "travel_rule.status.updated": a Travel Rule exchange's status changed (see /transaction-monitoring/travel-rule)
\- "workflow.compliance_changed": the compliance status of a published workflow changed after a re-evaluation
\- "transaction_rule_suggestions.created": a transaction rule suggestion run proposed new rule changes
\- "investigator.report.ready": the Investigator wrote a report on an item
\- "investigator.decision.automated": the Investigator decided an item on its own
\- "investigator.decision.overridden": a person took another action than the Investigator's report, or overrode a decision it took on its own
\- "investigator.decision.contested": the end user contested a decision and asked for a human review
\- "investigator.question.raised": the Investigator asked the team a question about its policy
\- "investigator.automation.paused": automation stopped deciding, paused by a person or at the monthly spending limit
\- "investigator.rfi.answered": the end user answered an information request
\- "investigator.rfi.overdue": an information request passed its deadline unanswered
\- "investigator.drift.detected": a queue drifted and automation stepped down a tier
\- "investigator.sla.breached": a report waited past its queue's SLA without a decision
\- "investigator.qa.failed": a decision a person took failed the Investigator's quality review
\- "investigator.external.report.ready": the report on an item you sent from outside Didit is ready

There is no wildcard; include every event family you want to receive. You can create multiple destinations to fan out to different URLs.

\## Webhook Envelope (session events)
\{
"event_id": "uuid",
"session_id": "uuid",
"status": "Approved" | "Declined" | "In Review" | "In Progress" | "Not Started" | "Abandoned" | "Expired" | "Kyc Expired" | "Resubmitted" | "Awaiting User",
"webhook_type": "status.updated",
"created_at": 1627680000,
"timestamp": 1627680000,
"application_id": "uuid",
"environment": "live" | "sandbox",
"sandbox_scenario": "decline_aml_hit" | null,  // armed scenario slug on sandbox sessions; null on live sessions or an unarmed sandbox session
"workflow_id": "uuid",
"workflow_version": 1,
"vendor_data": "your-internal-user-id",
"metadata": { "user_type": "premium" },
"decision": { ... }  // only when Approved / Declined / In Review / Abandoned
}

Status values are exact, case-sensitive strings — note "Kyc Expired" uses a single capital K.

KYB / business-session payloads additionally include "business_session_id" and "session_kind": "business".

\## Decision Object (V3)
The decision mirrors the GET /v3/session/decision/ response. Per-feature data is in plural arrays so a workflow can include multiple instances of the same feature:
\- id_verifications[], nfc_verifications[], liveness_checks[], face_matches[]
\- aml_screenings[], poa_verifications[], phone_verifications[], email_verifications[]
\- ip_analyses[], database_validations[], questionnaire_responses
\- bank_verifications[]: one item per Bank Verification step. Its method is "open_banking" (bank sign-in), "account_lookup" (account check), "statement" or "microdeposits", or "none" when the person continued without a bank before any attempt was evaluated. continued_without_bank: true says the person continued without a bank, and continuation.reason says why.
\- reviews[]
\- signatures[]: only when the workflow has an E-signature step. One item per signer; status "signed" means the documents are signed. Download signed_file and evidence_file with your API key.

Each item carries a node_id so you can correlate it with the workflow graph.
User Verification decisions also carry one biometric_consent object (not an array): required, trigger, status (not_required | pending | given | declined), reason. status "pending" with reason "BIOMETRIC_CONSENT_UNSUPPORTED_SDK" means a native SDK below 4.10.0 captured a face without the consent step, so consent was not collected.

\## Database Update Logic
switch (status):
"Approved"     -> user.verified = true; store decision data
"Declined"     -> user.verification_status = "declined"; log decision.*.warnings
"In Review"    -> user.verification_status = "pending_review"
"In Progress"  -> user.verification_status = "in_progress"
"Resubmitted"  -> reopen the listed feature nodes per resubmit_info
"Abandoned"    -> optionally trigger reminder
"Awaiting User" -> KYB parent session waiting on party verifications; no action needed
"Expired" / "Kyc Expired" -> mark session expired; optionally create a new one

\## Retry Policy
On 5xx, 404, timeout, or connection failure Didit retries up to 2 times:
\- 1st retry: ~1 minute after the initial failure
\- 2nd retry: ~4 minutes after the 1st retry
After that the delivery is dropped. Each delivery attempt is logged separately in the console's Deliveries tab.
Each attempt in a destination's delivery log has an attempt number: 1 for the first delivery of an event, then one more than the attempt it repeats for each retry or resend.
Automatic retries go to the URL the first attempt used; a manual resend goes to the destination's current URL.
Retries and resends are signed again with the destination's current secret and a fresh timestamp.

\## Error Handling
\- Return 2xx as fast as possible; do heavy work asynchronously.
\- Log the raw body for debugging signature failures.
\- Treat duplicate webhooks idempotently (key on event_id, or session_id + status + webhook_type).
\- Delivery order is not guaranteed: never let an older event overwrite newer state. Compare created_at, or re-fetch the decision from GET /v3/session/{session_id}/decision/.
\- HTTPS endpoints only. Webhook receivers behind Cloudflare must whitelist 18.203.201.92.

\## Environment Variable
DIDIT_WEBHOOK_SECRET -- from the Business Console (per-destination secret_shared_key).

\## Testing
Send test webhooks from the Business Console (API & Webhooks > Try Webhook) before going live. The console covers approved, declined, in-review, KYB, entity, activity, and transaction scenarios.

\## Docs
\- Webhooks guide: https://docs.didit.me/integration/webhooks
\- Webhook destinations API: https://docs.didit.me/management-api/webhook/list-destinations
\- Verification statuses: https://docs.didit.me/integration/verification-statuses
\- Decision schema: https://docs.didit.me/sessions-api/retrieve-session

\## Important
\- ALWAYS verify the signature before processing any webhook
\- Use constant-time comparison to prevent timing attacks
\- Store and HMAC the raw JSON string -- never re-stringify after parsing
\- Return 2xx quickly; do heavy processing asynchronously if needed`

\</pre>



      
      

Вставьте в

## ОбзорWebhook представляет собой способ, которым Didit передаёт на ваш бэкенд **обновления в реальном времени** при каждом изменении состояния сессии верификации, бизнес-сессии, пользователя или компании в системе поставщика либо транзакции. Подписав на нужные вам события один или несколько адресов назначения, вы сможете отреагировать сразу, как только будет готов результат, обычно в течение нескольких секунд после того, как проверяемый пользователь завершит свой сценарий.**Рекомендуемый** способ интеграции: webhook. Опрос [`GET /v3/session/{id}/decision/`](https://docs.didit.me/ru/sessions-api/retrieve-session) поддерживается как запасной вариант, но он медленнее, требует больше запросов и пропускает события вроде `data.updated`, `transaction.status.updated` и изменения на уровне сущностей, которые отправляются только через webhook. Считайте доставку webhook источником истины и опрашивайте API только при первом запуске или для сверки.Эта глава урока по интеграции в Didit Academy начинается с обзора webhook.В уроке Academy по интеграции webhook рассматриваются с 3:12: добавление адреса назначения, выбор событий и секрет подписи, с помощью которого вы будете проверять подпись ниже.[Обзор webhook — Didit Academy](https://www.youtube.com/embed/fX03-WEu_EI?start=192\&rel=0\&playsinline=1)> [!NOTE]
>
> **Пользователи Cloudflare и WAF.** Didit отправляет webhook с фиксированного адреса `18.203.201.92` и с заголовком `User-Agent: DiditWebhook/2.0 +https://didit.me`. Если ваша периферийная инфраструктура блокирует неизвестных клиентов, разрешите этот адрес в **Security > WAF > Tools > IP Access Rules** и выберите **Разрешить** для хоста-получателя webhook.## Типы событийИспользуйте точное значение в массиве `subscribed_events` адреса назначения webhook. Подстановочные символы не поддерживаются: перечислите каждое семейство, которое хотите получать. События можно распределить по нескольким адресам назначения.| Событие | Ресурс | Когда срабатывает |
| - | - | - |
| `status.updated` | Сессия KYC или бизнес-сессия KYB | Меняется статус сессии (Не начата → В процессе → Одобрена / Отклонена / На проверке / Прервана / Подана повторно). Payload KYB включает `session_kind: "business"` и `business_session_id`. |
| `data.updated` | Сессия KYC или бизнес-сессия KYB | Данные верификации редактируются после создания (исправления, внесённые проверяющим в данные KYC, POA, NFC, реестра KYB, документов KYB и т. д.). |
| `user.status.updated` | Сущность пользователя | Статус объединённого пользователя меняется между `ACTIVE`, `FLAGGED` и `BLOCKED` (действие в консоли, действие по правилу, список блокировки). Включает `previous_status`. |
| `user.data.updated` | Сущность пользователя | Меняются профиль, счётчики, метаданные или подтверждённые идентификаторы объединённого пользователя. Включает `changed_fields` и карту `changes` со значениями `previous` и `current`. |
| `business.status.updated` | Сущность компании | Статус объединённой компании меняется между `ACTIVE`, `FLAGGED` и `BLOCKED` (действие в консоли, действие по правилу, список блокировки). Включает `previous_status`. |
| `business.data.updated` | Сущность компании | Меняются профиль, регистрационные данные, счётчики, метаданные или агрегированные поля верификации объединённой компании. Включает `changed_fields` и `changes`. |
| `activity.created` | Лента активности | Зарезервировано для ленты активности. Значение принимается в `subscribed_events` и проверяется с помощью тестера **Try Webhook**, но Didit в настоящее время не доставляет `activity.created` для трафика продакшн-среды. |
| `transaction.created` | Мониторинг транзакций | Транзакция создана, и готов её первоначальный вердикт по правилам. Включает `transaction_id`, `txn_id`, `status`, `score`, `severity`, `amount`, `currency`, `direction`, `action_required`. |
| `transaction.status.updated` | Мониторинг транзакций | Статус транзакции меняется после оценки правилами, действия аналитика, устранения нарушения, совпадения со списком блокировки, обновления от провайдера или действия через API или консоль. Также отправляется один раз, изменился статус или нет, когда завершается скрининг криптовалютных транзакций, который был повторён в фоновом режиме ([хеши транзакций ещё не проиндексированы](https://docs.didit.me/ru/transaction-monitoring/aml-screening.md#хеши-транзакций-которые-ещё-не-проиндексированы)). |
| `travel_rule.status.updated` | Travel Rule | Меняется статус обмена [Travel Rule](https://docs.didit.me/ru/transaction-monitoring/travel-rule.md): при создании (включая переход в `NOT_APPLICABLE` для сумм ниже порога), при подтверждении владения, при ответе, полученном по электронной почте, при завершении или отмене, а также при истечении тайм-аута. Срабатывает для исходящих переводов и для входящих: для входящего аналога по каналу `INTERNAL`, для регистраций sunrise и для входящих запросов `TRP`. Для транзакции, которую создаёт Didit, эти события отправляются после её `transaction.created`. Включает `transaction_id`, `txn_id`, `travel_rule_status`, `rail`, `direction`. |
| `workflow.compliance_changed` | Комплаенс сценария | Статус комплаенса опубликованного сценария изменился после повторной оценки. Одно событие на сценарий. Содержит `workflow_id`, `workflow_version`, `workflow_label`, `transition` (`from` и `to`), `severity` и `kb_version`, и не содержит `status`. |
| `transaction_rule_suggestions.created` | Мониторинг транзакций | Запуск предложений по правилам предложил новые изменения правил, а уведомления webhook в настройках предложений по правилам включены. Содержит `run_id`, `open_count` и `suggestions[]`, и не содержит `status`. |
| `investigator.report.ready` | Investigator | Investigator подготовил отчёт по элементу. |
| `investigator.decision.automated` | Investigator | Investigator принял решение по элементу самостоятельно. |
| `investigator.decision.overridden` | Investigator | Человек выполнил другое действие, отличное от отчёта Investigator, или отменил решение, которое Investigator принял самостоятельно. |
| `investigator.decision.contested` | Investigator | Конечный пользователь оспорил решение и запросил ручную проверку. |
| `investigator.question.raised` | Investigator | Investigator задал команде вопрос о своей политике. |
| `investigator.automation.paused` | Investigator | Автоматизация перестала принимать решения: её приостановил человек или она достигла месячного лимита расходов. |
| `investigator.rfi.answered` | Investigator | Конечный пользователь ответил на запрос информации. |
| `investigator.rfi.overdue` | Investigator | Срок ответа на запрос информации истёк, но ответа не поступило. |
| `investigator.drift.detected` | Investigator | В очереди произошёл дрейф, и автоматизация понизила уровень. |
| `investigator.sla.breached` | Investigator | Отчёт ждал решения дольше SLA своей очереди. |
| `investigator.qa.failed` | Investigator | Решение, принятое человеком, не прошло проверку качества Investigator. |
| `investigator.external.report.ready` | Investigator | Отчёт по элементу, отправленному вами из-за пределов Didit, готов. |
| `auth.signup` | Аутентификация | Человек регистрируется через [Аутентификацию](https://docs.didit.me/ru/authentication/events-and-webhooks.md). Включает `auth_event` и сводку `authentication` по этому человеку. |
| `auth.login` | Аутентификация | Человек входит в систему. Включает `auth_event` и сводку `authentication` по этому человеку. |
| `auth.login_failed` | Аутентификация | Попытка входа не удалась. `vendor_data` равен `null`, если попытка входа не связана ни с одним известным человеком. Включает `auth_event` и сводку `authentication` по этому человеку. |
| `auth.password_reset_requested` | Аутентификация | Человек запрашивает сброс пароля. Включает `auth_event` и сводку `authentication` по этому человеку. |
| `auth.password_reset_completed` | Аутентификация | Человек завершает сброс пароля. Включает `auth_event` и сводку `authentication` по этому человеку. |
| `auth.mfa_changed` | Аутентификация | Человек добавляет или меняет аутентификатор. Включает `auth_event` и сводку `authentication` по этому человеку. |
| `auth.new_device` | Аутентификация | Человек входит с устройства, которое ранее не использовал. Включает `auth_event` и сводку `authentication` по этому человеку. |
| `auth.account_locked` | Аутентификация | Политика безопасности блокирует аккаунт или ваша команда блокирует человека. Включает `auth_event` и сводку `authentication` по этому человеку. |Значения из этой таблицы составляют полный набор событий, на которые можно подписаться. Таких событий, как `session.status.updated` или `kyc.completed`, не существует, как и других похожих. Единственный другой вид отправки от Didit: неподписанный [обратный вызов `kyb.registry_search.resolved`](#обратный-вызов-поиска-в-реестре-kyb). Он уходит на `webhook_url`, который вы передаёте в [`POST /v3/kyb/search/`](https://docs.didit.me/ru/standalone-apis/kyb-registry) для каждого запроса, и никогда не отправляется на адрес назначения webhook.> [!TIP]
>
> Меню Business Console > API & Webhooks > **Try Webhook** отправляет полноценные payload для каждого из этих событий (одобрена, отклонена, на проверке, имена в Unicode, KYC/KYB, сущность, активность и транзакция). Используйте его, чтобы проверить свой endpoint перед переходом в продакшн.## Структура payload### КонвертВсе webhook используют один и тот же конверт. Поля всегда сериализуются с `sort_keys=True` и компактными разделителями (`,` / `:`), чтобы один и тот же payload давал одну и ту же подпись на обеих сторонах.| Поле | Тип | Описание |
| - | - | - |
| `event_id` | `uuid` | Стабильный идентификатор события. Один и тот же **`event_id`** отправляется каждому адресу назначения, на который разветвляется событие, и повторно используется при повторных попытках. Настройте идемпотентность по нему. |
| `webhook_type` | string | Один из [типов событий](#типы-событий), перечисленных выше. |
| `timestamp` | int (секунды Unix) | Время, когда Didit отправил webhook. Используется при проверке подписи. Обновляется при каждой повторной попытке. |
| `created_at` | int (секунды Unix) | Время последнего обновления исходной записи. |
| `application_id` | `uuid` | Приложение, которому принадлежит адрес назначения. |
| `environment` | `"live"` \| `"sandbox"` | Указывает, пришло ли событие из продакшн-сессии или приложения либо из песочницы. |
| `status` | string | Присутствует в каждом событии сессии, сущности и транзакции (включая `*.data.updated`). События `investigator.*` не содержат `status` (см. payload Investigator ниже). События сессий используют точные [метки статусов верификации](https://docs.didit.me/ru/integration/verification-statuses.md) (например, `"Approved"`, `"Kyc Expired"`). События сущностей используют `ACTIVE` / `FLAGGED` / `BLOCKED`. События транзакций используют `APPROVED` / `IN_REVIEW` / `DECLINED` / `AWAITING_USER`. |Для событий **сессии** (`status.updated`, `data.updated` для обычных и бизнес-сессий) конверт дополнительно содержит:| Поле | Тип | Описание |
| - | - | - |
| `session_id` | `uuid` | UUID верификации пользователя или бизнес-сессии. |
| `business_session_id` | `uuid` | Только для KYB. Равно `session_id`. |
| `session_kind` | `"business"` | Только для событий KYB. Позволяет универсальному обработчику выбрать ветвь. |
| `workflow_id` | `uuid` | Сценарий, с помощью которого создана сессия, если он задан. |
| `workflow_version` | int | Версия сценария. |
| `vendor_data` | string | Ваш внутренний идентификатор, если он передан при создании сессии. |
| `vendor_business_id` | string | Только для сессий KYB, если передан. |
| `sandbox_scenario` | string \| null | Для каждого события сессии (KYC и KYB). Идентификатор активированного [сценария песочницы](https://docs.didit.me/ru/integration/sandbox-testing.md) (например, `"decline_aml_hit"`) для сессии песочницы с активированным сценарием. `null` для продакшн-сессии или для сессии песочницы без активированного сценария. Не присутствует в вебхуках сущностей или транзакций. |
| `metadata` | object | Метаданные, которые вы прикрепили при создании сессии, без внутренних ключей дедупликации. |
| `trigger` | string | Необязательное поле. Причина отправки webhook, если сам процесс верификации его не породил: `"manual_review"` (правки проверяющего из консоли), `"manual_step_update"` (проверяющий изменил статус функции), `"ongoing_monitoring"` (обновление постоянного AML-мониторинга), `"webhook_reconciliation"` (`status.updated` отправлен повторно, потому что первый итоговый webhook так и не был создан, поэтому вы можете получить второе событие по решению, которое уже получили: дедуплицируйте по `session_id` и `status`), `"manual_webhook_recovery"` (webhook, восстановленный вручную), `"kyb_registry_change"` (реестр компаний сообщил об изменении), `"console_registry_edit"`, `"console_key_people_skip"` и `"console_key_people_regenerate"` (правки, сделанные в консоли для бизнес-сессии), а также `"biometric_retention"` (`data.updated`, отправленный, когда биометрические данные сессии, полученные в реальном времени, удаляются по расписанию). Набор может расширяться, поэтому воспринимайте неизвестное значение как информационное. |
| `decision` | object | Присутствует, когда `status` равен `Approved`, `Declined`, `In Review` или `Abandoned`. Повторяет [решение сессии V3](https://docs.didit.me/ru/sessions-api/retrieve-session.md). |
| `resubmit_info` | `{ nodes_to_resubmit, reasons }` | Присутствует, когда `status` равен `Resubmitted` (без `decision`). |
| `decided_by` | string | Только для `status.updated`, когда статус равен `Approved`, `Declined` или `Resubmitted` и приложение использует Investigator. `"investigator"`, когда Investigator принял решение самостоятельно. `"human"`, когда решение принял пользователь консоли (включая принятие отчёта Investigator). Не указывается, если проверяющий не зафиксировал решение, если решение принял API-ключ или если Investigator не используется. |### Пример конверта сессии```json theme={null}
{
  "event_id": "9c0c8b8a-1111-4222-9333-444444444444",
  "webhook_type": "status.updated",
  "timestamp": 1774970000,
  "created_at": 1774969994,
  "application_id": "11111111-2222-3333-4444-555555555555",
  "environment": "live",
  "sandbox_scenario": null,
  "session_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
  "status": "Approved",
  "workflow_id": "66666666-7777-8888-9999-000000000000",
  "workflow_version": 4,
  "vendor_data": "user_42",
  "metadata": { "tier": "premium" },
  "decision": { "...": "see /sessions-api/retrieve-session" }
}
```> [!TIP]
>
> Объект `decision` имеет ту же схему, что и ответ [`GET /v3/session/{id}/decision/`](https://docs.didit.me/ru/sessions-api/retrieve-session). Канонический справочник полей по каждой функции (все свойства `id_verifications[]`, `aml_screenings[]` и т. д.) см. в разделе [Модели данных](https://docs.didit.me/ru/reference/data-models.md). Таблицы полей по функциям намеренно не дублируются здесь, чтобы схема хранилась в одном месте.### Массивы V3 (важно)Внутри `decision` каждый результат по функции является **массивом**, чтобы один сценарий мог включать несколько экземпляров одной и той же функции (например, несколько проверок документов):```text theme={null}
id_verifications[]        nfc_verifications[]
liveness_checks[]         face_matches[]
phone_verifications[]     email_verifications[]
poa_verifications[]       aml_screenings[]
ip_analyses[]             database_validations[]
questionnaire_responses   reviews[]
bank_verifications[]
```Каждый элемент содержит `node_id`, соответствующий графу сценария, и `status` для каждой функции. **Не** используйте устаревшие поля V2 в единственном числе (`id_verification`, `nfc`, `liveness`, `face_match`, `phone`, `email`, `poa`, `aml`, `ip_analysis`, `database_validation`): они появляются, только если адрес назначения webhook явно закреплён за `webhook_version: "v2"`.Результаты **электронной подписи** находятся в `signatures[]` и присутствуют только тогда, когда в сценарии есть шаг электронной подписи: один элемент на каждого подписанта, с данными подписанта, подтверждением с помощью кода, сертификатом, временной меткой, хешами каждого документа и путями для скачивания, а также PDF с доказательствами. Отдельного события подписи нет: `status.updated` сообщает, когда их читать. См. [отчёт об электронной подписи](https://docs.didit.me/ru/core-technology/electronic-signature/report-electronic-signature.md).Результаты **Банковской проверки** находятся в `bank_verifications[]`, по одному элементу на каждый шаг Банковской проверки. `method` каждого элемента показывает, как был подтверждён счёт: `open_banking` (вход в банк), `account_lookup` (проверка счёта) или резервный способ `statement` либо `microdeposits`. Значение `null`, пока счёт не оценён. Когда человек выбрал **Continue without your bank**, `continued_without_bank` равен `true`, а `continuation` указывает причину. В этом случае `method` равен `none`, если только раньше не была оценена попытка: см. [Когда человек не может подтвердить свой банк](https://docs.didit.me/ru/core-technology/bank-verification/fallbacks.md#просмотр-в-результатах). При подтверждении счёта финансовые данные не считываются, поэтому `financial_data.not_collected` перечисляет балансы, транзакции или доходы, которые запрашивал шаг. См. [Способы банковской проверки](https://docs.didit.me/ru/core-technology/bank-verification/verification-methods.md#определение-метода-в-результате).### Блок InvestigatorДля `decision` сессии пользователя или компании также передаётся `investigator`: последняя проверка [Investigator](https://docs.didit.me/ru/console/investigator.md) для этой сессии или `null`, если проверок не было.| Поле | Тип | Значение |
| - | - | - |
| `run_id` | uuid | Проверка. Полный отчёт читайте по адресу `GET /v3/investigator/runs/{run_id}/`. |
| `status` | string | `running`, `completed` или `failed`. |
| `stage` | string | `shadow` или `live` на момент выполнения проверки. |
| `queue` | string | `kyc`, `kyb`, `aml`, `transactions` или `cases`. |
| `is_open` | boolean | Проверка всё ещё ожидает решения. |
| `recommendation` | string \| null | `approve`, `decline`, `escalate` или `request`. |
| `recommendation_detail` | string \| null | Одна строка с рекомендованным действием. |
| `confidence` | string \| null | `high`, `moderate` или `low`. |
| `summary` | string \| null | Краткое содержание отчёта. |
| `ready_to_accept` | boolean | Отчёт можно принять без изменений. |
| `prepared_at` | datetime \| null | Когда отчёт был готов. |
| `automated` | boolean | Investigator принял решение самостоятельно, даже если участник позже его переопределил. |
| `decided_by` | string \| null | `investigator`, пока действует его собственное решение, `member` в остальных случаях, `null`, пока решения нет. |
| `decision` | object \| null | Как элемент покинул очередь: `outcome`, `action`, `final_status`, `agreed`, `decided_at`, `billed` и кто принял решение. |Функции сбора документов, **Документы KYB** и **Document AI**, не имеют отдельного массива. Они появляются в списке `features[]` решения как `{ "feature": "DOCUMENT_AI", "node_id": "…" }` (по одной записи на каждый узел). См. [Document AI](https://docs.didit.me/ru/core-technology/document-ai/overview.md).Решения проверки пользователей также содержат один объект `biometric_consent`: требовала ли сессия явного биометрического согласия, `trigger`, `status` ответа и подтверждающие его данные. `status: "pending"` с `reason: "BIOMETRIC_CONSENT_UNSUPPORTED_SDK"` означает, что нативный SDK версии ниже 4.10.0 захватил изображение лица без шага согласия. См. [Биометрическое согласие](https://docs.didit.me/ru/getting-started/biometric-consent.md).### Ключи метода верификации личностиКаждый элемент `id_verifications[]` указывает, какой [метод верификации личности](https://docs.didit.me/ru/core-technology/id-verification/verification-methods.md) его создал, чтобы обработчик мог выбирать ветку в зависимости от фактически полученных данных:| Ключ | Значение |
| - | - |
| `verification_method` | `document`, `id_lookup` или `wallet` |
| `assurance` | `documentary`, `data_match` или `cryptographic` |
| `wallet_provider` | Идентификатор кошелька из каталога, если результат был получен с помощью кошелька, иначе `null` |
| `id_lookup` | Данные реестра, если метод был `id_lookup`, иначе `null` |
| `wallet_verification` | Подтверждение учётных данных, если метод был `wallet`, иначе `null` |
| `fallback_from` | Почему метод, не являющийся документом, не завершил шаг ID: метод, причина и то, вернулся ли пользователь к съёмке документа или ему было отказано |Существующие обработчики продолжают работать без изменений: сессия с документом устанавливает `verification_method` в `document`, а остальные поля оставляет `null`. Типы полей описаны в [Модели данных](https://docs.didit.me/ru/reference/data-models.md#верификация-личности).#### Одобренная сессия кошелька (`status.updated`)```json theme={null}
{
  "event_id": "3a0c1b2c-4444-4555-8666-777777777777",
  "webhook_type": "status.updated",
  "timestamp": 1774970000,
  "created_at": 1774969994,
  "application_id": "11111111-2222-3333-4444-555555555555",
  "environment": "live",
  "session_id": "cccccccc-dddd-eeee-ffff-000000000000",
  "status": "Approved",
  "vendor_data": "user_77",
  "decision": {
    "session_id": "cccccccc-dddd-eeee-ffff-000000000000",
    "status": "Approved",
    "features": ["ID_VERIFICATION"],
    "id_verifications": [
      {
        "node_id": "id_verification_1",
        "status": "Approved",
        "issuing_state": "DNK",
        "first_name": "Freja",
        "last_name": "Nielsen",
        "full_name": "Freja Nielsen",
        "date_of_birth": "1988-03-02",
        "verification_method": "wallet",
        "assurance": "cryptographic",
        "wallet_provider": "mitid",
        "fallback_from": null,
        "id_lookup": null,
        "wallet_verification": {
          "provider": "mitid",
          "provider_name": "MitID",
          "issuing_authority": "Danish Agency for Digital Government",
          "issuing_country": "DNK",
          "credential_type": "person_identification",
          "level_of_assurance": "substantial",
          "verified_at": "2026-09-04T09:12:44Z",
          "signature_valid": true,
          "attributes": {
            "full_name": "Freja Nielsen",
            "date_of_birth": "1988-03-02",
            "cpr_alias": "b0f1c2d3-e4f5-4678-9abc-def012345678"
          },
          "portrait": null,
          "face_match_score": null
        },
        "warnings": []
      }
    ],
    "reviews": [],
    "created_at": "2026-09-04T09:12:20.443172Z"
  }
}
```### Payload событий сущностей```json theme={null}
{
  "event_id": "uuid",
  "webhook_type": "user.status.updated",
  "timestamp": 1774970000,
  "created_at": 1774969994,
  "application_id": "uuid",
  "environment": "live",
  "vendor_user_id": "uuid",
  "vendor_data": "user_42",
  "status": "BLOCKED",
  "previous_status": "ACTIVE"
}
```Статусы сущностей: `ACTIVE`, `FLAGGED` или `BLOCKED`. `user.data.updated` и `business.data.updated` сохраняют поле `status` и добавляют массив `changed_fields` и словарь `changes` вида `{ field: { previous, current } }`. События `business.*` используют `vendor_business_id` вместо `vendor_user_id`.### Payload событий транзакцийВебхуки транзакций используют ту же инфраструктуру рассылки и тот же конверт, но идентифицируют транзакцию, а не сессию:```json theme={null}
{
  "event_id": "uuid",
  "webhook_type": "transaction.created",
  "timestamp": 1774970000,
  "created_at": 1774970000,
  "application_id": "uuid",
  "environment": "live",
  "transaction_id": "uuid",
  "txn_id": "finance0001",
  "status": "APPROVED",
  "score": 0,
  "severity": "UNKNOWN",
  "amount": "1200.00",
  "currency": "EUR",
  "direction": "OUTBOUND",
  "action_required": null
}
````action_required` равен `null` или содержит ожидающее действие пользователя (`type` `wallet_ownership` или `verification_session` с его `url`), описанное в [Обязательные действия пользователя](https://docs.didit.me/ru/transaction-monitoring/sdk-transaction-submission.md#требуемые-действия-пользователя).Новые транзакции по умолчанию получают статус `APPROVED`. Если правило, список блокировки или сценарий меняет статус при создании, первоначальный payload `transaction.created` отражает этот итоговый статус. `transaction.status.updated` содержит ту же структуру с обновлённым `status`. Статусы транзакций: `APPROVED`, `IN_REVIEW`, `DECLINED` или `AWAITING_USER`; `severity` принимает значения `UNKNOWN` / `LOW` / `MEDIUM` / `HIGH` / `CRITICAL`, а `direction` принимает значения `INBOUND` / `OUTBOUND`. `transaction.created` и `transaction.status.updated` доставляются только для приложений в **продакшн-среде**. Обмены Travel Rule в песочнице отправляют `travel_rule.status.updated` с `environment: sandbox`.### Обратный вызов поиска в реестре KYBАсинхронный поток [`POST /v3/kyb/search/`](https://docs.didit.me/ru/standalone-apis/kyb-registry) доставляет один дополнительный payload, который **не** является событием для адреса назначения. Если вы передаёте `webhook_url` в запросе поиска, Didit отправляет POST-запрос с обратным вызовом `kyb.registry_search.resolved` на этот URL после того, как реестр вернёт результат. Он отличается от вебхуков адреса назначения тремя способами:- Он **без подписи**: заголовков `X-Signature*` нет. Вместо них Didit добавляет `X-Didit-Unsigned-Callback: true`. Проверяйте его, сопоставляя `request_id` с идентификатором, возвращённым в ответе на запрос поиска.
- Название события находится в `event_type` (а не в `webhook_type`), и полей `application_id` и `environment` нет.
- `timestamp` и `created_at` являются целыми числами в формате epoch, как и у других вебхуков, но в отличие от ISO-формата `created_at` у endpoint поиска.```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
}
```Для обратного вызова действует та же [политика повторных попыток](#политика-повторных-попыток), что и для вебхуков адреса назначения: при повторах `timestamp` обновляется, и подпись по-прежнему отсутствует. Полную схему списка кандидатов см. в [KYB Registry API](https://docs.didit.me/ru/standalone-apis/kyb-registry.md).## Проверка подписиКаждый webhook подписывается с помощью HMAC-SHA256 и `secret_shared_key` адреса назначения. Отправляются три заголовка подписи, чтобы вы могли выбрать тот, который работает в вашем стеке:| Заголовок | Что подписывается | Безопасен для middleware | Когда использовать |
| - | - | - | - |
| `X-Signature-V2` | Отсортированный компактный JSON с сохранением Unicode (`ensure_ascii=False`) | Да | **Рекомендуется**: работает, даже если ваш фреймворк перекодирует тело. |
| `X-Signature` | Точные исходные байты запроса, как Didit их передал (`sort_keys`, `ensure_ascii=True`) | Нет | Только если вы можете прочитать исходное тело до того, как его обработает любой парсер. |
| `X-Signature-Simple` **(устарел)** | `"{timestamp}:{session_id}:{status}:{webhook_type}"` | Да | Запасной вариант на крайний случай. Он аутентифицирует **только оболочку события**, а **не** `decision`. Перед использованием ознакомьтесь с уведомлением об устаревании ниже. |Заголовки, которые Didit всегда отправляет:| Заголовок | Пример | Значение |
| - | - | - |
| `Content-Type` | `application/json` | Тело запроса в формате JSON. |
| `User-Agent` | `DiditWebhook/2.0 +https://didit.me` | Идентифицирует воркер webhook Didit. |
| `X-Timestamp` | `1774970000` | Unix-время в секундах (epoch), когда webhook был отправлен. Отклоняйте запрос, если `abs(now - X-Timestamp) > 300`. |
| `X-Signature` / `X-Signature-V2` / `X-Signature-Simple` | шестнадцатеричные дайджесты (hex) | HMAC-SHA256 от тела или от канонической строки, описанной выше. |В течение 24 часов после ротации секрета подписи все доставки на этот адрес также содержат `X-Signature-Previous`, `X-Signature-V2-Previous` и `X-Signature-Simple-Previous` (см. [Ротация секрета подписи](#ротация-секрета-подписи)).### Ротация секрета подписиРотацию секрета подписи адреса назначения вы выполняете в Business Console.
В течение 24 часов после ротации каждая доставка на этот адрес содержит ещё три заголовка, подписанных заменённым секретом:| Заголовок | Та же подпись, что и | Подписан |
| - | - | - |
| `X-Signature-V2-Previous` | `X-Signature-V2` | Секретом, который был заменён |
| `X-Signature-Previous` | `X-Signature` | Секретом, который был заменён |
| `X-Signature-Simple-Previous` | `X-Signature-Simple` (устарел) | Секретом, который был заменён |Обработчик, который принимает совпадение либо по заголовку подписи, либо по его варианту с `-Previous`, продолжит проверку во время развёртывания нового секрета: старый секрет совпадает с заголовком `-Previous`, а новый совпадает с обычным заголовком.
Примеры кода ниже делают именно это.
Разверните новый секрет в течение 24 часов, потому что после этого проверку будет проходить только новый секрет.
Поле `previous_secret_expires_at` в ответе [`GET /v3/webhook/destinations/{destination_uuid}/`](https://docs.didit.me/ru/management-api/webhook/get-destination) показывает, когда заканчиваются эти 24 часа.Повторные попытки и повторные отправки подписываются заново текущим секретом адреса назначения в момент отправки и несут заголовки `-Previous` только в течение 24 часов.
Повторная ротация в течение 24 часов немедленно выводит из употребления самый старый секрет: через заголовки `-Previous` продолжает проверяться только тот секрет, который был текущим до новой ротации, и только в течение 24 часов от новой ротации.Заголовки `-Previous` существуют только для адресов назначения webhook.
Секрет `secret_shared_key` на уровне приложения, которым подписывается унаследованный `webhook_url`, не имеет периода перехода: его ротация с помощью `rotate_secret_key` в [`PATCH /v3/webhook/`](https://docs.didit.me/ru/sessions-api/management-api#настройка-webhook) сразу делает старый секрет недействительным.### `X-Signature-Simple` устарел> [!WARNING]
>
> **`X-Signature-Simple` аутентифицирует оболочку события, а не payload.** Его каноническая строка охватывает только `timestamp`, `session_id`, `status` и `webhook_type`. Она **не** охватывает `decision`, а также `resubmit_info`, `metadata`, `vendor_data` и `event_id`. Запрос, в котором `decision` переписан, а эти четыре поля не тронуты, по-прежнему проходит проверку `X-Signature-Simple`.
>
> Для событий, которые вообще не содержат `session_id`, защита ещё слабее. События транзакций (`transaction_id`, `txn_id`, `amount`, `score`) и события сущностей (`vendor_user_id`, `vendor_business_id`) подписываются строкой `"{timestamp}::{status}:{webhook_type}"`, которая не определяет, **к какой** транзакции или сущности относится событие. Две разные транзакции с одинаковым статусом в одну и ту же секунду получают одинаковый `X-Signature-Simple`.
> События `travel_rule.status.updated`, `workflow.compliance_changed` и `transaction_rule_suggestions.created` тоже не содержат `status`, поэтому подписываются строкой `"{timestamp}:::{webhook_type}"`, например `"{timestamp}:::travel_rule.status.updated"`.
>
> Если `X-Signature-Simple` — единственный заголовок, который вы проверяете, считайте всё, что находится вне этих четырёх полей, **недоверенным** и заново запрашивайте запись через API: [`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/ru/sessions-api/retrieve-session) для сессии, [`GET /v3/transactions/{transaction_id}/`](https://docs.didit.me/ru/management-api/transactions/get) для транзакции. Это авторитетный источник данных.Это ограничение совместимости, а не то, что мы можем исправить на месте. Каноническая строка из четырёх полей является контрактом на уровне протокола: каждый получатель, который проверяет `X-Signature-Simple`, пересчитывает именно эти четыре поля, поэтому изменение того, что подписывает заголовок, молча сломает проверку подписи у всех них. Покрытие тела также требует описанной канонической формы JSON, которую обе стороны воспроизводят побайтно, и Didit уже предоставляет её в виде `X-Signature-V2`. Вместо того чтобы добавлять второй вариант, покрывающий тело, со своими правилами канонизации, заголовок остаётся без изменений и устаревает в пользу существующих вариантов, которые подписывают весь payload.Что это означает для вас:- **На уровне передачи ничего не меняется.** `X-Signature-Simple` продолжает отправляться в каждой доставке и вычисляется так же. Существующие верификаторы продолжают работать, а об удалении мы сообщим заранее.
- **Новые интеграции должны использовать `X-Signature-V2`.** Он безопасен для middleware *и* покрывает весь payload.
- **Если вы не можете отказаться от `X-Signature-Simple`**, проверьте его, а затем запросите запись через API, а не читайте её из тела webhook.### Зачем три варианта?Некоторые веб-фреймворки (парсеры тела в Express, middleware в Django, API-шлюзы) незаметно перекодируют JSON до того, как ваш обработчик его прочитает. Если исходное тело содержало `"José"`, а ваш middleware переписывает его в `"José"`, байты меняются, хотя данные те же. Тогда `X-Signature` не проходит проверку. `X-Signature-V2` вычисляется из канонической формы JSON (отсортированные ключи, компактные разделители, **Unicode без экранирования**), которую почти каждый middleware воспроизводит детерминированно. Устаревший `X-Signature-Simple` служит запасным вариантом на случай, когда и это не срабатывает: он подписывает только короткую строку, не зависящую от парсера. Поскольку он подписывает только эту строку и никогда тело, он не может подтвердить тело.### Рекомендуемый порядок проверки1) Сначала проверьте **`X-Signature-V2`**. Пересоберите `JSON.parse(body)` с отсортированными ключами и сохранённым Unicode, затем вычислите `HMAC-SHA256(secret, canonical)` и сравните результат с заголовком через `timingSafeEqual`.
2) Если проверка V2 не прошла и вы можете читать необработанные байты, проверьте **`X-Signature`** по `HMAC-SHA256(secret, rawBody)`.
3) Только если обе проверки не прошли, используйте устаревший **`X-Signature-Simple`** с `HMAC-SHA256(secret, "{timestamp}:{session_id}:{status}:{webhook_type}")`. Этот вариант подтверждает только конверт: относитесь к `decision` в теле как к недоверенным данным и перед действием заново получите его через [`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/ru/sessions-api/retrieve-session).На каждом шаге, если присутствует парный заголовок `-Previous`, принимайте совпадение с любым из двух заголовков (см. [Ротация секрета подписи](#ротация-секрета-подписи)).Во всех случаях **отклоняйте** любой запрос, для которого `abs(now - X-Timestamp) > 300`, то есть если временная метка отличается от текущего времени более чем на **5 минут** (в том числе если она находится в будущем), чтобы защититься от повторной отправки.## Политика повторных попытокЕсли ваш endpoint отвечает кодом `5xx` или `404`, Didit выполняет до **2 повторных попыток** с экспоненциальной задержкой:- **1-я повторная попытка**: примерно через **1 минуту** после первоначального сбоя.
- **2-я повторная попытка**: примерно через **4 минуты** после 1-й повторной попытки.После второй повторной попытки доставка отбрасывается. Каждая попытка доставки (первоначальная и повторные) фиксируется в Business Console как отдельная запись на вкладке **Доставки** адреса назначения, поэтому вы можете повторить или проверить любую попытку.Каждая попытка в журнале доставок адреса назначения содержит номер `attempt`: `1` для первой доставки события, а для каждой повторной попытки или повторной отправки номер на единицу больше номера попытки, которую она повторяет.
Автоматические повторные попытки отправляются на тот URL, на который была отправлена первая попытка, даже если вы с тех пор изменили `url` адреса назначения.
Ручная повторная отправка идёт на текущий URL адреса назначения, поэтому исправление URL также исправляет повторную отправку.Другие правила доставки, которые нужно учитывать:- Исходящий HTTP-запрос прерывается по тайм-ауту через **5 секунд**.
- Didit блокирует доставку на частные адреса и localhost как защиту от SSRF и никогда не переходит по редиректам. Используйте публичный HTTPS endpoint, который отвечает напрямую.
- URL `url` адреса назначения, содержащий необработанный обратный слеш, управляющий символ или более одного `@` в компоненте authority, отклоняется как некорректный: такой URL нельзя создать, а существующий нельзя повторно доставить или воспроизвести. Используйте простой URL вида `https://host/path` с не более чем одним блоком userinfo (для URL вебхуков с basic-аутентификацией).
- Код `2xx` считается успехом. Тайм-ауты и ошибки соединения можно повторить (в журнале доставок они отображаются как `504` или `503`); ответы `3xx` и `4xx`, кроме `404`, **не** повторяются.
- При повторной попытке Didit пересчитывает подписи с новым `X-Timestamp`, поэтому пара «временная метка и подпись» всегда согласована.
- Повторные попытки и повторные отправки подписываются текущим секретом подписи адреса назначения на момент отправки.### Порядок доставкиDidit не гарантирует порядок, в котором webhook-и поступают к вам.
Каждая доставка ставится в очередь отдельно, а повторная попытка приходит через несколько минут после первой, поэтому `status.updated` со значением `Approved` может прийти раньше более раннего события со значением `In Progress`.Учтите это при разработке получателя:- Воспринимайте webhook как сигнал о том, что что-то изменилось. Если порядок важен, вызовите [`GET /v3/session/{session_id}/decision/`](https://docs.didit.me/ru/sessions-api/retrieve-session) и действуйте по текущему `status`.
- Никогда не позволяйте более старому событию перезаписать более новое состояние: сравнивайте `created_at` события с последним сохранённым для этой записи и игнорируйте более старое.
- Выявляйте повторы по `event_id`, который остаётся неизменным при повторных попытках.Didit ставит события перевода Travel Rule в очередь в определённом порядке, описанном в разделе [Изменение статуса обмена Travel Rule](#изменён-статус-обмена-по-travel-rule-travel_rule-status-updated), но повторная попытка всё же может изменить порядок их поступления.## Настройка адреса назначенияАдресами назначения можно управлять в **Business Console > API & Webhooks** или через Management API.**1. Создайте адрес назначения**Вызовите [`POST /v3/webhook/destinations/`](https://docs.didit.me/ru/management-api/webhook/create-destination) (или нажмите **Добавить адрес назначения** в консоли), передав `label`, публичный `url`, `webhook_version` (`"v3"`) и массив `subscribed_events`, в котором перечислены все семейства событий, которые должен получать этот endpoint. Требуется хотя бы одно событие, подстановочного знака не существует.**2. Сохраните secret\_shared\_key**Ответ на создание возвращает `secret_shared_key` адреса назначения. Храните его вместе с другими серверными секретами: он относится только к этому адресу назначения и служит входными данными для всех трёх вариантов подписи HMAC-SHA256. [`GET /v3/webhook/destinations/{destination_uuid}/`](https://docs.didit.me/ru/management-api/webhook/get-destination) возвращает его снова для вызовов с API-ключом и для пользователей консоли, которые могут читать webhook-и.**3. Реализуйте endpoint**Откройте публичный HTTPS-маршрут `POST`, прочитайте необработанное тело, проверьте подпись (предпочтительно `X-Signature-V2`), проверьте окно временной метки, затем выполните диспетчеризацию по `webhook_type`. Верните `2xx`, как только поставили работу в очередь, а тяжёлую обработку выполняйте асинхронно.**4. Подтвердите получение**Следите за вкладкой **Доставки** адреса назначения. Исправные webhook-и возвращают `200` в течение секунды или двух. Ответы `5xx` и `404` запускают политику повторных попыток Didit; устойчивые сбои отбрасываются после второй повторной попытки.**5. Проверяйте с помощью Try Webhook**Используйте сценарии [**Try Webhook**](#тестирование) в консоли, чтобы отправить на свой endpoint полезные нагрузки со статусами «одобрена», «отклонена» и «на проверке», а также для KYB, сущностей, активности и транзакций, прежде чем включить продакшн-трафик.![Функция тестирования webhook Didit в 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)Отправляйте тестовые webhook прямо из Business Console, чтобы проверить свой endpoint перед запуском.Можно создать столько адресов назначения, сколько нужно. Распространённый подход: **один адрес назначения на каждого потребителя**, например события KYC отправляются в ваш сервис авторизации, события KYB в сервис комплаенс-операций, транзакции в очередь антифрод-проверок. У каждого адреса назначения свой секрет, версия и список подписок, поэтому ротация секрета или неудачный деплой у одного потребителя не влияет на остальных.## Code samplesЧтобы защитить endpoint для webhook, проверяйте подлинность входящих запросов с помощью `secret_shared_key` адреса назначения.> [!WARNING]
>
> **Используйте `X-Signature-V2`.** Это вариант, наиболее устойчивый к повторному кодированию в промежуточном ПО, и он полностью подтверждает подлинность тела запроса (включая `decision`).### Node.js / Express (рекомендуется: X-Signature-V2)```javascript theme={null}
const express = require("express");
const crypto = require("crypto");

const app = express();
const PORT = process.env.PORT || 1337;

// Per-destination secret from POST /v3/webhook/destinations/
const WEBHOOK_SECRET_KEY = process.env.WEBHOOK_SECRET_KEY || "YOUR_WEBHOOK_SECRET_KEY";

app.use(express.json());

/**
 * Match Didit's float normalisation: whole-valued floats are serialised as ints.
 */
function shortenFloats(data) {
  if (Array.isArray(data)) return data.map(shortenFloats);
  if (data !== null && typeof data === "object") {
    return Object.fromEntries(
      Object.entries(data).map(([key, value]) => [key, shortenFloats(value)])
    );
  }
  if (typeof data === "number" && !Number.isInteger(data) && data % 1 === 0) {
    return Math.trunc(data);
  }
  return data;
}

/** Sort object keys recursively before re-stringifying. */
function sortKeys(obj) {
  if (Array.isArray(obj)) return obj.map(sortKeys);
  if (obj !== null && typeof obj === "object") {
    return Object.keys(obj).sort().reduce((acc, key) => {
      acc[key] = sortKeys(obj[key]);
      return acc;
    }, {});
  }
  return obj;
}

function verifySignatureV2(jsonBody, signatureHeader, timestampHeader, secret) {
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestampHeader, 10)) > 300) return false;

  // Reproduce Didit's canonical JSON: sorted keys, compact, Unicode preserved.
  const canonical = JSON.stringify(sortKeys(shortenFloats(jsonBody)));
  const expected = crypto.createHmac("sha256", secret).update(canonical, "utf8").digest("hex");

  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(signatureHeader, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

function verifySignatureSimple(jsonBody, signatureHeader, timestampHeader, secret) {
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestampHeader, 10)) > 300) return false;

  const canonical = [
    jsonBody.timestamp ?? "",
    jsonBody.session_id ?? "",
    jsonBody.status ?? "",
    jsonBody.webhook_type ?? "",
  ].join(":");
  const expected = crypto.createHmac("sha256", secret).update(canonical).digest("hex");

  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(signatureHeader, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

/**
 * The signature header and, for 24 hours after the destination's secret is
 * rotated, its "-Previous" header (the same signature made with the replaced secret).
 */
function signaturesFor(req, header) {
  return [req.get(header), req.get(`${header}-Previous`)].filter(Boolean);
}

app.post("/webhook", (req, res) => {
  const timestamp = req.get("X-Timestamp");
  const body = req.body;

  if (!timestamp || !WEBHOOK_SECRET_KEY) {
    return res.status(401).json({ message: "Missing required headers" });
  }

  // A match on either header is enough.
  const matches = (header, verify) =>
    signaturesFor(req, header).some((sig) => verify(body, sig, timestamp, WEBHOOK_SECRET_KEY));

  let verified = false;
  if (matches("X-Signature-V2", verifySignatureV2)) {
    verified = true;
  } else if (matches("X-Signature-Simple", verifySignatureSimple)) {
    // Deprecated fallback: Simple authenticates the envelope only. Do not trust
    // body.decision here — re-fetch it from GET /v3/session/{session_id}/decision/.
    verified = true;
  }
  if (!verified) return res.status(401).json({ message: "Invalid signature" });

  // Dispatch by event type. Return 2xx fast; do heavy work asynchronously.
  switch (body.webhook_type) {
    case "status.updated":
    case "data.updated":
      // session or business session
      break;
    case "user.status.updated":
    case "user.data.updated":
    case "business.status.updated":
    case "business.data.updated":
      // consolidated entity event
      break;
    case "activity.created":
      // audit timeline event
      break;
    case "transaction.created":
    case "transaction.status.updated":
      // transaction monitoring event
      break;
    case "travel_rule.status.updated":
      // travel rule exchange status event
      break;
  }
  return res.status(200).json({ ok: true });
});

app.listen(PORT, () => console.log(`Server on http://localhost:${PORT}`));
```Если вы можете читать необработанные байты до того, как их обработает какой-либо парсер, исходный вариант `X-Signature` работает так же, но подписывает необработанное тело. Мы оставили его в разделе устаревших форматов ниже для полноты.### Python / FastAPI (рекомендуется: X-Signature-V2)```python theme={null}
from fastapi import FastAPI, Request, HTTPException
from time import time
import json
import hmac
import hashlib
import os

app = FastAPI()


def shorten_floats(data):
    """Match Didit: whole-valued floats serialise as ints."""
    if isinstance(data, dict):
        return {k: shorten_floats(v) for k, v in data.items()}
    if isinstance(data, list):
        return [shorten_floats(x) for x in data]
    if isinstance(data, float) and data.is_integer():
        return int(data)
    return data


def verify_signature_v2(body_json, signature_header, timestamp_header, secret):
    if abs(int(time()) - int(timestamp_header)) > 300:
        return False
    canonical = json.dumps(
        shorten_floats(body_json),
        sort_keys=True,
        separators=(",", ":"),
        ensure_ascii=False,   # unescaped Unicode -- matches Didit's V2 canonical form
    )
    expected = hmac.new(secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature_header, expected)


def verify_signature_simple(body_json, signature_header, timestamp_header, secret):
    if abs(int(time()) - int(timestamp_header)) > 300:
        return False
    canonical = ":".join([
        str(body_json.get("timestamp", "")),
        str(body_json.get("session_id", "")),
        str(body_json.get("status", "")),
        str(body_json.get("webhook_type", "")),
    ])
    expected = hmac.new(secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature_header, expected)


def signatures(request, header):
    """The signature header and, for 24 hours after the destination's secret is
    rotated, its -Previous header (the same signature made with the replaced secret)."""
    return [sig for sig in (request.headers.get(header), request.headers.get(f"{header}-previous")) if sig]


@app.post("/webhook")
async def handle_webhook(request: Request):
    body = json.loads((await request.body()).decode("utf-8"))
    secret = os.environ["WEBHOOK_SECRET_KEY"]

    ts = request.headers.get("x-timestamp")
    if not ts:
        raise HTTPException(status_code=401, detail="Missing X-Timestamp")

    # A match on either header is enough.
    if any(verify_signature_v2(body, sig, ts, secret) for sig in signatures(request, "x-signature-v2")):
        pass
    elif any(verify_signature_simple(body, sig, ts, secret) for sig in signatures(request, "x-signature-simple")):
        # Deprecated fallback: Simple authenticates the envelope only. Do not trust
        # body["decision"] here - re-fetch it from GET /v3/session/{session_id}/decision/.
        pass
    else:
        raise HTTPException(status_code=401, detail="Invalid signature")

    # Dispatch on body["webhook_type"]; return 2xx fast.
    return {"ok": True}
```### PHP / Laravel (рекомендуется: X-Signature-V2)Настройки JSON в PHP по умолчанию **не** воспроизводят каноническую форму. Четыре из них нужно переопределить, и если пропустить хотя бы одну, подписываемые байты изменятся, и дайджест никогда не совпадёт:| Значение по умолчанию в PHP | Что выводится | Что нужно |
| - | - | - |
| `json_decode($raw, true)` (ассоциативный массив) | пустые объекты `{}` превращаются в `[]` | декодировать с `$associative = false`, чтобы JSON-объекты оставались `stdClass` |
| `json_encode` экранирует косые черты | `https:\/\/media.didit.me` | `JSON_UNESCAPED_SLASHES` |
| `json_encode` экранирует не-ASCII символы | `Jos\u00e9` вместо `José` | `JSON_UNESCAPED_UNICODE` |
| `ksort` сравнивает ключи, похожие на числа, как числа | `"9"` идёт перед `"10"` | `ksort($props, SORT_STRING)` |Ловушка с пустыми объектами самая коварная. В payload Didit регулярно встречаются пустые словари, например `risk_view.countries.risk_scores`, `risk_view.crimes.risk_scores` и `risk_view.custom_list`, поэтому преобразование туда и обратно с использованием ассоциативного массива ломает подпись на обычных одобренных сессиях, а не в каком-то редком граничном случае.```php theme={null}
<?php

use Illuminate\Http\Request;

class WebhookController extends Controller
{
    /**
     * Rebuild Didit's canonical JSON form: recursively sorted keys, JSON
     * objects preserved as objects, arrays left in order.
     */
    private function canonicalize($value)
    {
        if ($value instanceof \stdClass) {
            $props = get_object_vars($value);
            ksort($props, SORT_STRING); // code-point order, like Python's sort_keys
            $sorted = new \stdClass();
            foreach ($props as $key => $item) {
                $sorted->{$key} = $this->canonicalize($item);
            }
            return $sorted;
        }
        if (is_array($value)) { // JSON arrays keep their order
            return array_map(function ($item) { return $this->canonicalize($item); }, $value);
        }
        return $value;
    }

    private function verifySignatureV2(string $rawBody, string $sig, string $ts, string $secret): bool
    {
        if (abs(time() - (int) $ts) > 300) return false;

        // Decode to objects, not associative arrays: associative decoding turns
        // empty JSON objects ({}) into empty arrays ([]), and json_encode then
        // writes them back as [] -- different bytes than Didit signed.
        $decoded = json_decode($rawBody, false);
        if (json_last_error() !== JSON_ERROR_NONE) return false;

        $canonical = json_encode(
            $this->canonicalize($decoded),
            JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
        );
        return hash_equals(hash_hmac('sha256', $canonical, $secret), $sig);
    }

    /** X-Signature: HMAC over the exact bytes received -- never a re-serialised copy. */
    private function verifySignatureRaw(string $rawBody, string $sig, string $ts, string $secret): bool
    {
        if (abs(time() - (int) $ts) > 300) return false;
        return hash_equals(hash_hmac('sha256', $rawBody, $secret), $sig);
    }

    private function verifySignatureSimple(?array $body, string $sig, string $ts, string $secret): bool
    {
        if (abs(time() - (int) $ts) > 300) return false;
        $canonical = implode(':', [
            $body['timestamp'] ?? '',
            $body['session_id'] ?? '',
            $body['status'] ?? '',
            $body['webhook_type'] ?? '',
        ]);
        return hash_equals(hash_hmac('sha256', $canonical, $secret), $sig);
    }

    public function handle(Request $request)
    {
        $secret = env('WEBHOOK_SECRET_KEY');
        $ts = $request->header('x-timestamp');
        if (!$ts || !$secret) {
            return response()->json(['message' => 'Missing required headers'], 401);
        }

        // getContent() hands you the exact bytes Didit transmitted, before any parser runs.
        $rawBody = $request->getContent();
        $body = json_decode($rawBody, true); // associative copy, for dispatch only

        // Check the signature header and, for 24 hours after the destination's secret is
        // rotated, its -Previous header (the same signature made with the replaced secret).
        // A match on either header is enough.
        $matches = function (string $header, callable $verify) use ($request): bool {
            foreach ([$header, $header . '-previous'] as $name) {
                $sig = $request->header($name);
                if ($sig && $verify($sig)) {
                    return true;
                }
            }
            return false;
        };

        $verified = $matches('x-signature-v2', fn ($sig) => $this->verifySignatureV2($rawBody, $sig, $ts, $secret))
            || $matches('x-signature', fn ($sig) => $this->verifySignatureRaw($rawBody, $sig, $ts, $secret))
            // Deprecated fallback: Simple authenticates the envelope only. Do not trust
            // the decision body here - re-fetch it from GET /v3/session/{session_id}/decision/.
            || $matches('x-signature-simple', fn ($sig) => $this->verifySignatureSimple($body, $sig, $ts, $secret));
        if (!$verified) {
            return response()->json(['message' => 'Invalid signature'], 401);
        }

        // Dispatch on $body['webhook_type']; return 2xx fast.
        return response()->json(['ok' => true]);
    }
}
```Есть ещё два момента, которые стоит знать:- **Числа с плавающей точкой с нулевой дробной частью не требуют особой обработки в PHP.** Didit сериализует их как целые числа (`100.0` → `100`), а `json_encode` в PHP записывает такое число с плавающей точкой так же, поэтому шаг `shortenFloats`, который нужен в приведённом выше примере для Node.js, здесь не требуется.
- **Оставьте `serialize_precision` равным `-1`** (значение по умолчанию начиная с PHP 7.1). Если ваш хостинг переопределяет его на `17`, `json_encode` запишет `87.42` как `87.420000000000002`, и каждая проверка `X-Signature-V2` будет завершаться ошибкой.Если не удаётся добиться совпадения с канонической формой, более простой запасной вариант — `verifySignatureRaw` выше: `$request->getContent()` в Laravel возвращает нетронутые байты, поэтому `X-Signature` работает, пока ничто перед PHP не выполняет повторное кодирование тела. Именно этот случай и призван выдерживать `X-Signature-V2`, поэтому V2 проверяется первым.## ТестированиеДля проверки endpoint **не** нужно проводить полную верификацию.- В **Business Console > API & Webhooks > Try Webhook** выберите сценарий (например, `approved_full_features`, `declined_face_mismatch`, `in_review_aml_hit`, `business_session_approved`, `transaction_created`, `activity_created`, `user_status_updated`) и отправьте полностью сформированный webhook на URL вашего адреса назначения.
- Каждый сценарий подписывается теми же тремя заголовками подписи и тем же каноническим кодированием, что и боевой трафик, включая тестовые данные с Unicode-символами (`approved_kyc_with_unicode`), поэтому вы можете убедиться, что `X-Signature-V2` работает через ваше промежуточное ПО. Тестовые доставки содержат примерные данные и помечаются дополнительным заголовком `X-Didit-Test-Webhook: true` и суффиксом `(Test)` в `User-Agent`, чтобы они не попадали в продакшн-состояние.
- Если нужно отладить регрессию, повторите любую историческую доставку из вкладки **Доставки** адреса назначения.Для сквозных дымовых тестов проведите реальную верификацию через тестовый сценарий в своём приложении: создаваемые в результате webhook используют ту же структуру, заголовки и политику повторных попыток, что и сценарии **Try Webhook**.## Examples### Одобренная сессия KYC (`status.updated`)```json theme={null}
{
  "event_id": "9c0c8b8a-1111-4222-9333-444444444444",
  "webhook_type": "status.updated",
  "timestamp": 1774970000,
  "created_at": 1774969994,
  "application_id": "11111111-2222-3333-4444-555555555555",
  "environment": "live",
  "sandbox_scenario": null,
  "session_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
  "status": "Approved",
  "workflow_id": "66666666-7777-8888-9999-000000000000",
  "workflow_version": 4,
  "vendor_data": "user_42",
  "metadata": { "tier": "premium" },
  "decision": {
    "session_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
    "session_number": 43762,
    "status": "Approved",
    "workflow_id": "66666666-7777-8888-9999-000000000000",
    "features": ["ID_VERIFICATION", "LIVENESS", "FACE_MATCH", "AML"],
    "vendor_data": "user_42",
    "id_verifications": [
      {
        "node_id": "id_verification_1",
        "status": "Approved",
        "document_type": "Identity Card",
        "document_number": "SAMPLE-DOC-12345",
        "first_name": "Jane",
        "last_name": "Doe",
        "date_of_birth": "1990-01-01",
        "issuing_state": "ESP",
        "verification_method": "document",
        "assurance": "documentary",
        "wallet_provider": null,
        "fallback_from": null,
        "id_lookup": null,
        "wallet_verification": null,
        "warnings": []
      }
    ],
    "liveness_checks": [
      { "node_id": "liveness_1", "status": "Approved", "method": "ACTIVE_3D", "score": 95.4, "warnings": [] }
    ],
    "face_matches": [
      { "node_id": "face_match_1", "status": "Approved", "score": 96.1, "warnings": [] }
    ],
    "aml_screenings": [
      { "node_id": "aml_1", "status": "Approved", "total_hits": 0, "entity_type": "person", "hits": [], "warnings": [] }
    ],
    "reviews": [],
    "created_at": "2026-05-17T08:54:25.443172Z"
  }
}
```### Отклонённая сессия с предупреждениями (`status.updated`)```json theme={null}
{
  "event_id": "9c0c8b8a-1111-4222-9333-555555555555",
  "webhook_type": "status.updated",
  "timestamp": 1774970000,
  "created_at": 1774969994,
  "application_id": "11111111-2222-3333-4444-555555555555",
  "environment": "live",
  "sandbox_scenario": null,
  "session_id": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
  "status": "Declined",
  "vendor_data": "user_99",
  "decision": {
    "session_id": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
    "status": "Declined",
    "features": ["ID_VERIFICATION", "LIVENESS", "FACE_MATCH"],
    "id_verifications": [
      {
        "node_id": "id_verification_1",
        "status": "Declined",
        "document_type": "Passport",
        "expiration_date": "2024-01-01",
        "warnings": [
          {
            "feature": "ID_VERIFICATION",
            "risk": "DOCUMENT_EXPIRED",
            "additional_data": null,
            "log_type": "error",
            "short_description": "Document expired",
            "long_description": "The document's expiration date has passed, rendering it no longer valid for use.",
            "node_id": "id_verification_1"
          }
        ]
      }
    ],
    "liveness_checks": [{ "node_id": "liveness_1", "status": "Approved", "score": 92.0, "warnings": [] }],
    "face_matches": [
      {
        "node_id": "face_match_1",
        "status": "Declined",
        "score": 32.0,
        "warnings": [
          {
            "feature": "FACEMATCH",
            "risk": "LOW_FACE_MATCH_SIMILARITY",
            "additional_data": null,
            "log_type": "error",
            "short_description": "Low face match similarity",
            "long_description": "The facial features of the provided image don't closely match the reference image, suggesting a potential identity mismatch.",
            "node_id": "face_match_1"
          }
        ]
      }
    ],
    "reviews": []
  }
}
```### Одобренная бизнес-сессия (`status.updated`, KYB)```json theme={null}
{
  "event_id": "uuid",
  "webhook_type": "status.updated",
  "timestamp": 1774970000,
  "created_at": 1774969994,
  "application_id": "uuid",
  "environment": "live",
  "sandbox_scenario": null,
  "session_id": "kyb-uuid",
  "business_session_id": "kyb-uuid",
  "session_kind": "business",
  "status": "Approved",
  "vendor_data": "biz_7",
  "vendor_business_id": "acme-inc",
  "decision": {
    "session_id": "kyb-uuid",
    "session_kind": "business",
    "status": "Approved",
    "features": ["REGISTRY_CHECK", "AML", "DOCUMENT_VERIFICATION"],
    "registry_checks": [{ "node_id": "registry_1", "status": "Approved", "company_name": "Acme Corp", "...": "..." }],
    "aml_screenings": [{ "node_id": "aml_1", "status": "Approved", "entity_type": "company", "total_hits": 0, "hits": [] }],
    "document_verifications": [{ "node_id": "doc_1", "status": "Approved", "document_type": "certificate_of_incorporation" }],
    "key_people_checks": [],
    "reviews": []
  }
}
```### Создана транзакция (`transaction.created`)```json theme={null}
{
  "event_id": "uuid",
  "webhook_type": "transaction.created",
  "timestamp": 1774970000,
  "created_at": 1774970000,
  "application_id": "uuid",
  "environment": "live",
  "transaction_id": "uuid",
  "txn_id": "finance0001",
  "status": "APPROVED",
  "score": 0,
  "severity": "UNKNOWN",
  "amount": "1200.00",
  "currency": "EUR",
  "direction": "OUTBOUND",
  "action_required": null
}
```### Изменён статус обмена по Travel Rule (`travel_rule.status.updated`)```json theme={null}
{
  "event_id": "9c0c8b8a-1111-4222-9333-666666666666",
  "webhook_type": "travel_rule.status.updated",
  "timestamp": 1774970000,
  "created_at": 1774970000,
  "application_id": "11111111-2222-3333-4444-555555555555",
  "environment": "live",
  "transaction_id": "22222222-3333-4444-5555-666666666666",
  "txn_id": "trv-2026-07-05-001",
  "travel_rule_status": "COMPLETED",
  "rail": "INTERNAL",
  "direction": "OUTBOUND"
}
````travel_rule_status` принимает одно из 13 статусов обмена, описанных в разделе [Travel Rule](https://docs.didit.me/ru/transaction-monitoring/travel-rule.md#lifecycle).
`rail` принимает значения `INTERNAL`, `TRP`, `GTR`, `TRUST`, `VERIFYVASP`, `SYGNA` или `EMAIL` (пустая строка, пока не выбран канал или если канал не удалось определить, а также всегда для регистраций по схеме sunrise), либо `TEST_VASP` для тестового перевода в песочнице, на который ответил [Didit Test VASP](https://docs.didit.me/ru/transaction-monitoring/travel-rule-sandbox.md). `direction` принимает значения `INBOUND` или `OUTBOUND`.
Событие из песочницы содержит `"environment": "sandbox"`, а событие `TEST_VASP` также содержит `test_vasp: true` и `test_vasp_scenario` (сценарий, по которому был получен ответ).Когда Didit создаёт транзакцию для перевода по Travel Rule (вызов [`POST /v3/transactions/`](https://docs.didit.me/ru/management-api/transactions/create), [регистрация по схеме sunrise после депозита](https://docs.didit.me/ru/transaction-monitoring/travel-rule.md#регистрация-депозита-задним-числом), входящий запрос `TRP` или входящая часть при канале `INTERNAL`), сначала отправляется событие `transaction.created` для этой транзакции, а после него события `travel_rule.status.updated` для перевода.
Правило, которое меняет статус транзакции на этом шаге, может также отправить `transaction.status.updated` до `transaction.created`. Регистрация по схеме sunrise, которая привязывается к уже созданной вами транзакции депозита, событие `transaction.created` не отправляет.
Каждый webhook доставляется и повторяется отдельно, поэтому повторно отправленное событие может прийти после более позднего. Ключом обработчика сделайте `transaction_id`, а транзакцию перечитывайте, когда нужно получить её текущее состояние.
Перевод, отправленный через TRUST, никогда не получает событие `COMPLETED`, потому что TRUST не возвращает ничего, что Didit может прочитать: перевод остаётся в статусе `AWAITING_COUNTERPARTY` до истечения срока подтверждения, после чего завершается со статусом `EXPIRED`. Через GTR, VerifyVASP и Sygna Bridge Didit получает ответ контрагента из сети, и событие отправляется после него (см. [сети Travel Rule](https://docs.didit.me/ru/transaction-monitoring/travel-rule-networks.md)).### Изменено соответствие сценария требованиям (`workflow.compliance_changed`)```json theme={null}
{
  "event_id": "9c0c8b8a-1111-4222-9333-777777777777",
  "webhook_type": "workflow.compliance_changed",
  "timestamp": 1774970000,
  "created_at": 1774970000,
  "application_id": "11111111-2222-3333-4444-555555555555",
  "environment": "live",
  "workflow_id": "22222222-3333-4444-5555-666666666666",
  "workflow_version": 4,
  "workflow_label": "KYC standard",
  "transition": { "from": "compliant", "to": "gaps_detected" },
  "severity": "actionable",
  "kb_version": "2026-10"
}
````transition.from` и `transition.to` принимают одно из значений: `compliant`, `gaps_detected`, `pending_change`, `unknown_coverage` или `not_evaluated`.
`severity` равен `actionable`, когда обнаружены пробелы, `indicative` при ожидающемся изменении и `informative`, когда пробелы устранены.
Didit отправляет одно событие для каждого сценария, в котором произошли изменения.### Созданы предложения по правилам транзакций (`transaction_rule_suggestions.created`)```json theme={null}
{
  "event_id": "9c0c8b8a-1111-4222-9333-888888888888",
  "webhook_type": "transaction_rule_suggestions.created",
  "timestamp": 1774970000,
  "created_at": 1774970000,
  "application_id": "11111111-2222-3333-4444-555555555555",
  "environment": "live",
  "run_id": "33333333-4444-5555-6666-777777777777",
  "open_count": 3,
  "suggestions": [
    {
      "suggestion_id": "44444444-5555-6666-7777-888888888888",
      "kind": "TWEAK",
      "title": "Raise the review threshold on high-value transfers",
      "rule_id": "55555555-6666-7777-8888-999999999999",
      "expected_impact": {}
    }
  ]
}
````kind` равен `ADD` для нового правила, `TWEAK` для изменения существующего правила и `DISABLE` для его отключения. `rule_id` равен `null` для `ADD`.
`open_count` обозначает количество предложений, которые остаются открытыми после запуска, включая новые.
Настройки предложений по правилам включают или отключают этот webhook; см. [webhooks транзакций](https://docs.didit.me/ru/transaction-monitoring/webhooks.md).### События Investigator (`investigator.*`)Двенадцать событий `investigator.*` используют тот же конверт (`event_id`, `webhook_type`, `timestamp`, `created_at`, `application_id`, `environment`) и те же заголовки подписи, что и все остальные события. Они содержат только идентификаторы и числовые показатели: никогда имя, причину, которую проверяющий ввёл вручную, описание или любой текст, написанный конечным пользователем. У них нет `session_id`; объект, к которому относится событие, указан в `item`.```json theme={null}
{
  "event_id": "9c0c8b8a-1111-4222-9333-777777777777",
  "webhook_type": "investigator.decision.automated",
  "timestamp": 1774970000,
  "created_at": 1774970000,
  "application_id": "11111111-2222-3333-4444-555555555555",
  "environment": "live",
  "run_id": "33333333-4444-5555-6666-777777777777",
  "queue": "kyc",
  "item": { "kind": "session", "id": "cccccccc-dddd-eeee-ffff-000000000000" },
  "recommendation": "approve",
  "confidence": 0.93,
  "provenance": {
    "agent_version": "1",
    "engine": "a1b2c3d4",
    "policy_version": 4,
    "model_id": "a1b2c3d4"
  },
  "decided_by": "investigator",
  "outcome": "approved",
  "action": "approve",
  "final_status": "Approved",
  "agreed": true
}
```| Событие | Поля помимо конверта |
| - | - |
| `investigator.report.ready` | `run_id`, `queue`, `item`, `recommendation`, `confidence`, `provenance`, `ready_to_accept`, `questions` (количество) |
| `investigator.decision.automated` | `run_id`, `queue`, `item`, `recommendation`, `confidence`, `provenance`, `decided_by` (`"investigator"`), `outcome`, `action`, `final_status`, `agreed` |
| `investigator.decision.overridden` | То же, что у `investigator.decision.automated`, но с `decided_by` равным `"human"`, плюс `override_category`, `overrode` (`"report"` или `"automated"`) и, когда `overrode` равен `"automated"`, `previous_status` |
| `investigator.decision.contested` | `run_id`, `queue`, `item`, `recommendation`, `confidence`, `provenance`, `case_id`, `contested_at` |
| `investigator.question.raised` | `question_id`, `number`, `queue`, `situation`, `situation_label`, `origin`, `rule_action`, `run_id`, `item` |
| `investigator.automation.paused` | `reason` (`"manual"` или `"spend_limit"`); для `"spend_limit"` также `month` (`YYYY-MM`) и `currency` |
| `investigator.rfi.answered` | `request_id`, `run_id`, `queue`, `item`, `status`, `deadline_at`, `answered_by`, `answered_at` |
| `investigator.rfi.overdue` | `request_id`, `run_id`, `queue`, `item`, `status`, `deadline_at` |
| `investigator.drift.detected` | `queue`, `reasons`, `recent_agreement`, `baseline_agreement`, `recent_decisions`, `baseline_decisions`, `drop_points`, `psi`, `from_tier`, `to_tier` |
| `investigator.sla.breached` | `run_id`, `queue`, `item`, `priority`, `sla_due_at` |
| `investigator.qa.failed` | `qa_id`, `run_id`, `queue`, `item`, `kind`, `overall`, `scores`, `sop_complies`, `missed_risks` (коды рисков), `action`, `decided_at` |
| `investigator.external.report.ready` | `item_id`, `external_id`, `queue` (`"external"`), `kind`, `status`, `recommendation`, `confidence`, `policy_action`, `provenance` (`engine`, `agent_version`, `policy_version`, `model_id`) |`provenance` показывает, какая версия Investigator написала отчёт:- `engine` обозначает версию движка Investigator: непрозрачный идентификатор из восьми символов. Он меняется при каждом изменении движка, поэтому два отчёта с одинаковым `engine` написаны одним и тем же движком.
- `agent_version` обозначает версию агента.
- `policy_version` обозначает версию вашей политики, которой следовал отчёт.
- `model_id` обозначает устаревший псевдоним, который содержит то же значение, что и `engine`. Читайте `engine`.
- `prompt_hash` больше не отправляется.`item.kind` указывает тип объекта (сессия, бизнес-сессия, транзакция или кейс), а `item.id` его идентификатор. Временные метки с суффиксом `_at` представляют собой строки в формате ISO 8601. Чтобы подписаться на эти события, перечислите их точные названия в `subscribed_events`, как и любые другие события.### Изменён статус сущности пользователя (`user.status.updated`)```json theme={null}
{
  "event_id": "uuid",
  "webhook_type": "user.status.updated",
  "timestamp": 1774970000,
  "created_at": 1774969980,
  "application_id": "uuid",
  "environment": "live",
  "vendor_user_id": "uuid",
  "vendor_data": "user_42",
  "status": "BLOCKED",
  "previous_status": "ACTIVE"
}
```### Resubmitted session```json theme={null}
{
  "event_id": "uuid",
  "webhook_type": "status.updated",
  "timestamp": 1774970000,
  "created_at": 1774969980,
  "application_id": "uuid",
  "environment": "live",
  "sandbox_scenario": null,
  "session_id": "uuid",
  "status": "Resubmitted",
  "vendor_data": "user_42",
  "resubmit_info": {
    "nodes_to_resubmit": [
      { "node_id": "feature_poa", "feature": "PROOF_OF_ADDRESS" }
    ],
    "reasons": { "feature_poa": "Poor document quality" }
  }
}
```## Устаревший способ: X-Signature с исходным телом запроса`X-Signature` по-прежнему отправляется с каждой доставкой. Это HMAC-SHA256 от **точных байтов**, которые Didit передаёт (`json.dumps(..., sort_keys=True, separators=(",", ":"))`). Используйте его, только если вы можете прочитать исходное тело запроса до того, как любое промежуточное ПО его изменит.> [tab] Node.js
>
> ```javascript theme={null}
> const express = require("express");
> const bodyParser = require("body-parser");
> const crypto = require("crypto");
>
> const app = express();
> const PORT = process.env.PORT || 1337;
> const WEBHOOK_SECRET_KEY = process.env.WEBHOOK_SECRET_KEY || "YOUR_WEBHOOK_SECRET_KEY";
>
> app.use(bodyParser.json({
>   verify: (req, _res, buf, encoding) => {
>     if (buf && buf.length) req.rawBody = buf.toString(encoding || "utf8");
>   },
> }));
>
> app.post("/webhook", (req, res) => {
>   const signature = req.get("X-Signature");
>   const timestamp = req.get("X-Timestamp");
>   if (!signature || !timestamp || !req.rawBody) return res.status(401).end();
>
>   if (Math.abs(Math.floor(Date.now() / 1000) - parseInt(timestamp, 10)) > 300) {
>     return res.status(401).json({ message: "Stale timestamp" });
>   }
>
>   const expected = crypto.createHmac("sha256", WEBHOOK_SECRET_KEY).update(req.rawBody).digest("hex");
>   const a = Buffer.from(expected, "utf8");
>   const b = Buffer.from(signature, "utf8");
>   if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
>     return res.status(401).json({ message: "Invalid signature" });
>   }
>
>   const event = JSON.parse(req.rawBody);
>   // Handle event.webhook_type ...
>   return res.json({ ok: true });
> });
>
> app.listen(PORT);
> ```> [tab] Python
>
> ```python theme={null}
> from fastapi import FastAPI, Request, HTTPException
> from time import time
> import hmac
> import hashlib
> import os
>
> app = FastAPI()
>
>
> def verify_raw(body: str, signature: str, timestamp: str, secret: str) -> bool:
>     if abs(int(time()) - int(timestamp)) > 300:
>         return False
>     expected = hmac.new(secret.encode("utf-8"), body.encode("utf-8"), hashlib.sha256).hexdigest()
>     return hmac.compare_digest(signature, expected)
>
>
> @app.post("/webhook")
> async def handle(request: Request):
>     body = (await request.body()).decode("utf-8")
>     sig = request.headers.get("x-signature")
>     ts = request.headers.get("x-timestamp")
>     secret = os.environ["WEBHOOK_SECRET_KEY"]
>
>     if not sig or not ts or not verify_raw(body, sig, ts, secret):
>         raise HTTPException(status_code=401, detail="Unauthorized")
>     return {"ok": True}
> ```## Устаревший способ: формат webhook V2> [!WARNING]
>
> Поля ниже применяются только тогда, когда адрес назначения закреплён за `webhook_version: "v2"`. Существует также более старая привязка `"v1"` (третья форма решения с полями в единственном числе) для адресов назначения, созданных до V2. Перед использованием обратитесь в поддержку. Новые интеграции должны использовать **V3** (по умолчанию), которая описана выше и использует массивы во множественном числе.| Аспект | V2 | V3 |
| - | - | - |
| Заголовки подписи | `X-Signature`, `X-Signature-V2`, `X-Signature-Simple` (все три отправляются независимо от версии; Simple [устарел](#x-signature-simple-устарел)) | То же самое |
| Имена полей решения | Объекты в единственном числе (`id_verification`, `liveness`, `face_match`, ...) | Массивы во множественном числе (`id_verifications`, `liveness_checks`, `face_matches`, ...) |
| Несколько экземпляров функции | Не поддерживается | Поддерживается через отдельные массивы для каждой функции с `node_id` |
| Оценки AML | `aml.score`, `aml.hits[].match_score`, `aml.hits[].risk_score` | Те же значения в `aml_screenings[]` |Объекты для отдельных функций одинаковы в обеих версиях: адрес назначения V2 получает те же поля `match_score`, `risk_score`, `score_breakdown`, `risk_view` и `review_status` для каждого совпадения, а также агрегированный `score`, как и V3 (см. [отчёт AML](https://docs.didit.me/ru/core-technology/aml-screening/report-aml-screening.md#три-оценки-а-не-один-вердикт)). Закрепление за V2 меняет место отчёта AML в `decision`, но не его содержимое.Если потребителю на стороне получателя по-прежнему нужны структуры V2, создайте отдельный адрес назначения, закреплённый за `webhook_version: "v2"`, а новых потребителей оставьте на V3.## Связанные материалы- [API адресов назначения webhook](https://docs.didit.me/ru/management-api/webhook/list-destinations.md): получение списка, создание, обновление и удаление адресов назначения, а также просмотр журналов доставки.
- [Создание адреса назначения webhook](https://docs.didit.me/ru/management-api/webhook/create-destination.md): схема запроса и ответа, включая `secret_shared_key` адреса назначения.
- [Модели данных](https://docs.didit.me/ru/reference/data-models.md): каноническая схема для каждого объекта отдельной функции, встроенного в `decision`.
- [Схема решения](https://docs.didit.me/ru/sessions-api/retrieve-session.md): полный список полей объекта `decision`, встроенного в webhook сессий.
- [Статусы верификации](https://docs.didit.me/ru/integration/verification-statuses.md): точные значения `status` и переходы, упомянутые выше.
- [Лимиты запросов](https://docs.didit.me/ru/integration/rate-limiting.md): лимиты, которые применяются при опросе `GET /v3/session/{id}/decision/` в качестве запасного варианта.


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