# Вебхуки

Система вебхуков OneKYC обеспечивает доставку уведомлений о событиях верификации на указанный URL-адрес в режиме реального времени. Поддерживаются события **KYC** (сессии, верификация, шаги, документы, ссылки и др.) и **KYB** (верификация юридических лиц). Выбор типов событий осуществляется при настройке endpoint в панели администратора.

**Основные характеристики:**

- События KYC (сессии, верификация, шаги, документы, ссылки и др.) и KYB (см. [Вебхуки KYB](/ru/kyb/webhooks))
- Криптографическая подпись HMAC-SHA256 для обеспечения безопасности доставки
- Автоматические повторные попытки с экспоненциальным увеличением интервала (до 5 попыток)
- Ключи идемпотентности на основе SHA-256 для предотвращения дублирования
- Журнал доставки и метрики в панели администратора

Настройка выполняется в панели администратора: вы указываете URL-адрес вашего сервера, выбираете типы событий (или все типы) и сохраняете конфигурацию. Система OneKYC направляет POST-запросы с криптографической подписью при каждом событии.

## Настройка [#настройка]

В разделе **Вебхуки** панели администратора добавьте URL-адрес вашего сервера, выберите необходимые типы событий (или все типы) и сохраните настройки. При создании endpoint система генерирует секретный ключ (с префиксом `whsec_`) для проверки подписи. Секретный ключ отображается однократно — сохраните его в надёжном месте.

## Типы событий [#типы-событий]

События KYC сгруппированы по категориям, перечисленным ниже. События KYB (категория **KYB events** в панели администратора) описаны в разделе [Вебхуки KYB](/ru/kyb/webhooks).

### Сессии (5 событий) [#сессии-5-событий]

| Событие | Описание |
|---------|----------|
| `session.created` | Создана новая сессия верификации |
| `session.started` | Сессия запущена (пользователь начал прохождение) |
| `session.completed` | Сессия завершена — **терминальное** событие, отправляется только после финального решения (автоматического или оператора ручного ревью) |
| `session.expired` | Срок действия сессии истёк |
| `session.cancelled` | Сессия отменена |

### Верификация (6 событий) [#верификация-6-событий]

| Событие | Описание |
|---------|----------|
| `verification.approved` | Верификация одобрена |
| `verification.declined` | Верификация отклонена |
| `verification.needs_review` | Верификация направлена на ручную проверку оператором (**не** терминальное состояние) |
| `verification.ocr.corrected` | Данные OCR исправлены оператором |
| `verification.resubmit_requested` | Оператор запросил повторную загрузку шага (Manual Review Resubmit) |
| `verification.resubmit_fulfilled` | Заявитель повторно загрузил запрошенный шаг |
| `verification.enrichment.updated` | Обновлена безопасная сводка асинхронного enrichment: покрытие и решение |

### Порядок событий для сессий с ручным ревью [#порядок-событий-для-сессий-с-ручным-ревью]

Если сессия требует ручной проверки оператором, порядок отправки событий следующий:

1. `verification.needs_review` — сразу после того, как автоматическая проверка завершилась с решением "нужна ручная проверка", или когда оператор вручную возвращает терминальную сессию на ревью. `session.completed` **не отправляется** на этом этапе — сессия ещё не финализирована.
2. `verification.approved` или `verification.declined` — после финального решения оператора.
3. `session.completed` — **сразу после** события `verification.approved` / `verification.declined`. Отправляется одной цепочкой событий.

Если вы подписаны только на `session.completed`, вы получите это событие **ровно один раз** — в момент финализации сессии. Промежуточное состояние "на ручной проверке" вы не увидите; для этого подпишитесь на `verification.needs_review`.

Для сессий без ручной проверки (автоматическое одобрение/отклонение) порядок событий:
`verification.approved` / `verification.declined` → `session.completed`.

`session.completed` является финальным событием для клиентского webhook-цикла. Внутри продукта оператор может позже вернуть завершённую сессию на ревью или запросить пересдачу шага; такие действия отправляют `verification.needs_review` или события resubmit и не переигрывают исходный `session.completed` до нового финального решения.

### Шаги (5 событий) [#шаги-5-событий]

| Событие | Описание |
|---------|----------|
| `step.started` | Шаг верификации начат |
| `step.completed` | Шаг верификации завершён |
| `step.failed` | Шаг завершился с ошибкой |
| `step.skipped` | Шаг пропущен, потому что вышестоящая зависимость не прошла |
| `step.retried` | Повторное выполнение шага |

### Документы (1 событие) [#документы-1-событие]

| Событие | Описание |
|---------|----------|
| `document.uploaded` | Документ загружен |

### AML-скрининг (3 события) [#aml-скрининг-3-события]

| Событие | Описание |
|---------|----------|
| `aml.screening_completed` | AML-проверка завершена |
| `aml.hit_found` | Обнаружено совпадение в базах AML |
| `aml.cleared` | AML-проверка завершена без совпадений |

### Ссылки верификации (4 события) [#ссылки-верификации-4-события]

| Событие | Описание |
|---------|----------|
| `link.created` | Ссылка верификации создана |
| `link.consumed` | Ссылка использована |
| `link.expired` | Срок действия ссылки истёк |
| `link.revoked` | Ссылка отозвана |

## Формат запроса и подпись [#формат-запроса-и-подпись]

OneKYC направляет на указанный URL-адрес POST-запрос с телом в формате JSON. Каждый запрос содержит следующие заголовки:

| Заголовок             | Описание                                         |
| --------------------- | ------------------------------------------------ |
| `Content-Type`        | `application/json`                               |
| `X-Onekyc-Signature`  | Подпись в формате `t=<timestamp>,v1=<signature>` |
| `X-Onekyc-Event-Type` | Тип события (например, `session.created`)        |
| `X-Onekyc-Event-Id`   | UUID события                                     |
| `X-Onekyc-Timestamp`  | Unix-время отправки                              |
| `User-Agent`          | `OneKYC-Webhook/1.0`                             |

> Имена HTTP-заголовков нечувствительны к регистру (RFC 7230 §3.2). На стороне приёмника используйте сравнение без учёта регистра (большинство HTTP-фреймворков делает это автоматически).

### Проверка подписи [#проверка-подписи]

Для верификации подлинности вебхука выполните следующие шаги:

