# Webhooks

The OneKYC webhook system delivers verification event notifications to your URL in real time. **KYC** events (sessions, verification, steps, documents, links, etc.) and **KYB** events (business verification) are both supported. You select the event types when configuring the endpoint in the admin panel.

**Key properties:**

- KYC events (sessions, verification, steps, documents, links, etc.) and KYB events (see [KYB webhooks](/en/kyb/webhooks))
- HMAC-SHA256 signing for delivery integrity
- Automatic retries with exponential backoff (up to 5 attempts)
- SHA-256 idempotency keys to prevent duplicate processing
- Delivery log and metrics in the admin panel

You configure webhooks in the admin panel: enter your server URL, select event types (or all types), and save. OneKYC sends signed POST requests for every event.

## Configuration [#configuration]

In the **Webhooks** section of the admin panel, add your server URL, select the event types (or all types), and save. When the endpoint is created, the system generates a secret (with the `whsec_` prefix) for signature verification. The secret is shown once — store it in a secure vault.

## Event types [#event-types]

KYC events are grouped into the categories below. KYB events (the **KYB events** category in the admin panel) are documented in [KYB webhooks](/en/kyb/webhooks).

### Sessions (5 events) [#sessions-5-events]

| Event | Description |
|---------|----------|
| `session.created` | A new verification session was created |
| `session.started` | The session was started (the user began verification) |
| `session.completed` | The session is complete — a **terminal** event, sent only after the final decision (automatic or by a manual review operator) |
| `session.expired` | The session expired |
| `session.cancelled` | The session was cancelled |

### Verification (6 events) [#verification-6-events]

| Event | Description |
|---------|----------|
| `verification.approved` | Verification approved |
| `verification.declined` | Verification declined |
| `verification.needs_review` | Verification routed to manual review by an operator (**not** a terminal state) |
| `verification.ocr.corrected` | OCR data was corrected by an operator |
| `verification.resubmit_requested` | A reviewer requested step resubmission (Manual Review Resubmit) |
| `verification.resubmit_fulfilled` | The applicant resubmitted the requested step |
| `verification.enrichment.updated` | Safe asynchronous enrichment coverage and decision summary updated |

### Event order for sessions with manual review [#event-order-for-sessions-with-manual-review]

If a session needs manual review, events are sent in the following order:

1. `verification.needs_review` — sent right after automated checks finish with a "needs review" decision, or when an operator manually sends a terminal session back to review. `session.completed` **is not sent** at this point — the session is not finalised yet.
2. `verification.approved` or `verification.declined` — after the operator's final decision.
3. `session.completed` — sent **immediately after** `verification.approved` / `verification.declined`. The two events are sent as one chain.

If you only subscribe to `session.completed`, you receive that event **exactly once** — when the session is finalised. You do not see the intermediate "in review" state; subscribe to `verification.needs_review` for that.

For sessions without manual review (automatic approval/rejection), the event order is:
`verification.approved` / `verification.declined` → `session.completed`.

`session.completed` is final for the customer-facing webhook cycle. Internally, a reviewer can later send a completed session back to review or request a resubmit; those operator actions emit `verification.needs_review` or resubmit events and do not replay the original `session.completed` until a new final decision is made.

### Steps (5 events) [#steps-5-events]

| Event | Description |
|---------|----------|
| `step.started` | A verification step started |
| `step.completed` | A verification step completed |
| `step.failed` | The step finished with an error |
| `step.skipped` | The step was skipped because an upstream dependency did not pass |
| `step.retried` | The step is retried |

### Documents (1 event) [#documents-1-event]

| Event | Description |
|---------|----------|
| `document.uploaded` | A document was uploaded |

### AML screening (3 events) [#aml-screening-3-events]

| Event | Description |
|---------|----------|
| `aml.screening_completed` | AML screening completed |
| `aml.hit_found` | A match against an AML watchlist was found |
| `aml.cleared` | AML screening completed with no matches |

### Verification links (4 events) [#verification-links-4-events]

| Event | Description |
|---------|----------|
| `link.created` | The verification link was created |
| `link.consumed` | The link was consumed |
| `link.expired` | The link expired |
| `link.revoked` | The link was revoked |

## Request format and signature [#request-format-and-signature]

OneKYC sends a POST request with a JSON body to your URL. Every request carries the following headers:

