# KYB webhooks

The KYB webhook system delivers business verification event notifications to your server in real time. The delivery mechanism mirrors KYC webhooks: HMAC-SHA256 signing, exponential backoff between retries, and idempotency support.

**Key properties:**

- 12 event types covering the full KYB verification lifecycle
- The `kyb.business.verified`, `kyb.business.rejected`, and `kyb.business.review_required` events carry the **full business data** in the `data` field (matching the `GET /v1/kyb/businesses/{id}` API response): legal form, directors, beneficial owners with UBO fields, the KYB input `profile` (`email`, `phone`, `trade_name`, `business_type`, and related profile fields), rejection reasons, last verification timestamp, and other fields
- HMAC-SHA256 signing for delivery integrity (the `X-Onekyc-Signature` header)
- Automatic retries with exponential backoff (up to 5 attempts)
- SHA-256 idempotency keys to prevent duplicate processing
- Delivery log in the **Webhooks** section of the admin panel

You configure endpoints in the **Webhooks** section of the admin panel (the same section as KYC). When creating or editing an endpoint, choose the **KYB events** category and pick the event types you need (or select "All events" to also receive KYB events).

---

## Interpreting the decision [#interpreting-the-decision]

- Company data, `company_status: active`, and `kyb.step.completed` do not establish successful KYB verification.
- For `kyb.business.*`, use `data.verification_status`: `verified`, `rejected`, or `review`. Review requires a reviewer decision and is not approval.
- For `kyb.session.completed`, use `data.result`: `approved` means approval; `rejected` or `declined` means rejection. `status` describes the process. Historical rejections can carry `status: failed`; a completed process can have `status: completed` for either final decision.
- `decision_source` distinguishes `automatic` from `manual_review`. Manual decisions include `decision_id` and, when available, `decision_reason_codes`. Missing fields on historical events do not establish automatic approval.
- A manual approval does not rewrite individual check results. Inspect `verifications`, nested KYB evidence, `beneficiary_checks`, and `decision_reasons` when registry coverage, signature findings or other signals matter to your integration. `ai_confirmation` is not the final decision.
- `business_is_current_session: false` means this is a historical session: do not use its approval to replace the current company decision. An absent flag means this relationship was not established. Use `kyb.business.*` events for changes to the current business verdict.
- The terminal event's nested `business.verification_status` describes this session's decision; underlying lookup results remain available as evidence. Manual rejection codes are distinct from technical check findings.

The decision, KYB business projection and required events are persisted in one transaction. Event-persistence failures return an operation error; HTTP delivery remains asynchronous through the outbox. A 2xx response confirms your server accepted the request, not that its business handling was correct.

Retries can cause repeated or out-of-order deliveries. Use the event ID, session ID and decision timestamp. Each subscribed endpoint receives its own delivery of the same event; matching data does not establish two decisions. Completing a reopened session produces a new event even if its final status is unchanged.

## Event list [#event-list]

| Event | Description |
|---------|----------|
| `kyb.session.created` | KYB session created (the user opened the verification link) |
| `kyb.session.started` | KYB session started (the user began entering data) |
| `kyb.session.completed` | Final KYB session decision (approval or rejection) |
| `kyb.session.expired` | KYB session expired |
| `kyb.session.cancelled` | KYB session cancelled |
| `kyb.business.verified` | Business successfully verified |
| `kyb.business.rejected` | Business rejected |
| `kyb.business.review_required` | Business routed to manual review by an operator |
| `kyb.step.started` | Verification step started (for example, sending the AI analysis request) |
| `kyb.step.completed` | Verification step completed (for example, AI analysis) |
| `kyb.step.failed` | Verification step finished with an error |
| `kyb.step.skipped` | Verification step skipped because an upstream dependency did not pass |

---

## Payload format [#payload-format]

Every KYB event uses a single envelope format, identical to KYC:

```json
{
  "id": "evt_uuid",
  "type": "kyb.business.verified",
  "api_version": "2026-02-01",
  "created_at": "2026-02-24T10:05:00Z",
  "tenant_id": "tenant_uuid",
  "data": { ... }
}
```

---

## Example: kyb.business.verified [#example-kybbusinessverified]

The `data` field contains a **full snapshot of the business** (matching the field set returned by `GET /v1/kyb/businesses/{id}`), including the `profile` block with fields entered during the KYB flow:

```json
{
  "id": "evt_kyb_biz_001",
  "type": "kyb.business.verified",
  "api_version": "2026-02-01",
  "created_at": "2026-02-24T10:05:00Z",
  "tenant_id": "tenant_uuid",
  "data": {
    "business_id": "biz_uuid",
    "external_id": "company-001",
    "session_id": "sess_uuid",
    "last_session_id": "sess_uuid",
    "company_name": "Acme Corp Ltd",
    "registration_number": "KZ123456789",
    "tax_number": "123456789012",
    "country": "KZ",
    "legal_form": "TOO",
    "company_status": "active",
    "status_date": "2015-06-15",
    "registration_date": "2015-06-15",
    "legal_address": "Almaty, Primernaya St. 1",
    "actual_address": "Almaty, Primernaya St. 1",
    "main_url": "https://acmecorp.kz",
    "profile": {
      "email": "compliance@acmecorp.kz",
      "phone": "+77271234567",
      "trade_name": "Acme",
      "website": "https://acmecorp.kz",
      "business_type": "b2b",
      "business_activity": "Software development",
      "ownership_structure": "Private company"
    },
    "director_name": "Ivanov Ivan Ivanovich",
    "director_title": "CEO",
    "directors": [{ "name": "Ivanov Ivan Ivanovich", "title": "CEO" }],
    "beneficiaries": [{ "name": "Petrov P.P.", "share": "60%", "type": "natural_person", "country": "KZ", "pep": false }],
    "ai_confirmation": true,
    "ai_summary": "Company has been operating since 2015, no adverse findings.",
    "ai_sources": ["gosreestr.kz", "kgd.gov.kz"],
    "status_evidence_sources": ["gosreestr.kz"],
    "risk_score": 8.5,
    "rejection_reasons": [],
    "last_verified_at": "2026-02-24T10:05:00Z",
    "verification_status": "verified",
    "business_document_key": "business-docs/tenant_uuid/biz_uuid/charter.pdf",
    "created_at": "2026-02-24T09:00:00Z",
    "updated_at": "2026-02-24T10:05:00Z"
  }
}
```

**`data` field reference:**

| Field | Type | Description |
|------|-----|----------|
| `business_id` | string | UUID of the business in OneKYC |
| `external_id` | string | External identifier of the business in your system |
| `session_id` | string | UUID of the current verification session |
| `last_session_id` | string | UUID of the latest session (same as `session_id` for the first verification) |
| `company_name` | string | Company name |
| `registration_number` | string | Company registration number |
| `tax_number` | string | INN / BIN |
| `country` | string | ISO country code of registration |
| `legal_form` | string | Legal form |
| `company_status` | string | Company status in the state register (`active`, `liquidating`, `liquidated`, etc.) |
| `status_date` | string | Date the current status was assigned |
| `status_info` | string | Additional status information |
| `liquidation_date` | string | Liquidation date (when applicable) |
| `legal_address` | string | Legal address |
| `actual_address` | string | Operating address |
| `main_url` | string | Official website address |
| `profile` | object | Company profile fields entered during the KYB flow (`email`, `phone`, `trade_name`, `website`, `business_type`, activity fields, ownership structure, identifiers) |
| `registration_date` | string | State registration date |
| `director_name` | string | Director's full name |
| `director_title` | string | Director's title |
| `directors` | array | List of directors `[{name, title}]` |
| `beneficiaries` | array | List of beneficial owners `[{name, share, type, country, pep}]` |
| `activities` | object | Activity types `{main, additional}` |
| `ai_confirmation` | boolean | AI confirmation result |
| `ai_summary` | string | Short AI analysis summary |
| `ai_notes` | string | Additional AI analysis notes |
| `ai_sources` | array | Sources used during AI analysis |
| `director_unavailable_reason_info` | string | Reason director data is unavailable |
| `beneficiaries_unavailable_reason_info` | string | Reason beneficial owner data is unavailable |
| `activities_unavailable_reason_info` | string | Reason activity data is unavailable |
| `main_url_unavailable_reason_info` | string | Reason website information is unavailable |
| `status_evidence_sources` | array | Sources for the company status data |
| `director_evidence_sources` | array | Sources for the director data |
| `beneficiaries_evidence_sources` | array | Sources for the beneficial owner data |
| `activities_evidence_sources` | array | Sources for the activity data |
| `main_url_evidence_sources` | array | Sources for the website data |
| `risk_score` | number | Risk score (0–100) |
| `rejection_reasons` | array | Rejection reasons (for the `rejected` status) |
| `last_verified_at` | string | Last verification timestamp (RFC 3339) |
| `verification_status` | string | Verification status: `verified`, `rejected`, `review`, `pending` |
| `business_document_key` | string | Business document key in object storage |
| `metadata` | object | Additional metadata |
| `created_at` | string | Record creation timestamp (RFC 3339) |
| `updated_at` | string | Record update timestamp (RFC 3339) |

> Fields with empty or zero values may be absent from the payload.

---

## Example: kyb.business.rejected [#example-kybbusinessrejected]

The `data` field carries the full business data, including the `rejection_reasons` array describing the rejection reasons:

```json
{
  "id": "evt_kyb_biz_002",
  "type": "kyb.business.rejected",
  "api_version": "2026-02-01",
  "created_at": "2026-02-24T10:08:00Z",
  "tenant_id": "tenant_uuid",
  "data": {
    "business_id": "biz_uuid",
    "external_id": "company-002",
    "session_id": "sess_uuid_2",
    "company_name": "Shell Corp",
    "registration_number": "XX999999",
    "country": "XX",
    "legal_form": "LLC",
    "risk_score": 72.3,
    "ai_confirmation": false,
    "rejection_reasons": [
      "company_not_found_in_registry",
      "high_risk_jurisdiction"
    ],
    "verification_status": "rejected",
    "created_at": "2026-02-24T09:00:00Z",
    "updated_at": "2026-02-24T10:08:00Z"
  }
}
```

---

## Example: kyb.business.review_required [#example-kybbusinessreview_required]

The `data` field carries the full business data (including `directors`, `beneficiaries`, `legal_form`, and the rest):

```json
{
  "id": "evt_kyb_biz_003",
  "type": "kyb.business.review_required",
  "api_version": "2026-02-01",
  "created_at": "2026-02-24T10:10:00Z",
  "tenant_id": "tenant_uuid",
  "data": {
    "business_id": "biz_uuid",
    "external_id": "company-003",
    "session_id": "sess_uuid_3",
    "company_name": "Ambiguous LLC",
    "registration_number": "KZ111222333",
    "country": "KZ",
    "legal_form": "AO",
    "directors": [{ "name": "Sidorov S.S.", "title": "Director" }],
    "beneficiaries": [{ "name": "OOO Holding", "share": "100%", "type": "legal_entity" }],
    "risk_score": 45.0,
    "ai_confirmation": false,
    "verification_status": "review",
    "created_at": "2026-02-24T09:00:00Z",
    "updated_at": "2026-02-24T10:10:00Z"
  }
}
```

---

## Example: kyb.session.completed [#example-kybsessioncompleted]

```json
{
  "id": "evt_kyb_sess_001",
  "type": "kyb.session.completed",
  "api_version": "2026-02-01",
  "created_at": "2026-02-24T10:04:00Z",
  "tenant_id": "tenant_uuid",
  "data": {
    "session_id": "sess_uuid",
    "flow_id": "flow_uuid",
    "external_id": "company-001",
    "status": "completed",
    "scope": "kyb",
    "result": "approved",
    "business_id": "biz_uuid",
    "completed_at": "2026-02-24T10:04:00Z"
  }
}
```

---

## Signature verification [#signature-verification]

The mechanism is identical to KYC webhooks. Every request carries the header:

```
X-Onekyc-Signature: t=<unix_timestamp>,v1=<hex_signature>
```

During secret rotation, `v2=<new_signature>` is added. For the full description, see [KYC webhooks](/en/api/webhooks#verifying-the-signature).

Signature verification algorithm:

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

function verifyWebhook(rawBody, signatureHeader, secret) {
  const parts = signatureHeader.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)
  if (Math.floor(Date.now() / 1000) - parseInt(timestamp) > 300) {
    throw new Error('Webhook timestamp is too old');
  }

  // Build the signed string: timestamp.raw_body
  const signedPayload = `${timestamp}.${rawBody}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1Signature));
}
```

---

## Retry policy [#retry-policy]

If delivery fails (a non-2xx response or a 30-second timeout), OneKYC retries with exponential backoff. The base delay is 5 seconds, doubled on each attempt, with a maximum interval of 24 hours:

| Attempt | Approximate delay |
|---------|--------------------------|
| 1 | ~5 s |
| 2 | ~10 s |
| 3 | ~20 s |
| 4 | ~40 s |
| 5 | Final attempt, then DLQ if delivery still fails |

Delays vary by ±20% (jitter). The system performs up to 5 total delivery attempts. After all attempts are exhausted, the event is moved to the dead-letter queue. You can trigger redelivery manually from the **Webhooks** section of the admin panel.

## Terminal business evidence [#terminal-business-evidence]

`kyb.session.completed` includes available `business_id`, `external_id`, top-level `company_name` and the `business` profile, for both automatic and reviewer decisions. The profile contains available registration, tax, jurisdiction, address, legal form, director/beneficiary and lookup evidence. It uses this session's collected evidence or its associated persisted business; another session's business profile is not substituted.

Available current verifications, document analysis, uploaded `media_files`, decision reasons, custom steps, risk/device summaries and beneficiary KYC checks are included. Lookup results preserve outcome, reason, coverage, consulted sources and next actions. Provider raw payloads, internal references and storage paths are excluded from document analysis.

For business people, `is_pep` and `is_sanctioned` are optional. `false` means an observed negative finding; an absent field means unknown or unchecked. Do not coerce absence to `false`.

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