1. Извлеките значения `t` (timestamp) и `v1` (signature) из заголовка `X-Onekyc-Signature`.
2. Сформируйте строку для подписи: `{timestamp}.{raw_body}` (числовое значение из `t`, символ точки, затем тело запроса в исходном виде).
3. Вычислите HMAC-SHA256 с использованием вашего секретного ключа вебхука (`whsec_...`).
4. Сравните полученное значение с `v1` из заголовка (используйте сравнение с постоянным временем выполнения — constant-time comparison).
5. Убедитесь, что значение timestamp не старше допустимого временного окна (рекомендуемое значение — 5 минут).

**Защита от replay-атак:** отклоняйте запросы, у которых `|текущее_время - X-Onekyc-Timestamp| > 300 секунд` (5 минут). Дополнительно используйте заголовок `X-Onekyc-Event-Id` (UUID) для дедупликации — сохраняйте обработанные event ID и игнорируйте повторные доставки.

При ротации секретного ключа в заголовке дополнительно присутствует `v2=<signature>` — подпись новым ключом. Оба ключа действуют одновременно в течение периода грации.

#### Примеры кода для проверки подписи [#примеры-кода-для-проверки-подписи]

**Node.js:**

```javascript
const crypto = require('crypto');

function verifyWebhookSignature(rawBody, signature, secret) {
  // Извлечение timestamp и signature из заголовка
  const parts = signature.split(',');
  const timestamp = parts.find(p => p.startsWith('t=')).split('=')[1];
  const v1Signature = parts.find(p => p.startsWith('v1=')).split('=')[1];

  // Проверка актуальности timestamp (не старше 5 минут)
  const maxAgeSeconds = 300; // 5 минут
  const currentTime = Math.floor(Date.now() / 1000);
  if (currentTime - parseInt(timestamp) > maxAgeSeconds) {
    throw new Error('Webhook timestamp is too old');
  }

  // Формирование строки для подписи: timestamp.raw_body
  const signedPayload = `${timestamp}.${rawBody}`;

  // Вычисление HMAC-SHA256
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  // Сравнение с постоянным временем выполнения
  if (!crypto.timingSafeEqual(
    Buffer.from(expectedSignature),
    Buffer.from(v1Signature)
  )) {
    throw new Error('Invalid webhook signature');
  }

  return true;
}
```

**Go:**

```go
import (
    "crypto/hmac"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/hex"
    "errors"
    "fmt"
    "strconv"
    "strings"
    "time"
)

func verifyWebhookSignature(rawBody []byte, signature, secret string) error {
    // Извлечение timestamp и signature из заголовка
    parts := strings.Split(signature, ",")
    var timestamp, v1Signature string

    for _, part := range parts {
        if strings.HasPrefix(part, "t=") {
            timestamp = strings.TrimPrefix(part, "t=")
        } else if strings.HasPrefix(part, "v1=") {
            v1Signature = strings.TrimPrefix(part, "v1=")
        }
    }

    if timestamp == "" || v1Signature == "" {
        return errors.New("invalid signature format")
    }

    // Проверка актуальности timestamp (не старше 5 минут)
    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil {
        return fmt.Errorf("invalid timestamp: %w", err)
    }

    maxAge := int64(300) // 5 минут
    if time.Now().Unix()-ts > maxAge {
        return errors.New("webhook timestamp is too old")
    }

    // Формирование строки для подписи: timestamp.raw_body
    signedPayload := fmt.Sprintf("%s.%s", timestamp, rawBody)

    // Вычисление HMAC-SHA256
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(signedPayload))
    expectedSignature := hex.EncodeToString(mac.Sum(nil))

    // Сравнение с постоянным временем выполнения
    if subtle.ConstantTimeCompare(
        []byte(expectedSignature),
        []byte(v1Signature),
    ) != 1 {
        return errors.New("invalid webhook signature")
    }

    return nil
}
```

### Формат payload [#формат-payload]

Все события передаются в едином формате конверта:

```json
{
  "id": "evt_uuid",
  "type": "session.created",
  "api_version": "2026-02-01",
  "created_at": "2026-02-01T12:00:00Z",
  "tenant_id": "uuid",
  "environment": "production",
  "idempotency_key": "unique_key",
  "data": { ... }
}
```

Содержимое поля `data` определяется типом события (сессия, верификация, шаг, документ, ссылка и др.).

### Политика повторных попыток [#политика-повторных-попыток]

При ошибке доставки (ответ с кодом, отличным от 2xx) система выполняет до **5 общих попыток доставки** с экспоненциальным увеличением интервала (базовая задержка — 5 секунд, удваивается на каждой повторной попытке, максимальный интервал — 24 часа, добавляется случайный jitter ±20% для размазывания нагрузки). После исчерпания всех попыток событие помещается в очередь недоставленных сообщений (Dead Letter Queue).

Для подтверждения успешного получения вебхука ваш сервер должен вернуть ответ с HTTP-кодом 2xx.

**Ключ идемпотентности (`idempotency_key`):** генерируется на основе SHA-256 хеша логической идентичности события и предназначен для дедупликации событий на стороне получателя. Значение представляет собой 32-символьную шестнадцатеричную строку.

**Пример:** `a1b2c3d4e5f6a1b2c3d4e5f6a1b2`

Некоторые payload финальных решений по сессии могут содержать дополнительные поля, если соответствующие данные доступны: `applicant.details`, `applicant.localized`, `media_files`, `decision_reasons` и `custom_steps`. Эти поля добавочные; получатель вебхука должен игнорировать поля, которые не использует.

## Примеры payload для событий [#примеры-payload-для-событий]

### События сессий (session.\*) [#события-сессий-session]

Все события сессий содержат плоскую структуру данных с информацией о сессии.
Терминальные KYC-события, такие как `session.completed`, также включают
обогащённые блоки `applicant` и `verifications`. Если во flow есть шаги
подтверждения телефона или email по OTP, подтверждённые значения передаются как
`data.applicant.phone` и `data.applicant.email`.

Поле верхнего уровня `result` является источником истины для финального решения
по сессии. Массив `verifications` может включать исторические повторные попытки
одной проверки. Каждый элемент проверки включает `attempt_number` и
`is_current`, когда эти данные доступны; используйте `is_current: true` как
актуальный результат конкретной проверки. Например, если живость документа
завалилась на 1-й попытке и прошла на 2-й, терминальный approved-payload может
содержать обе строки, но только строка 2-й попытки будет помечена как текущая.

