> For the complete documentation index, see [llms.txt](https://finext.gitbook.io/one-kyc/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://finext.gitbook.io/one-kyc/api/webhooks.md).

# 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](/one-kyc/kyb/webhooks.md))
* 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

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

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

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

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

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

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

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

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

| 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

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

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

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

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

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

### 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)](/one-kyc/api/api-reference.md).

### 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)](/one-kyc/api/api-reference.md).

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

### 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\_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` field (Kontur.Focus)

> **Status:** Generally available since 2026-04-24. Toggled at the AML-screening step level via the `enable_focus_enrichment` flag in the flow configuration (default `true`).

After the primary AML screening against Kontur.Compliance lists (sanctions, terrorists, PEP, bankruptcy, etc.), an extra enrichment stage runs through **Kontur.Focus API**. This stage augments the applicant decision with data from 13 Focus endpoints and produces a merged decision tagged with the 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's INN discovered by the pipeline (source listed in `inn_source`)             |
| `inn_source`              | string           | INN source: `ocr` / `compliance` / `focus_search` / `focus_affiliates` / `manual`      |
| `inn_confidence`          | number           | Confidence in the discovered INN (0.00–1.00)                                           |
| `compliance_decision`     | string           | Kontur.Compliance decision before merging with Focus: `approve` / `decline` / `review` |
| `compliance_match_count`  | int              | Number of Compliance list matches                                                      |
| `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 DB, normalised to 0–100 in payload)                   |
| `hard_block_reason`       | string \| null   | Reason for hard block (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`        |

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

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

Source: the `checkPassport` Focus API endpoint. Used as a second source for passport verification in addition to the built-in `passport_check` of the verification step.

**`focus_sanctions` field** — hits against 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"
}
```

**`focus_pep` field** — PEP register hit:

```json
{
  "items": [
    {
      "full_name": "IVANOV IVAN IVANOVICH",
      "category": "national_pep",
      "position": "State Duma deputy",
      "checked_at": "2026-04-24T10:15:00Z"
    }
  ],
  "source": "focus.pepSearch"
}
```

**`focus_person_bankruptcy` field** — individual bankruptcy:

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

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

**`focus_affiliates` field** — affiliated companies (where the applicant is a director / founder / beneficial owner):

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

You can restrict the affiliate fields via the `affiliate_fields_visibility` setting in the AML step configuration — for example, hide ownership share from non-admin roles.

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

`status` values: `registered` (self-employed), `not_registered`, `unknown` (could not be checked — for example, no INN or FNS refusal).

**`focus_fssp_summary` field** — Federal Bailiff Service records (sole proprietors only):

```json
{
  "total_debt_rub": 1250000,
  "open_cases_count": 3,
  "items": [
    {
      "case_number": "12345/24/77001-IP",
      "debt_amount_rub": 500000,
      "subject": "Alimony",
      "status": "open"
    }
  ],
  "checked_at": "2026-04-24T10:15:00Z",
  "source": "focus.fssp"
}
```

**`focus_court_summary` field** — court cases (sole proprietors only):

```json
{
  "total_cases": 5,
  "as_plaintiff": 2,
  "as_defendant": 3,
  "items": [
    {
      "case_number": "A40-12345/2024",
      "court_name": "Moscow Arbitration Court",
      "role": "defendant",
      "status": "in_progress",
      "registration_date": "2024-03-15"
    }
  ],
  "checked_at": "2026-04-24T10:15:00Z",
  "source": "focus.generalCourtCases"
}
```

#### Hard-block rules

A non-empty `hard_block_reason` means Focus data triggered an **automatic rejection** regardless of the final risk\_score. Possible values:

| Value                          | Description                                                               |
| ------------------------------ | ------------------------------------------------------------------------- |
| `passport_invalid`             | Focus reported 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 affiliated with the applicant is under sanctions                |
| `fssp_debt_exceeds_threshold`  | FSSP debt exceeds the configured `hard_block_min_fssp_debt_rub` threshold |

Each rule can be disabled individually in 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` set to 0).

#### Per-endpoint flags

You can toggle each Focus call individually in the AML step configuration (under **Flows → AML screening step**):

| Flag                              | Focus endpoint             | Description                                   |
| --------------------------------- | -------------------------- | --------------------------------------------- |
| `enable_focus_enrichment`         | —                          | Global switch for the entire enrichment stage |
| `enable_sanctioned_persons_check` | `sanctionedPersons`        | Search against Focus sanctions lists          |
| `enable_pep_search_check`         | `pepSearch`                | PEP register search                           |
| `enable_person_bankruptcy_check`  | `personBankruptcy`         | Individual bankruptcy check                   |
| `enable_passport_invalid_check`   | `checkPassport`            | Passport verification with a second source    |
| `enable_person_affiliates`        | `personAffiliates`         | Affiliated companies search                   |
| `enable_smz_check`                | `smzSend` + `smzGetStatus` | Self-employed status check                    |
| `enable_fssp_check`               | `fssp`                     | FSSP check (sole proprietors only)            |
| `enable_court_cases_check`        | `generalCourtCases`        | Court cases check (sole proprietors only)     |

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

### 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)](/one-kyc/api/api-reference.md).

### OCR events (verification.ocr.\*)

**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\_\*)

Events for step resubmission requests by manual review operators.

**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",
    "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:

```json
{
  "type": "verification.resubmit_fulfilled",
  "data": {
    "session_id": "550e8400-e29b-41d4-a716-446655440002",
    "flow_id": "550e8400-e29b-41d4-a716-446655440003",
    "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

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

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

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)](/one-kyc/api/api-reference.md) 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)](/one-kyc/api/api-reference.md).