| Header                | Description                                      |
| --------------------- | ------------------------------------------------ |
| `Content-Type`        | `application/json`                               |
| `X-Onekyc-Signature`  | Signature in the form `t=<timestamp>,v1=<signature>` |
| `X-Onekyc-Event-Type` | Event type (for example, `session.created`)      |
| `X-Onekyc-Event-Id`   | Event UUID                                       |
| `X-Onekyc-Timestamp`  | Unix timestamp of the send                       |
| `User-Agent`          | `OneKYC-Webhook/1.0`                             |

> HTTP header names are case-insensitive (RFC 7230 §3.2). On the receiving side, use case-insensitive comparison (most HTTP frameworks do this automatically).

### Verifying the signature [#verifying-the-signature]

To verify a webhook's authenticity:

1. Extract `t` (timestamp) and `v1` (signature) from the `X-Onekyc-Signature` header.
2. Build the signed string: `{timestamp}.{raw_body}` (the numeric value from `t`, a literal dot, then the raw request body).
3. Compute HMAC-SHA256 using your webhook secret (`whsec_...`).
4. Compare the result with `v1` from the header (use a constant-time comparison).
5. Confirm that the timestamp is within the allowed window (5 minutes is recommended).

**Replay protection:** reject requests where `|now - X-Onekyc-Timestamp| > 300 seconds` (5 minutes). Additionally, use the `X-Onekyc-Event-Id` header (UUID) for deduplication — store processed event IDs and ignore duplicate deliveries.

When the secret is rotated, the header also carries `v2=<signature>` — the signature with the new key. Both keys are valid during the grace period.

#### Signature verification examples [#signature-verification-examples]

**Node.js:**

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

function verifyWebhookSignature(rawBody, signature, secret) {
  // Extract timestamp and signature from the header
  const parts = signature.split(',');
  const timestamp = parts.find(p => p.startsWith('t=')).split('=')[1];
  const v1Signature = parts.find(p => p.startsWith('v1=')).split('=')[1];

  // Verify the timestamp is fresh (≤ 5 minutes old)
  const maxAgeSeconds = 300; // 5 minutes
  const currentTime = Math.floor(Date.now() / 1000);
  if (currentTime - parseInt(timestamp) > maxAgeSeconds) {
    throw new Error('Webhook timestamp is too old');
  }

  // Build the signed string: timestamp.raw_body
  const signedPayload = `${timestamp}.${rawBody}`;

  // Compute HMAC-SHA256
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  // Constant-time compare
  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 {
    // Extract timestamp and signature from the header
    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")
    }

    // Verify the timestamp is fresh (≤ 5 minutes old)
    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil {
        return fmt.Errorf("invalid timestamp: %w", err)
    }

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

    // Build the signed string: timestamp.raw_body
    signedPayload := fmt.Sprintf("%s.%s", timestamp, rawBody)

    // Compute HMAC-SHA256
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(signedPayload))
    expectedSignature := hex.EncodeToString(mac.Sum(nil))

    // Constant-time compare
    if subtle.ConstantTimeCompare(
        []byte(expectedSignature),
        []byte(v1Signature),
    ) != 1 {
        return errors.New("invalid webhook signature")
    }

    return nil
}
```

### Payload format [#payload-format]

Every event uses a single envelope format:

```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": { ... }
}
```

The `data` field's contents depend on the event type (session, verification, step, document, link, etc.).

### Retry policy [#retry-policy]

If delivery fails (the response is not 2xx), the system performs up to **5 total delivery attempts** with exponential backoff (base delay 5 seconds, doubled on each retry, maximum interval 24 hours, with ±20% random jitter to spread the load). After all attempts are exhausted, the event is moved to the dead-letter queue (DLQ).

To acknowledge successful delivery, your server must respond with HTTP 2xx.

**Idempotency key (`idempotency_key`):** generated from a SHA-256 hash of the logical event identity and intended for receiver-side deduplication. The value is a 32-character hexadecimal string.

**Example:** `a1b2c3d4e5f6a1b2c3d4e5f6a1b2`

Some session decision payloads may include optional enrichment fields when source data is available: `applicant.details`, `applicant.localized`, `media_files`, `decision_reasons`, and `custom_steps`. These fields are additive; webhook consumers should ignore fields they do not use.

## Event payload examples [#event-payload-examples]

### Session events (session.\*) [#session-events-session]

Every session event contains a flat data structure with session info. Terminal
KYC events such as `session.completed` also include the enriched `applicant`
and `verifications` blocks. If the flow contains phone or email OTP steps, the
verified values are included as `data.applicant.phone` and
`data.applicant.email`.

The top-level `result` field is the source of truth for the final session
decision. The `verifications` array may include historical retry attempts for a
check. Each verification item includes `attempt_number` and `is_current` when
available; use `is_current: true` as the current per-check result. For example,
if document liveness failed on attempt 1 and passed on attempt 2, the terminal
approved payload may contain both rows, with only the attempt 2 row marked as
current.

```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
        }
      }
    ]
  }
}
```

The full session field schema is in the [API reference (Swagger / OpenAPI)](/en/api/api-reference).

### Verification events (verification.\*) [#verification-events-verification]

Verification events contain full session info, applicant data extracted from the document, and the results of every check.

**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"
        }
      }
    ]
  }
}
```