```json
{
  "id": "evt_550e8400-e29b-41d4-a716-446655440000",
  "type": "session.completed",
  "api_version": "2026-02-01",
  "created_at": "2026-02-11T12:00:00Z",
  "tenant_id": "550e8400-e29b-41d4-a716-446655440001",
  "environment": "production",
  "idempotency_key": "example-idempotency-key",
  "data": {
    "session_id": "550e8400-e29b-41d4-a716-446655440002",
    "flow_id": "550e8400-e29b-41d4-a716-446655440003",
    "flow_version": 1,
    "external_id": "user_12345",
    "status": "completed",
    "created_at": "2026-02-11T10:00:00Z",
    "started_at": "2026-02-11T10:05:00Z",
    "completed_at": "2026-02-11T12:00:00Z",
    "expires_at": "2026-02-12T10:00:00Z",
    "steps_completed": 5,
    "result": "approved",
    "applicant": {
      "first_name": "John",
      "last_name": "Doe",
      "full_name": "JOHN DOE",
      "document_number": "AB1234567",
      "document_type": "passport",
      "date_of_birth": "1990-01-15",
      "phone": "+12125550100",
      "email": "john.doe@example.com"
    },
    "verifications": [
      {
        "type": "document_ocr",
        "step_id": "550e8400-e29b-41d4-a716-446655440010",
        "attempt_number": 1,
        "is_current": true,
        "status": "passed",
        "score": 0.95,
        "ocr": {
          "document_number": "AB1234567",
          "full_name": "JOHN DOE",
          "first_name": "JOHN",
          "last_name": "DOE",
          "date_of_birth": "1990-01-15",
          "issue_date": "2020-01-15",
          "expiry_date": "2030-01-15",
          "issuing_country": "USA",
          "document_type": "passport",
          "localized": {
            "English": {
              "Full Name": "JOHN DOE"
            }
          }
        }
      },
      {
        "type": "document_liveness",
        "step_id": "550e8400-e29b-41d4-a716-446655440010",
        "attempt_number": 2,
        "is_current": true,
        "status": "passed",
        "score": 0.96,
        "doc_liveness": {
          "is_real": true,
          "score": 0.96
        }
      }
    ]
  }
}
```

Полная схема полей сессии доступна в [Справочнике API (Swagger / OpenAPI)](/ru/api/api-reference).

### События верификации (verification.\*) [#события-верификации-verification]

События верификации содержат полную информацию о сессии, извлечённые данные заявителя из документа и результаты всех выполненных проверок.

**verification.approved:**

```json
{
  "data": {
    "session_id": "550e8400-e29b-41d4-a716-446655440002",
    "flow_id": "550e8400-e29b-41d4-a716-446655440003",
    "flow_version": 1,
    "external_id": "user_12345",
    "status": "completed",
    "result": "approved",
    "created_at": "2026-02-11T10:00:00Z",
    "started_at": "2026-02-11T10:01:00Z",
    "completed_at": "2026-02-11T12:00:00Z",
    "expires_at": "2026-02-12T10:00:00Z",
    "steps_completed": 5,
    "applicant": {
      "first_name": "John",
      "last_name": "Doe",
      "full_name": "John Doe",
      "middle_name": "Michael",
      "gender": "M",
      "nationality": "USA",
      "issuing_country": "USA",
      "document_number": "AB1234567",
      "document_type": "passport",
      "date_of_birth": "1990-01-15",
      "date_of_expiry": "2030-01-15",
      "date_of_issue": "2020-01-15",
      "phone": "+12125550100",
      "email": "john.doe@example.com"
    },
    "verifications": [
      {
        "type": "document_ocr",
        "step_id": "550e8400-e29b-41d4-a716-446655440010",
        "attempt_number": 1,
        "is_current": true,
        "status": "passed",
        "score": 0.95,
        "ocr": {
          "document_number": "AB1234567",
          "full_name": "JOHN DOE",
          "first_name": "JOHN",
          "last_name": "DOE",
          "date_of_birth": "1990-01-15",
          "nationality": "USA",
          "gender": "M",
          "issue_date": "2020-01-15",
          "expiry_date": "2030-01-15",
          "issuing_country": "USA",
          "issuing_authority": "U.S. Department of State",
          "document_type": "passport",
          "mrz_type": "TD3"
        }
      },
      {
        "type": "document_liveness",
        "step_id": "550e8400-e29b-41d4-a716-446655440010",
        "attempt_number": 2,
        "is_current": true,
        "status": "passed",
        "score": 0.96,
        "doc_liveness": {
          "is_real": true,
          "score": 0.96,
          "status": "Ok"
        }
      },
      {
        "type": "face_liveness",
        "step_id": "550e8400-e29b-41d4-a716-446655440011",
        "attempt_number": 2,
        "is_current": true,
        "status": "passed",
        "score": 0.97,
        "face_liveness": {
          "is_live": true,
          "score": 0.97,
          "faces": [
            {
              "x1": 80.0, "y1": 60.0, "x2": 220.0, "y2": 260.0,
              "yaw": 2.3, "pitch": -1.1, "roll": 0.5,
              "left_eye_closed": 0.03, "right_eye_closed": 0.04,
              "state": { "is_front": true, "quality": "High", "luminance": "Normal", "result": "Real" }
            }
          ]
        }
      },
      {
        "type": "face_match",
        "status": "passed",
        "score": 0.94,
        "face_match": {
          "score": 0.94,
          "is_match": true,
          "threshold": 0.7,
          "faces1": [
            {
              "x1": 80.0, "y1": 60.0, "x2": 220.0, "y2": 260.0,
              "yaw": 2.3, "pitch": -1.1, "roll": 0.5,
              "left_eye_closed": 0.03, "right_eye_closed": 0.04,
              "state": { "is_front": true, "quality": "High", "luminance": "Normal", "result": "Real" }
            }
          ],
          "faces2": [
            {
              "x1": 80.0, "y1": 60.0, "x2": 220.0, "y2": 260.0,
              "yaw": 2.3, "pitch": -1.1, "roll": 0.5,
              "left_eye_closed": 0.03, "right_eye_closed": 0.04,
              "state": { "is_front": true, "quality": "High", "luminance": "Normal", "result": "Real" }
            }
          ]
        }
      },
      {
        "type": "aml_screening",
        "status": "passed",
        "score": 0.9
      },
      {
        "type": "proof_of_address",
        "status": "passed",
        "score": 0.93,
        "proof_of_address": {
          "decision": "approved",
          "route": "gps_document",
          "reason_codes": ["gps_country_match", "document_address_match"],
          "applicant_message_code": "poa_approved",
          "reviewer_summary": "GPS, IP, and document evidence match the configured address policy.",
          "address_country": "USA",
          "address_city": "New York",
          "address_postal_code": "10001",
          "gps_accuracy_meters": 18.5,
          "ip_country": "USA",
          "ip_expected": "USA",
          "fingerprint_state": "consistent",
          "document_available": true,
          "document_category": "utility",
          "document_subtype": "utility_bill",
          "document_country": "USA"
        }
      }
    ]
  }
}
```

**verification.declined:**

```json
{
  "data": {
    "session_id": "...",
    "result": "declined",
    "applicant": { "first_name": "Jane", "last_name": "Doe", "document_number": "CD9876543", "document_type": "passport" },
    "verifications": [
      {
        "type": "document_ocr",
        "status": "failed",
        "score": 0.95,
        "rejection_reasons": ["document_expired"],
        "ocr": {
          "document_number": "CD9876543",
          "first_name": "JANE",
          "last_name": "DOE",
          "date_of_birth": "1990-05-15",
          "expiry_date": "2020-01-01",
          "issuing_country": "USA",
          "document_type": "passport"
        }
      }
    ]
  }
}
```

**verification.needs_review:**

```json
{
  "data": {
    "session_id": "...",
    "result": "review",
    "applicant": { "first_name": "Alice", "last_name": "Smith", "document_number": "EF5551234", "document_type": "passport" },
    "verifications": [
      {
        "type": "document_ocr",
        "status": "passed",
        "score": 0.91,
        "ocr": {
          "document_number": "EF5551234",
          "first_name": "ALICE",
          "last_name": "SMITH",
          "date_of_birth": "1985-03-20",
          "expiry_date": "2030-03-19",
          "issuing_country": "USA",
          "document_type": "passport"
        }
      },
      {
        "type": "face_match",
        "status": "review",
        "score": 0.76,
        "face_match": {
          "score": 0.76,
          "is_match": false,
          "threshold": 0.7,
          "faces1": [
            {
              "x1": 80.0, "y1": 60.0, "x2": 220.0, "y2": 260.0,
              "yaw": 2.3, "pitch": -1.1, "roll": 0.5,
              "left_eye_closed": 0.03, "right_eye_closed": 0.04,
              "state": { "is_front": true, "quality": "High", "luminance": "Normal", "result": "Real" }
            }
          ],
          "faces2": [
            {
              "x1": 80.0, "y1": 60.0, "x2": 220.0, "y2": 260.0,
              "yaw": 2.3, "pitch": -1.1, "roll": 0.5,
              "left_eye_closed": 0.03, "right_eye_closed": 0.04,
              "state": { "is_front": true, "quality": "High", "luminance": "Normal", "result": "Real" }
            }
          ]
        }
      },
      {
        "type": "proof_of_address",
        "status": "review",
        "score": 0.62,
        "proof_of_address": {
          "decision": "needs_review",
          "route": "document",
          "reason_codes": ["document_review_required"],
          "applicant_message_code": "poa_review_required",
          "reviewer_summary": "Document address was extracted but needs operator confirmation.",
          "address_country": "USA",
          "address_city": "Austin",
          "address_postal_code": "78701",
          "ip_country": "USA",
          "document_available": true,
          "document_category": "bank",
          "document_subtype": "bank_statement",
          "document_ocr_confidence_bucket": "medium",
          "document_ocr_degraded": false,
          "document_country": "USA"
        }
      }
    ]
  }
}
```

Полная схема всех полей с типами, форматами и описаниями доступна в [Справочнике API (Swagger / OpenAPI)](/ru/api/api-reference).

### События шагов (step.\*) [#события-шагов-step]

События шагов содержат информацию о выполнении конкретного шага верификации:

```json
{
  "type": "step.completed",
  "data": {
    "session_id": "550e8400-e29b-41d4-a716-446655440002",
    "step_id": "550e8400-e29b-41d4-a716-446655440010",
    "step_key": "document_capture",
    "step_type": "document",
    "step_name": "Upload Document",
    "execution_id": "550e8400-e29b-41d4-a716-446655440020",
    "attempt_number": 1,
    "status": "completed",
    "output": {
      "document_type": "passport",
      "storage_key": "documents/tenant_123/session_456/document.jpg"
    }
  }
}
```

**Дополнительные поля для `step.failed`:**

| Поле | Описание |
|------|----------|
| `error_code` | Код ошибки |
| `error_message` | Описание ошибки |
| `max_attempts` | Максимально допустимое количество попыток (позволяет определить, была ли это последняя попытка) |

**Дополнительные поля для `step.retried`:**

| Поле | Описание |
|------|----------|
| `max_attempts` | Максимально допустимое количество попыток |
| `reason` | Только для правок заявителем: "applicant_revision". Заявитель вернулся и изменил пройденный шаг; данные из предыдущего step.completed для этого шага устарели. |

### События документов (document.\*) [#события-документов-document]

**document.uploaded:**

```json
{
  "data": {
    "session_id": "550e8400-e29b-41d4-a716-446655440002",
    "step_id": "step_document_capture",
    "storage_key": "documents/tenant_123/session_456/document.jpg"
  }
}
```

> Промежуточные события OCR / liveness / face-match / AML-скрининга / proof-of-address НЕ доставляются как отдельные webhook-события. Их результаты включены в payload события `verification.approved` / `verification.declined` / `verification.needs_review` (массив `verifications`). Для proof-of-address отправляется только редактированный summary; raw GPS-координаты и storage keys не отправляются. См. примеры выше.

### События AML-скрининга (aml.\*) [#события-aml-скрининга-aml]

**aml.screening_completed:**

```json
{
  "data": {
    "session_id": "550e8400-e29b-41d4-a716-446655440002",
    "match_found": false,
    "risk_score": 15.2,
    "decision": "approve",
    "match_severity": ""
  }
}
```

**aml.hit_found:**