The full field schema with types, formats, and descriptions is in the [API reference (Swagger / OpenAPI)](/en/api/api-reference).

### Step events (step.\*) [#step-events-step]

Step events carry information about the execution of a single verification 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"
    }
  }
}
```

**Extra fields for `step.failed`:**

| Field | Description |
|------|----------|
| `error_code` | Error code |
| `error_message` | Error description |
| `max_attempts` | Maximum allowed number of attempts (lets you tell whether this was the last attempt) |

**Extra fields for `step.retried`:**

| Field | Description |
|------|----------|
| `max_attempts` | Maximum allowed number of attempts |
| `reason` | Present only for applicant-initiated revisions: "applicant_revision". The applicant went back and changed a completed step; data you stored from the previous step.completed for this step is superseded. |

### Document events (document.\*) [#document-events-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"
  }
}
```

> Intermediate OCR / liveness / face-match / AML-screening / proof-of-address events are NOT delivered as separate webhook events. Their results are included in the `verification.approved` / `verification.declined` / `verification.needs_review` payload (the `verifications` array). Proof-of-address payloads include the redacted summary only; raw GPS coordinates and storage keys are not sent. See the examples above.

### AML screening events (aml.\*) [#aml-screening-events-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
  }
}
```

#### The `aml.enrichment` field (Kontur.Focus) [#the-amlenrichment-field-konturfocus]

> **Status:** Available since 2026-04-24. Enabled at the AML screening step level through the `enable_focus_enrichment` flag in the flow configuration (default `true`).

After the main AML screening against Kontur.Compliance lists (sanctions, terrorists, PEP, bankruptcy, and others), an additional enrichment stage runs through the **Kontur.Focus API**. This stage enriches the applicant decision with data from 13 Focus endpoints and produces a merged decision that records its source.

Enrichment results are included in the `aml.screening_completed` payload as an `enrichment` object:

```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": { "...": "see below" },
      "focus_sanctions": { "...": "see below" },
      "focus_pep": { "...": "see below" },
      "focus_person_bankruptcy": { "...": "see below" },
      "focus_affiliates": { "...": "see below" },
      "focus_smz_status": { "...": "see below" },
      "focus_fssp_summary": { "...": "see below" },
      "focus_court_summary": { "...": "see below" }
    }
  }
}
```

**Enrichment fields:**

| Field | Type | Description |
|------|-----|----------|
| `inn` | string | Applicant INN detected by the pipeline (source in `inn_source`) |
| `inn_source` | string | INN source: `ocr` / `compliance` / `focus_search` / `focus_affiliates` / `manual` |
| `inn_confidence` | number | Confidence in the detected INN (0.00–1.00) |
| `compliance_decision` | string | Kontur.Compliance decision before merging with Focus: `approve` / `decline` / `review` |
| `compliance_match_count` | int | Number of matches against Compliance lists |
| `merged_decision` | string | Final merged decision |
| `merged_matches_count` | int | Total number of matches (Compliance + Focus) |
| `merged_risk_score` | number | Final risk score (0.000–1.000 in the database, normalized to 0–100 in the payload) |
| `hard_block_reason` | string \| null | Hard-block reason (see below) |
| `enrichment_status` | string | `pending` / `running` / `completed` / `failed` |
| `enrichment_completed_at` | string (RFC3339) | When enrichment finished |
| `call_statuses` | object | Status of each Focus call: `completed` / `error` / `skipped` / `skipped_no_inn` |

**The `focus_passport` field** — Focus-side passport check:

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

Source: the `checkPassport` endpoint of the Focus API. It is used as a second source of passport validation, in addition to the built-in `passport_check` of the verification step.

**The `focus_sanctions` field** — hits on Focus sanctions lists:

```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"
}
```

**The `focus_pep` field** — hits in the PEP registry:

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

**The `focus_person_bankruptcy` field** — bankruptcy of individuals:

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

When bankruptcy is detected, item fields include `case_number`, `arbitr_court`, `stage` (observation / financial recovery / external administration / bankruptcy proceedings / asset liquidation), `start_date`, `manager_full_name`, and others.

**The `focus_affiliates` field** — related companies (where the applicant is a director / founder / beneficiary):

```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"
}
```

The set of affiliates fields can be restricted with the `affiliate_fields_visibility` setting in the AML step configuration — for example, to hide the ownership share from non-administrators.

**The `focus_smz_status` field** — self-employed (NPD) status:

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

Possible `status` values: `registered` (the applicant is self-employed), `not_registered`, `unknown` (the check could not be completed — for example, there is no INN or the tax authority refused).

**The `focus_fssp_summary` field** — records at the Federal Bailiff Service (individual entrepreneurs only):

```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"
}
```

**The `focus_court_summary` field** — court cases (individual entrepreneurs only):

```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 rules [#hard-block-rules]

A non-empty `hard_block_reason` means that Focus data led to **automatic rejection** regardless of the final risk_score. Possible values:

| Value | Description |
|----------|----------|
| `passport_invalid` | Focus reported that the passport is invalid |
| `bankruptcy_asset_liquidation` | The applicant is at the "asset liquidation" stage of bankruptcy |
| `focus_sanctions_hit` | A match was found in Focus sanctions lists |
| `affiliate_sanctions_hit` | A company related to the applicant is under sanctions |
| `fssp_debt_exceeds_threshold` | The bailiff-service debt exceeds the configured `hard_block_min_fssp_debt_rub` threshold |

Each of these rules can be disabled separately through the AML step configuration (`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` with a value of 0).

#### Per-endpoint flags [#per-endpoint-flags]

Each Focus call can be enabled or disabled individually in the AML step configuration (section **Flow → AML screening step**):

| Flag | Focus endpoint | Description |
|------|----------------|----------|
| `enable_focus_enrichment` | — | Global switch for the whole enrichment stage |
| `enable_sanctioned_persons_check` | `sanctionedPersons` | Search in Focus sanctions lists |
| `enable_pep_search_check` | `pepSearch` | Search in the PEP registry |
| `enable_person_bankruptcy_check` | `personBankruptcy` | Bankruptcy check for individuals |
| `enable_passport_invalid_check` | `checkPassport` | Passport check with a second source |
| `enable_person_affiliates` | `personAffiliates` | Search for related companies |
| `enable_smz_check` | `smzSend` + `smzGetStatus` | Self-employed status check |
| `enable_fssp_check` | `fssp` | Bailiff-service check (individual entrepreneurs only) |
| `enable_court_cases_check` | `generalCourtCases` | Court case check (individual entrepreneurs only) |

If the global `enable_focus_enrichment = false`, the `enrichment` field is absent from the payload and the decision is based only on Kontur.Compliance data.

#### Enrichment lifecycle and coverage [#enrichment-lifecycle-and-coverage]

KYC terminal events include `data.enrichment` when an applicant enrichment record exists. Its `scope` is `applicant`: this is the latest applicant aggregate, not proof that enrichment ran for this particular session. The `summary` includes `status`, observation/completion timestamps, endpoint `call_statuses`, merged decision, risk score and match count when known, and optional passport/bankruptcy findings.

```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
    }
  }
}
```

Absent findings are unknown. A skipped or failed endpoint is not a negative check. Known zero scores/counts and known false findings remain explicit. Raw vendor responses and identifiers such as INN, SNILS and passport numbers are not part of this summary.

Enrichment can finish after a terminal event. Subscribe to `verification.enrichment.updated` for subsequent completed or failed updates, correlated by `session_id`, `applicant_id` and attempt. The update uses the same applicant-scoped summary. An unavailable terminal snapshot is reported through `enrichment_errors`; it must not be treated as a clean result.

#### Terminal identity and evidence [#terminal-identity-and-evidence]

Automatic and manually reviewed KYC terminal/decision events use the same evidence projection. `applicant.full_name` remains the existing display value. `full_name_latin` is emitted only for an observed Latin-script name. `full_name_native` and `full_name_original` retain original document spelling when available; no transliteration is invented. `name_source` identifies the document result, step and attempt; `name_evidence` retains reconciliation alternatives. `localized` preserves provider field labels and adds canonical aliases. Missing names remain absent for flows without identity evidence.

Terminal snapshots also include available `verifications`, `decision_reasons`, `custom_steps`, `document_collections`, `media_files`, safe `risk_signals` and device summaries. Applicant identity uses current attempts. Verification entries retain `is_current` so consumers can distinguish superseded evidence. Optional read failures appear as stable codes in `enrichment_errors` rather than silently implying an empty successful check.

### Verification link events (link.\*) [#verification-link-events-link]

Every link event carries the full verification-link record:

```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"
    }
  }
}
```

The full link field schema is in the [API reference (Swagger / OpenAPI)](/en/api/api-reference).

### OCR events (verification.ocr.\*) [#ocr-events-verificationocr]

**verification.ocr.corrected** — an operator edited OCR results in the admin panel:

```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 events (verification.resubmit_\*) [#manual-review-resubmit-events-verificationresubmit_]

Events for step resubmission requests by manual review operators.

`request_id` identifies one resubmit attempt and correlates its fulfillment. `scope` is `step` (also the legacy default) or `flow` for a full KYB restart. `step_id` is empty for `flow`. A nonblank own `client_message` permits an empty `reason_code`; both are sent when supplied. Previous attempts remain in history. Internal notes are never sent.

Manual rejection events (`verification.declined`, `session.completed`, and KYB decision events) carry the current `decision_id`, optional `decision_reason_codes`, and optional public `client_message` from that same persisted decision. A comment-only rejection has no invented reason code. Previous comments are never substituted for the current decision, and `internal_notes` remain private.

**verification.resubmit_requested** — a reviewer asked the applicant to resubmit a step:

```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": "Photo is blurry, please re-upload",
    "attempt_number": 1,
    "requested_by_id": "550e8400-e29b-41d4-a716-446655440100",
    "requested_by_name": "Reviewer Name",
    "expires_at": "2026-05-04T12:00:00Z"
  }
}
```

Allowed `reason_code` values: `illegible`, `blurry`, `cropped`, `glare`, `wrong_document`, `expired_document`, `partial`.

**verification.resubmit_fulfilled** — the applicant resubmitted the requested step:

Fulfillment confirms that a new terminal attempt was recorded for the requested step; it does not mean that the step or verification session passed or was approved. The payload includes the same `external_id` identity supplied with the request.

```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 limits: up to 3 requests per session, 7-day request TTL. If the applicant does not resubmit before the TTL expires, the request is cancelled automatically.

## Frequently asked questions [#frequently-asked-questions]

### Lost webhook secret [#lost-webhook-secret]

A webhook secret (with the `whsec_` prefix) cannot be recovered — it is shown only once when you create the endpoint.

**What to do:**

1. Open the OneKYC admin panel.
2. Go to **Webhooks**.
3. Click **"Rotate secret"** next to the endpoint.
4. Copy the new secret — it is shown only once.
5. Update the secret in your application configuration.

**Note:** during rotation, both secrets (old and new) are valid at the same time for a **24-hour grace period**. This keeps webhook delivery uninterrupted while you update credentials. The `X-Onekyc-Signature` header carries both `v1=<old_signature>` and `v2=<new_signature>`.

At the grace deadline, the replacement becomes the primary secret: the header
contains only `v1=<new_signature>`. The old secret no longer signs deliveries,
even if background database cleanup has not run yet. A receiver must accept the
replacement signature in `v2` during grace and in `v1` after completion; use
constant-time HMAC comparison and retain timestamp/replay checks.

To finish early, confirm that the receiver uses the replacement key, then choose
**Complete rotation** in the console. API clients can call
`POST /admin/v1/webhooks/endpoints/{id}/rotate/complete` with
`{"rotation_expires_at":"<expiry returned by the endpoint API>"}`.
A stale expiry returns `409`; refresh the endpoint instead of automatically
retrying a secret mutation. Completion immediately retires the old secret.