```json
{
  "data": {
    "session_id": "550e8400-e29b-41d4-a716-446655440002",
    "risk_score": 85.5,
    "decision": "review",
    "match_severity": "high"
  }
}
```

**aml.cleared:**

```json
{
  "data": {
    "session_id": "550e8400-e29b-41d4-a716-446655440002",
    "risk_score": 5.0
  }
}
```

#### Поле `aml.enrichment` (Kontur.Focus) [#поле-amlenrichment-konturfocus]

> **Статус:** Доступно с 2026-04-24. Включается на уровне шага AML-скрининга через флаг `enable_focus_enrichment` в конфигурации flow (по умолчанию `true`).

После основного AML-скрининга по спискам Kontur.Compliance (санкции, террористы, PEP, банкротство и др.) включается дополнительный этап enrichment через **Kontur.Focus API**. Этот этап обогащает решение по аппликанту данными из 13 эндпоинтов Focus и формирует объединённое (merged) решение с пометкой источника.

Результаты enrichment включаются в payload `aml.screening_completed` в виде объекта `enrichment`:

```json
{
  "data": {
    "session_id": "550e8400-e29b-41d4-a716-446655440002",
    "match_found": true,
    "risk_score": 78.4,
    "decision": "review",
    "match_severity": "high",
    "enrichment": {
      "inn": "771234567890",
      "inn_source": "ocr",
      "inn_confidence": 0.92,
      "compliance_decision": "review",
      "compliance_match_count": 2,
      "merged_decision": "review",
      "merged_matches_count": 4,
      "merged_risk_score": 78.4,
      "hard_block_reason": null,
      "enrichment_status": "completed",
      "enrichment_completed_at": "2026-04-24T10:15:30Z",
      "call_statuses": {
        "passport_check": "completed",
        "sanctioned_persons": "completed",
        "pep_search": "completed",
        "person_bankruptcy": "completed",
        "person_affiliates": "completed",
        "smz": "skipped",
        "fssp": "skipped_no_inn",
        "general_court_cases": "completed"
      },
      "focus_passport": { "...": "см. ниже" },
      "focus_sanctions": { "...": "см. ниже" },
      "focus_pep": { "...": "см. ниже" },
      "focus_person_bankruptcy": { "...": "см. ниже" },
      "focus_affiliates": { "...": "см. ниже" },
      "focus_smz_status": { "...": "см. ниже" },
      "focus_fssp_summary": { "...": "см. ниже" },
      "focus_court_summary": { "...": "см. ниже" }
    }
  }
}
```

**Поля enrichment:**

| Поле | Тип | Описание |
|------|-----|----------|
| `inn` | string | ИНН аппликанта, обнаруженный пайплайном (источник в `inn_source`) |
| `inn_source` | string | Источник ИНН: `ocr` / `compliance` / `focus_search` / `focus_affiliates` / `manual` |
| `inn_confidence` | number | Уверенность в найденном ИНН (0.00–1.00) |
| `compliance_decision` | string | Решение Kontur.Compliance до объединения с Focus: `approve` / `decline` / `review` |
| `compliance_match_count` | int | Количество совпадений по спискам Compliance |
| `merged_decision` | string | Итоговое объединённое решение |
| `merged_matches_count` | int | Общее количество совпадений (Compliance + Focus) |
| `merged_risk_score` | number | Итоговый риск-скор (0.000–1.000 в БД, отнормирован к 0–100 в payload) |
| `hard_block_reason` | string \| null | Причина жёсткой блокировки (см. ниже) |
| `enrichment_status` | string | `pending` / `running` / `completed` / `failed` |
| `enrichment_completed_at` | string (RFC3339) | Когда завершился enrichment |
| `call_statuses` | object | Статус каждого Focus-вызова: `completed` / `error` / `skipped` / `skipped_no_inn` |

**Поле `focus_passport`** — Focus-сторонняя проверка паспорта:

```json
{
  "checked_at": "2026-04-24T10:15:00Z",
  "valid": true,
  "source": "focus.checkPassport"
}
```

Источник: эндпоинт `checkPassport` Focus API. Используется как второй источник проверки паспорта в дополнение к встроенному `passport_check` шага верификации.

**Поле `focus_sanctions`** — обнаружения по санкционным спискам Focus:

```json
{
  "items": [
    {
      "list_name": "EU consolidated list",
      "matched_name": "IVANOV IVAN",
      "match_type": "exact",
      "confidence": 0.95
    }
  ],
  "checked_at": "2026-04-24T10:15:00Z",
  "source": "focus.sanctionedPersons"
}
```

**Поле `focus_pep`** — попадание в реестр PEP:

```json
{
  "items": [
    {
      "full_name": "ИВАНОВ ИВАН ИВАНОВИЧ",
      "category": "national_pep",
      "position": "Депутат Государственной Думы",
      "checked_at": "2026-04-24T10:15:00Z"
    }
  ],
  "source": "focus.pepSearch"
}
```

**Поле `focus_person_bankruptcy`** — банкротство физических лиц:

```json
{
  "is_bankrupt": false,
  "items": [],
  "checked_at": "2026-04-24T10:15:00Z",
  "source": "focus.personBankruptcy"
}
```

При обнаружении банкротства поля item включают `case_number`, `arbitr_court`, `stage` (наблюдение / финансовое оздоровление / внешнее управление / конкурсное производство / реализация имущества), `start_date`, `manager_full_name` и др.

**Поле `focus_affiliates`** — связанные компании (где аппликант является директором / учредителем / бенефициаром):

```json
{
  "items": [
    {
      "ogrn": "1027700123456",
      "inn": "7701234567",
      "company_name": "ООО Пример",
      "role": "director",
      "share_percent": 50.0,
      "is_sanctioned": false,
      "is_bankrupt": false
    }
  ],
  "checked_at": "2026-04-24T10:15:00Z",
  "source": "focus.personAffiliates"
}
```

Состав полей affiliates можно ограничить через настройку `affiliate_fields_visibility` в конфигурации шага AML — например, скрыть долю участия от не-администраторов.

**Поле `focus_smz_status`** — статус самозанятости (НПД):

```json
{
  "status": "registered",
  "registered_at": "2023-01-15",
  "checked_at": "2026-04-24T10:15:00Z",
  "source": "focus.smzGetStatus"
}
```

Возможные значения `status`: `registered` (является самозанятым), `not_registered`, `unknown` (не удалось проверить — например, нет ИНН или отказ ФНС).