### No incoming webhooks [#no-incoming-webhooks]

If webhooks stop arriving, run through the following checks:

**1. Endpoint status in the admin panel**

- Open the **Webhooks** section of the admin panel.
- Check the endpoint status (active / disabled / error).
- Review the delivery log — it lists response codes and error details.

**2. Server reachability**

- The URL must be publicly reachable over HTTPS (HTTP is not supported).
- Make sure your firewall does not block incoming requests from OneKYC.
- Verify the SSL certificate is valid (self-signed certificates are not supported).

**3. Webhook handler code**

- Your endpoint must respond with HTTP **2xx** (200, 201, 204) within 30 seconds.
- If processing takes longer, return `200 OK` immediately and process asynchronously.
- Check your application log for signature validation errors.

**4. Event types**

- Confirm that the required event types are enabled in the endpoint settings.
- If you selected "All events", confirm that events are actually being generated (sessions created, verifications run, etc.).

**5. Dead-letter queue**

If the system cannot deliver a webhook after 5 attempts (with exponential backoff up to 24 hours), the event is moved to the dead-letter queue. Undelivered events are visible in the admin panel, where you can trigger manual redelivery.

**Endpoint testing:**

To validate an endpoint, click **"Send test webhook"** in the admin panel. In the dialog, choose an event type (for example, `verification.approved`) — the system sends a test payload of that type with a valid HMAC-SHA256 signature. The test event identifier carries the `evt_test_` prefix.