**Поле `focus_fssp_summary`** — записи в Федеральной службе судебных приставов (только для индивидуальных предпринимателей):

```json
{
  "total_debt_rub": 1250000,
  "open_cases_count": 3,
  "items": [
    {
      "case_number": "12345/24/77001-ИП",
      "debt_amount_rub": 500000,
      "subject": "Алименты",
      "status": "open"
    }
  ],
  "checked_at": "2026-04-24T10:15:00Z",
  "source": "focus.fssp"
}
```

**Поле `focus_court_summary`** — судебные дела (только для ИП):

```json
{
  "total_cases": 5,
  "as_plaintiff": 2,
  "as_defendant": 3,
  "items": [
    {
      "case_number": "А40-12345/2024",
      "court_name": "Арбитражный суд г. Москвы",
      "role": "defendant",
      "status": "in_progress",
      "registration_date": "2024-03-15"
    }
  ],
  "checked_at": "2026-04-24T10:15:00Z",
  "source": "focus.generalCourtCases"
}
```

#### Hard-block правила [#hard-block-правила]

Поле `hard_block_reason` непустое означает, что Focus-данные привели к **автоматическому отклонению** независимо от итогового risk_score. Возможные значения:

| Значение | Описание |
|----------|----------|
| `passport_invalid` | Focus сообщил, что паспорт недействителен |
| `bankruptcy_asset_liquidation` | Аппликант находится на стадии «реализация имущества» в банкротстве |
| `focus_sanctions_hit` | Найдено совпадение в санкционных списках Focus |
| `affiliate_sanctions_hit` | Связанная с аппликантом компания находится под санкциями |
| `fssp_debt_exceeds_threshold` | Долг по ФССП превышает настроенный порог `hard_block_min_fssp_debt_rub` |

Каждое из этих правил можно отключить отдельно через конфигурацию шага AML (`hard_block_passport_invalid`, `hard_block_bankruptcy_asset_sale`, `hard_block_focus_sanctions_hit`, `hard_block_affiliate_sanctions_hit`, `hard_block_min_fssp_debt_rub` со значением 0).

#### Per-endpoint флаги [#per-endpoint-флаги]

Выполнение каждого Focus-вызова можно включать / выключать индивидуально в конфигурации шага AML (раздел **Флоу → Шаг AML-скрининга**):

| Флаг | Эндпоинт Focus | Описание |
|------|----------------|----------|
| `enable_focus_enrichment` | — | Глобальный switch для всего этапа enrichment |
| `enable_sanctioned_persons_check` | `sanctionedPersons` | Поиск по санкционным спискам Focus |
| `enable_pep_search_check` | `pepSearch` | Поиск в реестре PEP |
| `enable_person_bankruptcy_check` | `personBankruptcy` | Проверка банкротства физлиц |
| `enable_passport_invalid_check` | `checkPassport` | Проверка паспорта вторым источником |
| `enable_person_affiliates` | `personAffiliates` | Поиск связанных компаний |
| `enable_smz_check` | `smzSend` + `smzGetStatus` | Проверка статуса самозанятости |
| `enable_fssp_check` | `fssp` | Проверка ФССП (только ИП) |
| `enable_court_cases_check` | `generalCourtCases` | Проверка судебных дел (только ИП) |

Если глобальный `enable_focus_enrichment = false`, поле `enrichment` в payload отсутствует, и решение строится только на данных Kontur.Compliance.

#### Жизненный цикл enrichment и покрытие [#жизненный-цикл-enrichment-и-покрытие]

Терминальные KYC-события содержат `data.enrichment`, если для аппликанта существует запись enrichment. Её `scope` равен `applicant`: это последний агрегат по аппликанту, а не подтверждение того, что enrichment выполнялся именно для этой сессии. В `summary` входят `status`, время наблюдения и завершения, `call_statuses` по эндпоинтам, объединённое решение, риск-скор и число совпадений (если известны), а также необязательные результаты проверки паспорта и банкротства.

```json
{
  "enrichment": {
    "scope": "applicant",
    "summary": {
      "status": "completed",
      "call_statuses": {"passport_check": "completed", "fssp": "skipped_no_inn"},
      "merged_decision": "approve",
      "merged_risk_score": 0,
      "match_count": 0,
      "passport_invalid": false
    }
  }
}
```

Отсутствующие результаты означают «неизвестно». Пропущенный или завершившийся ошибкой эндпоинт не является отрицательной проверкой. Известные нулевые оценки и счётчики, а также известные отрицательные находки передаются явно. Исходные ответы поставщиков и идентификаторы (ИНН, СНИЛС, номер паспорта) в эту сводку не входят.

Enrichment может завершиться уже после терминального события. Подпишитесь на `verification.enrichment.updated`, чтобы получать последующие обновления (завершён или ошибка); они коррелируются по `session_id`, `applicant_id` и номеру попытки и используют ту же сводку уровня аппликанта. Если терминальный снимок недоступен, это отражается в `enrichment_errors`; такой результат нельзя считать чистым.

#### Итоговая идентичность и данные [#итоговая-идентичность-и-данные]

Автоматические и проверенные вручную терминальные KYC-события и события решений используют одну и ту же проекцию данных. `applicant.full_name` остаётся прежним отображаемым значением. `full_name_latin` передаётся только для наблюдаемого имени в латинице. `full_name_native` и `full_name_original` сохраняют исходное написание в документе, если оно доступно; транслитерация не выдумывается. `name_source` указывает результат документа, шаг и попытку; `name_evidence` сохраняет альтернативы сверки. `localized` сохраняет подписи полей провайдера и добавляет канонические псевдонимы. Отсутствующие имена остаются отсутствующими для flow без данных о личности.

Терминальные снимки также включают доступные `verifications`, `decision_reasons`, `custom_steps`, `document_collections`, `media_files`, безопасные `risk_signals` и сводки по устройствам. Личность аппликанта определяется по текущим попыткам. Записи верификации сохраняют `is_current`, чтобы потребители могли отличать устаревшие данные. Сбои необязательного чтения отображаются стабильными кодами в `enrichment_errors`, а не молчаливо подразумевают пустую успешную проверку.

### События ссылок верификации (link.\*) [#события-ссылок-верификации-link]

Все события ссылок содержат полную информацию о ссылке верификации:

```json
{
  "type": "link.consumed",
  "data": {
    "link": {
      "id": "550e8400-e29b-41d4-a716-446655440003",
      "tenant_id": "550e8400-e29b-41d4-a716-446655440000",
      "flow_id": "550e8400-e29b-41d4-a716-446655440002",
      "external_user_id": "user_12345",
      "external_reference": "ref_abc123",
      "status": "consumed",
      "session_id": "550e8400-e29b-41d4-a716-446655440001",
      "created_at": "2026-02-11T10:00:00Z",
      "activated_at": "2026-02-11T10:05:00Z",
      "consumed_at": "2026-02-11T12:00:00Z",
      "expires_at": "2026-02-14T10:00:00Z",
      "locale": "en"
    }
  }
}
```

Полная схема полей ссылки доступна в [Справочнике API (Swagger / OpenAPI)](/ru/api/api-reference).

### События OCR (verification.ocr.\*) [#события-ocr-verificationocr]

**verification.ocr.corrected** — оператор отредактировал результаты OCR через панель администратора:

```json
{
  "type": "verification.ocr.corrected",
  "data": {
    "session_id": "550e8400-e29b-41d4-a716-446655440002",
    "applicant_id": "550e8400-e29b-41d4-a716-446655440005",
    "verification_id": "550e8400-e29b-41d4-a716-446655440099",
    "fields": {
      "first_name": "JANE",
      "last_name": "DOE",
      "date_of_birth": "1990-05-15"
    }
  }
}
```

### События Manual Review Resubmit (verification.resubmit_\*) [#события-manual-review-resubmit-verificationresubmit_]

События запроса повторной загрузки шага оператором ручного ревью.

`request_id` идентифицирует отдельную отправку и связывает её с завершением пересдачи. `scope` равен `step` (также значение по умолчанию для старых событий) или `flow` для полного KYB. Для `flow` поле `step_id` пустое. Непустой собственный `client_message` позволяет не выбирать `reason_code`; если указаны оба значения, передаются оба. Предыдущие отправки сохраняются в истории. Внутренние заметки не передаются.

События ручного отклонения (`verification.declined`, `session.completed` и события решения KYB) содержат текущий `decision_id`, необязательные `decision_reason_codes` и собственный публичный `client_message` из того же сохранённого решения. Для отклонения только с комментарием причина не выдумывается. Старый комментарий не подставляется вместо текущего решения; `internal_notes` остаются внутренними.

**verification.resubmit_requested** — оператор запросил у заявителя повторную загрузку шага:

```json
{
  "type": "verification.resubmit_requested",
  "data": {
    "session_id": "550e8400-e29b-41d4-a716-446655440002",
    "flow_id": "550e8400-e29b-41d4-a716-446655440003",
    "external_id": "user_12345",
    "applicant_id": "550e8400-e29b-41d4-a716-446655440005",
    "step_id": "550e8400-e29b-41d4-a716-446655440010",
    "step_type": "document_capture",
    "reason_code": "blurry",
    "client_message": "Фотография нечёткая, перезагрузите, пожалуйста",
    "attempt_number": 1,
    "requested_by_id": "550e8400-e29b-41d4-a716-446655440100",
    "requested_by_name": "Reviewer Name",
    "expires_at": "2026-05-04T12:00:00Z"
  }
}
```

Допустимые значения `reason_code`: `illegible`, `blurry`, `cropped`, `glare`, `wrong_document`, `expired_document`, `partial`.

**verification.resubmit_fulfilled** — заявитель повторно загрузил запрошенный шаг:

Событие означает, что для запрошенного шага записана новая терминальная попытка; оно не означает успешное прохождение шага или одобрение всей проверки. Payload содержит тот же `external_id`, который был отправлен в событии запроса.

```json
{
  "type": "verification.resubmit_fulfilled",
  "data": {
    "session_id": "550e8400-e29b-41d4-a716-446655440002",
    "flow_id": "550e8400-e29b-41d4-a716-446655440003",
    "external_id": "user_12345",
    "applicant_id": "550e8400-e29b-41d4-a716-446655440005",
    "step_id": "550e8400-e29b-41d4-a716-446655440010",
    "reason_code": "blurry",
    "attempt_number": 1,
    "fulfilled_at": "2026-04-27T18:30:00Z"
  }
}
```

> Лимиты Manual Review Resubmit: до 3 запросов на сессию, TTL запроса 7 дней. Если заявитель не выполнит повторную загрузку до истечения TTL, запрос автоматически отменяется.

## Часто задаваемые вопросы [#часто-задаваемые-вопросы]

### Утрата секретного ключа вебхука [#утрата-секретного-ключа-вебхука]

Секретный ключ вебхука (с префиксом `whsec_`) невозможно восстановить — он отображается только один раз при создании endpoint.

**Порядок действий:**

1. Откройте панель администратора OneKYC.
2. Перейдите в раздел **Вебхуки**.
3. Нажмите кнопку **«Ротация секрета»** рядом с соответствующим endpoint.
4. Скопируйте новый секретный ключ — он отображается однократно.
5. Обновите секретный ключ в конфигурации вашего приложения.

**Обратите внимание:** в ходе ротации оба секретных ключа (прежний и новый) действуют одновременно в течение **периода грации продолжительностью 24 часа**. Это обеспечивает непрерывность доставки вебхуков на время обновления. В заголовке `X-Onekyc-Signature` будут присутствовать как `v1=<прежняя_подпись>`, так и `v2=<новая_подпись>`.

По истечении периода грации новый ключ становится основным: заголовок
содержит только `v1=<новая_подпись>`. Старый ключ больше не подписывает доставки,
даже если фоновая очистка в базе данных ещё не выполнена. Получатель должен принимать
новую подпись в `v2` в течение периода грации и в `v1` после его завершения; используйте
сравнение HMAC за постоянное время и сохраняйте проверки временной метки и защиты от повторов.

Чтобы завершить ротацию досрочно, убедитесь, что получатель использует новый ключ, затем
выберите **Завершить ротацию** в консоли. API-клиенты могут вызвать
`POST /admin/v1/webhooks/endpoints/{id}/rotate/complete` с телом
`{"rotation_expires_at":"<срок, возвращённый API endpoint>"}`.
Устаревший срок возвращает `409`; обновите данные endpoint, а не повторяйте
изменение секрета автоматически. Завершение немедленно выводит старый ключ из использования.

### Отсутствие входящих вебхуков [#отсутствие-входящих-вебхуков]

В случае прекращения поступления вебхуков выполните следующие проверки:

**1. Статус endpoint в панели администратора**

- Откройте раздел **Вебхуки** в панели администратора.
- Проверьте статус endpoint (активен / отключён / ошибка).
- Просмотрите журнал доставки — в нём указаны коды ответов и описания ошибок.

**2. Доступность вашего сервера**

- URL-адрес должен быть публично доступен по протоколу HTTPS (HTTP не поддерживается).
- Убедитесь, что межсетевой экран не блокирует входящие запросы от OneKYC.
- Проверьте валидность SSL-сертификата (самоподписанные сертификаты не поддерживаются).

**3. Код обработки вебхука**

- Ваш endpoint должен возвращать ответ с HTTP-кодом **2xx** (200, 201, 204) в течение 30 секунд.
- Если обработка требует больше времени, рекомендуется немедленно возвращать ответ `200 OK` и выполнять обработку асинхронно.
- Проверьте журнал вашего приложения на наличие ошибок валидации подписи.

**4. Типы событий**

- Убедитесь, что в настройках endpoint включены необходимые типы событий.
- При выборе варианта «Все события» проверьте, что события действительно генерируются (создаются сессии, выполняются верификации и т. д.).

**5. Очередь недоставленных сообщений (Dead Letter Queue)**

Если система не смогла доставить вебхук после 5 попыток (с экспоненциальным увеличением интервала до 24 часов), событие помещается в Dead Letter Queue. Недоставленные события доступны для просмотра в панели администратора, откуда можно инициировать повторную доставку вручную.

**Тестирование endpoint:**

Для проверки работоспособности endpoint нажмите кнопку **«Отправить тестовый вебхук»** в панели администратора. В открывшемся диалоговом окне выберите тип события (например, `verification.approved`) — система направит тестовый payload данного события с корректной подписью HMAC-SHA256. Идентификатор тестового события имеет префикс `evt_test_`.

### Обращение в службу поддержки [#обращение-в-службу-поддержки]

При возникновении затруднений в работе с вебхуками воспользуйтесь следующими каналами:

**1. Документация и примеры**

- Ознакомьтесь с материалами данного раздела документации и приведёнными примерами кода.
- Обратитесь к [Справочнику API (Swagger / OpenAPI)](/ru/api/api-reference) для получения детальной спецификации.

**2. Техническая поддержка**

- **Email:** info@onekyc.io
- **Telegram:** [@OneKYC_Support](https://t.me/OneKYC_Support)
- **Часы работы:** Пн–Пт, 10:00–19:00 (GMT+6, Алматы)

**3. Критические проблемы**

Для решения критических проблем, блокирующих работу в производственной среде:
- Направьте обращение в службу поддержки с пометкой **«URGENT»** в теме письма.
- Укажите `tenant_id`, URL-адрес endpoint и примеры записей из журнала ошибок.
- Ответ будет предоставлен в течение 2 часов в рабочее время.

**4. Сообщения об ошибках и предложения по улучшению**

В случае обнаружения ошибки в системе вебхуков или при наличии предложений по улучшению:
- Создайте обращение через личный кабинет в панели администратора.
- Или направьте подробное описание на адрес info@onekyc.io.

---

Подробная информация о формате payload, заголовках и политике повторных попыток доступна в [Справочнике API (Swagger / OpenAPI)](/ru/api/api-reference).

## Вебхуки комментариев ревью [#вебхуки-комментариев-ревью]

Новый комментарий к задаче KYC создаёт событие `verification.comment.created`, к задаче KYB — `kyb.review.comment.created`. Одинаковый контракт действует для админки клиента, платформенной консоли ревью и клиентского API. Комментарий не меняет решение проверки и не завершает сессию.

Подпишитесь на нужный тип события. Существующие подписки `*` также получают новые события. Используются обычные подпись вебхука, фильтрация sandbox/production, повторные попытки и журнал доставок. Возможны повторные доставки: устраняйте дубли по `id` события или `idempotency_key`. Порядок получения не гарантирован.

Поле `data` содержит `scope`, `task_id`, `session_id`, `flow_id`, `external_id` и объект `comment` с полями `id`, `content`, `created_at`, `author_type`. Для KYC передаётся `applicant_id`, для KYB — `business_id`, если он задан. Типы авторов: `admin_user`, `superadmin_user`, `api_key`, `system`. Идентификаторы и имена сотрудников в вебхук не включаются.

Пример данных события для KYC:

```json
{
  "scope": "kyc",
  "task_id": "550e8400-e29b-41d4-a716-446655440010",
  "session_id": "550e8400-e29b-41d4-a716-446655440001",
  "flow_id": "550e8400-e29b-41d4-a716-446655440002",
  "external_id": "customer_123",
  "applicant_id": "550e8400-e29b-41d4-a716-446655440099",
  "comment": {
    "id": "550e8400-e29b-41d4-a716-446655440011",
    "content": "Please provide a clearer document photo.",
    "created_at": "2026-10-16T08:00:00.123456Z",
    "author_type": "admin_user"
  }
}
```

Все комментарии задачи доступны клиенту-интегратору. Внутренние заметки решения `internal_notes` и сообщение повторной загрузки `client_message` — отдельные поля; они не создают события комментариев. События создаются только для новых комментариев; исторические комментарии не рассылаются.

Получение комментариев: `GET /tenant/v1/kyc/review-tasks/{task_id}/comments` с правом `reviews:read` или `GET /tenant/v1/kyb/review-tasks/{task_id}/comments` с правом `kyb.reviews:read`. Создание: `POST` по тому же пути с соответствующим правом записи. Успешный ответ создания дополнен `comment_id` и `created_at`, совпадающими с сохранённым комментарием и снимком в вебхуке. Каждый успешный POST создаёт новый комментарий; дедупликация вебхука не делает повторные запросы создания идемпотентными. При неопределённом результате запроса сверяйтесь с API комментариев до повторной отправки.

Комментарий, событие и запись outbox сохраняются одной транзакцией. Ошибка сохранения события откатывает создание комментария. HTTP-доставка выполняется асинхронно после фиксации транзакции: успешный ответ создания не подтверждает доставку вебхука клиенту; проверяйте журнал доставок.