### Contacting support [#contacting-support]

If you run into trouble with webhooks, use the following channels:

**1. Documentation and examples**

- Re-read this section and the code examples above.
- See the [API reference (Swagger / OpenAPI)](/en/api/api-reference) for the detailed specification.

**2. Technical support**

- **Email:** info@onekyc.io
- **Telegram:** [@OneKYC_Support](https://t.me/OneKYC_Support)
- **Hours:** Mon–Fri, 10:00–19:00 (GMT+6, Almaty)

**3. Critical issues**

For critical issues blocking production:
- Send a request to support with **"URGENT"** in the subject line.
- Include `tenant_id`, the endpoint URL, and sample error log entries.
- A response is provided within 2 hours during business hours.

**4. Bug reports and improvement suggestions**

If you find a bug in the webhook system or have suggestions:
- Open a request through the admin panel customer area.
- Or send the details to info@onekyc.io.

---

For full details on payload format, headers, and retry policy, see the [API reference (Swagger / OpenAPI)](/en/api/api-reference).

## Review comment webhooks [#review-comment-webhooks]

New comments on KYC review tasks emit `verification.comment.created`; new comments on KYB review tasks emit `kyb.review.comment.created`. Comments created in the tenant dashboard, the platform review console, and the tenant API use the same contract. These events do not change the verification decision or complete the session.

Subscribe to the exact event type for the required scope. Existing `*` subscriptions also receive these events. Delivery uses the existing webhook signature, environment filtering, retries, and delivery logs. Events are delivered at least once; consumers must deduplicate using the event `id` or `idempotency_key`. Arrival order is not guaranteed.

Example event data for 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"
  }
}
```

For KYB, `scope` is `kyb` and `business_id` replaces `applicant_id` when present. Author types are `admin_user`, `superadmin_user`, `api_key`, and `system`. Employee identifiers and names are not included in the webhook. All task comments are client-visible; decision `internal_notes` and resubmission `client_message` are separate fields and do not produce comment events. Only newly created comments generate these events; existing comments are not replayed.

Retrieve comments with `GET /tenant/v1/kyc/review-tasks/{task_id}/comments` (`reviews:read`) or `GET /tenant/v1/kyb/review-tasks/{task_id}/comments` (`kyb.reviews:read`). Creating a comment uses `POST` at the same path and the corresponding write scope. The successful creation response adds `comment_id` and `created_at`, matching the saved comment and webhook snapshot. Each successful POST creates a new comment; webhook event deduplication does not make repeated creation requests idempotent. After an uncertain request outcome, reconcile using the comments API before resubmitting.

The comment, webhook event, and outbox entry are committed together. Failure to persist the event rolls back comment creation. HTTP delivery happens asynchronously after commit, so a successful comment response is not proof of successful HTTP webhook delivery; use delivery logs.
