> ## Documentation Index
> Fetch the complete documentation index at: https://docs.compliance.legaltalent.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Session Webhooks

> Real-time notifications for KYC session lifecycle events

Session webhooks notify your application in real-time as users progress through KYC onboarding. Receive instant updates when sessions are created, completed, processed, or when their status changes.

## Event Types

<CardGroup cols={2}>
  <Card title="kyc.session.created" icon="plus">
    New session created via your backend
  </Card>

  <Card title="kyc.session.completed" icon="check">
    User finished all workflow steps
  </Card>

  <Card title="kyc.session.processed" icon="gears">
    Processing complete with decision
  </Card>

  <Card title="kyc.session.approved" icon="circle-check">
    Session manually approved
  </Card>

  <Card title="kyc.session.rejected" icon="circle-xmark">
    Session manually rejected
  </Card>

  <Card title="kyc.session.manual_review" icon="user-clock">
    Sent to manual review queue
  </Card>

  <Card title="kyc.session.correction_requested" icon="pen-to-square">
    Reviewer requested customer remediation
  </Card>

  <Card title="kyc.session.correction_resolved" icon="rotate-right">
    Customer resolved a remediation request
  </Card>
</CardGroup>

## Session Lifecycle

```mermaid theme={null}
stateDiagram-v2
    [*] --> Created: POST /sessions
    Created --> Completed: User finishes steps
    Completed --> Processing: Auto-process or POST /process
    Processing --> Approved: auto_approve
    Processing --> Flagged: flag
    Processing --> Rejected: auto_deny
    Processing --> ManualReview: manual_review
    Flagged --> Approved: (already approved)
    ManualReview --> Remediation: Corrections requested
    Approved --> Remediation: Corrections requested
    Rejected --> Remediation: Corrections requested
    Remediation --> ManualReview: User resolves corrections
    Remediation --> Completed: User resolves corrections
    ManualReview --> Approved: PATCH approved
    ManualReview --> Rejected: PATCH rejected
```

<Note>
  **Flagged vs Manual Review**: A `flagged` session is automatically approved but marked for attention. The session status is `APPROVED`, but `final_decision` is `flagged`. Use this to approve users while tracking risk signals for monitoring.
</Note>

| Event                              | When It Fires                                              |
| ---------------------------------- | ---------------------------------------------------------- |
| `kyc.session.created`              | When you create a new session via `POST /kyc/sessions`     |
| `kyc.session.completed`            | When the end-user completes the final step in the workflow |
| `kyc.session.processed`            | After session processing completes with automation results |
| `kyc.session.approved`             | When a compliance officer manually approves a session      |
| `kyc.session.rejected`             | When a compliance officer manually rejects a session       |
| `kyc.session.manual_review`        | When a session is flagged for manual review                |
| `kyc.session.correction_requested` | When a reviewer requests customer remediation              |
| `kyc.session.correction_resolved`  | When the customer completes a corrected step               |

***

## Configuration

### Enable Session Webhooks

Configure your webhook endpoint in your tenant's notification settings:

<CodeGroup>
  ```bash cURL theme={null}
  curl -X PATCH https://kyc.legaltalent.ai/kyc/tenants/me/notification-channels \
    -H "Authorization: Bearer YOUR_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
      "webhook_url": "https://your-server.com/webhooks/kyc"
    }'
  ```

  ```python Python theme={null}
  import requests

  response = requests.patch(
      "https://kyc.legaltalent.ai/kyc/tenants/me/notification-channels",
      headers={"Authorization": f"Bearer {token}"},
      json={
          "webhook_url": "https://your-server.com/webhooks/kyc"
      }
  )
  ```

  ```javascript JavaScript theme={null}
  const response = await fetch(
    'https://kyc.legaltalent.ai/kyc/tenants/me/notification-channels',
    {
      method: 'PATCH',
      headers: {
        'Authorization': `Bearer ${token}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        webhook_url: 'https://your-server.com/webhooks/kyc'
      })
    }
  );
  ```
</CodeGroup>

### Response

```json theme={null}
{
  "status": "success",
  "data": {
    "notification_config": {
      "webhook_url": "https://your-server.com/webhooks/kyc",
      "webhook_secret": "****************************a1b2",
      "notification_emails": [],
      "slack_webhook_url": null,
      "batch_notifications_enabled": true
    }
  }
}
```

<Info>
  A `webhook_secret` is automatically generated when you set a `webhook_url`. Use this secret to verify webhook signatures. The full secret is only shown once - store it securely.
</Info>

### Regenerate Webhook Secret

To generate a new secret (invalidating the old one):

```bash theme={null}
curl -X PATCH https://kyc.legaltalent.ai/kyc/tenants/me/notification-channels \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "webhook_secret": null
  }'
```

Then set the webhook\_url again to generate a new secret.

***

## Webhook Delivery

### Request Format

Webhooks are delivered as `POST` requests with the following structure:

**Headers:**

```http theme={null}
POST /webhooks/kyc HTTP/1.1
Host: your-server.com
Content-Type: application/json
User-Agent: KYC-Webhooks/1.0
X-Webhook-ID: evt_7f8a9b2c3d4e5f6a7b8c9d0e
X-Webhook-Timestamp: 1735228800
X-Webhook-Signature: sha256=a1b2c3d4e5f6789012345678901234567890abcdef...
```

**Body (Envelope):**

```json theme={null}
{
  "id": "evt_7f8a9b2c3d4e5f6a7b8c9d0e",
  "type": "kyc.session.processed",
  "created": 1735228800,
  "data": {
    // Event-specific payload
  }
}
```

### Headers Reference

| Header                | Type    | Description                                                  |
| --------------------- | ------- | ------------------------------------------------------------ |
| `X-Webhook-ID`        | string  | Unique event identifier (`evt_<uuid>`). Use for idempotency. |
| `X-Webhook-Timestamp` | integer | Unix timestamp when the event was created                    |
| `X-Webhook-Signature` | string  | HMAC-SHA256 signature prefixed with `sha256=`                |
| `User-Agent`          | string  | Always `KYC-Webhooks/1.0`                                    |
| `Content-Type`        | string  | Always `application/json`                                    |

***

## Signature Verification

All webhooks are signed using **HMAC-SHA256**. Verify the signature to ensure the webhook is authentic and hasn't been tampered with.

### Signature Algorithm

```
message = "{timestamp}.{payload}"
signature = HMAC-SHA256(webhook_secret, message)
```

Where:

* `timestamp` = Value from `X-Webhook-Timestamp` header
* `payload` = Raw JSON body (the entire envelope)
* `webhook_secret` = Your tenant's webhook secret

### Implementation Examples

<CodeGroup>
  ```python Python theme={null}
  import hmac
  import hashlib
  from fastapi import FastAPI, Request, HTTPException

  app = FastAPI()
  WEBHOOK_SECRET = "whsec_your_secret_here"

  def verify_webhook_signature(payload: bytes, timestamp: str, signature: str) -> bool:
      """Verify HMAC-SHA256 webhook signature."""
      # Extract the signature value (remove 'sha256=' prefix)
      if signature.startswith('sha256='):
          signature = signature[7:]
      
      # Construct the message: timestamp.payload
      message = f"{timestamp}.{payload.decode('utf-8')}"
      
      # Calculate expected signature
      expected = hmac.new(
          key=WEBHOOK_SECRET.encode('utf-8'),
          msg=message.encode('utf-8'),
          digestmod=hashlib.sha256
      ).hexdigest()
      
      # Constant-time comparison to prevent timing attacks
      return hmac.compare_digest(expected, signature)

  @app.post("/webhooks/kyc")
  async def handle_webhook(request: Request):
      # Get raw body
      body = await request.body()
      
      # Get headers
      timestamp = request.headers.get('X-Webhook-Timestamp')
      signature = request.headers.get('X-Webhook-Signature')
      event_id = request.headers.get('X-Webhook-ID')
      
      if not timestamp or not signature:
          raise HTTPException(status_code=400, detail="Missing signature headers")
      
      # Verify signature
      if not verify_webhook_signature(body, timestamp, signature):
          raise HTTPException(status_code=401, detail="Invalid signature")
      
      # Parse and process the event
      payload = await request.json()
      event_type = payload.get('type')
      data = payload.get('data', {})
      
      # Handle different event types
      if event_type == 'kyc.session.processed':
          handle_session_processed(data)
      elif event_type == 'kyc.session.completed':
          handle_session_completed(data)
      
      return {"received": True, "event_id": event_id}
  ```

  ```javascript Node.js (Express) theme={null}
  const express = require('express');
  const crypto = require('crypto');

  const app = express();
  const WEBHOOK_SECRET = 'whsec_your_secret_here';

  // Use raw body for signature verification
  app.use('/webhooks/kyc', express.raw({ type: 'application/json' }));

  function verifyWebhookSignature(payload, timestamp, signature) {
    // Remove 'sha256=' prefix
    const sig = signature.replace('sha256=', '');
    
    // Construct message
    const message = `${timestamp}.${payload.toString()}`;
    
    // Calculate expected signature
    const expected = crypto
      .createHmac('sha256', WEBHOOK_SECRET)
      .update(message)
      .digest('hex');
    
    // Constant-time comparison
    return crypto.timingSafeEqual(
      Buffer.from(expected),
      Buffer.from(sig)
    );
  }

  app.post('/webhooks/kyc', (req, res) => {
    const timestamp = req.headers['x-webhook-timestamp'];
    const signature = req.headers['x-webhook-signature'];
    const eventId = req.headers['x-webhook-id'];
    
    if (!timestamp || !signature) {
      return res.status(400).json({ error: 'Missing signature headers' });
    }
    
    if (!verifyWebhookSignature(req.body, timestamp, signature)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }
    
    const payload = JSON.parse(req.body);
    const eventType = payload.type;
    const data = payload.data;
    
    // Handle event
    switch (eventType) {
      case 'kyc.session.processed':
        handleSessionProcessed(data);
        break;
      case 'kyc.session.completed':
        handleSessionCompleted(data);
        break;
    }
    
    res.json({ received: true, event_id: eventId });
  });
  ```

  ```go Go theme={null}
  package main

  import (
      "crypto/hmac"
      "crypto/sha256"
      "encoding/hex"
      "encoding/json"
      "fmt"
      "io"
      "net/http"
      "strings"
  )

  const webhookSecret = "whsec_your_secret_here"

  func verifySignature(payload []byte, timestamp, signature string) bool {
      // Remove 'sha256=' prefix
      sig := strings.TrimPrefix(signature, "sha256=")
      
      // Construct message
      message := fmt.Sprintf("%s.%s", timestamp, string(payload))
      
      // Calculate expected signature
      mac := hmac.New(sha256.New, []byte(webhookSecret))
      mac.Write([]byte(message))
      expected := hex.EncodeToString(mac.Sum(nil))
      
      return hmac.Equal([]byte(expected), []byte(sig))
  }

  func webhookHandler(w http.ResponseWriter, r *http.Request) {
      body, _ := io.ReadAll(r.Body)
      
      timestamp := r.Header.Get("X-Webhook-Timestamp")
      signature := r.Header.Get("X-Webhook-Signature")
      
      if !verifySignature(body, timestamp, signature) {
          http.Error(w, "Invalid signature", http.StatusUnauthorized)
          return
      }
      
      var payload map[string]interface{}
      json.Unmarshal(body, &payload)
      
      // Process the webhook...
      
      w.Header().Set("Content-Type", "application/json")
      json.NewEncoder(w).Encode(map[string]bool{"received": true})
  }
  ```

  ```ruby Ruby (Sinatra) theme={null}
  require 'sinatra'
  require 'openssl'
  require 'json'

  WEBHOOK_SECRET = 'whsec_your_secret_here'

  def verify_signature(payload, timestamp, signature)
    sig = signature.sub('sha256=', '')
    message = "#{timestamp}.#{payload}"
    
    expected = OpenSSL::HMAC.hexdigest('SHA256', WEBHOOK_SECRET, message)
    
    Rack::Utils.secure_compare(expected, sig)
  end

  post '/webhooks/kyc' do
    payload = request.body.read
    timestamp = request.env['HTTP_X_WEBHOOK_TIMESTAMP']
    signature = request.env['HTTP_X_WEBHOOK_SIGNATURE']
    
    unless verify_signature(payload, timestamp, signature)
      halt 401, { error: 'Invalid signature' }.to_json
    end
    
    data = JSON.parse(payload)
    # Process webhook...
    
    content_type :json
    { received: true }.to_json
  end
  ```
</CodeGroup>

### Replay Attack Prevention

To prevent replay attacks, verify that the timestamp is recent:

```python theme={null}
import time

MAX_TIMESTAMP_AGE = 300  # 5 minutes

def verify_timestamp(timestamp: str) -> bool:
    try:
        ts = int(timestamp)
        now = int(time.time())
        return abs(now - ts) < MAX_TIMESTAMP_AGE
    except ValueError:
        return False
```

***

## Event Payloads

### kyc.session.created

Sent when a new session is created via `POST /kyc/sessions`.

```json theme={null}
{
  "id": "evt_abc123def456",
  "type": "kyc.session.created",
  "created": 1735228800,
  "data": {
    "event_type": "kyc.session.created",
    "tenant_id": "tenant_abc",
    "session_id": "sess_123456",
    "workflow_id": "wf_789012",
    "access_link": "https://kyc.legaltalent.ai/public/sessions/abc123token",
    "expires_at": 1735833600,
    "custom_tags": {
      "department": "onboarding",
      "region": "latam"
    },
    "timestamp": "2024-12-26T12:00:00Z"
  }
}
```

**Data Fields:**

| Field         | Type    | Description                                                                                     |
| ------------- | ------- | ----------------------------------------------------------------------------------------------- |
| `event_type`  | string  | Always `kyc.session.created`                                                                    |
| `tenant_id`   | string  | Your tenant ID                                                                                  |
| `session_id`  | string  | Unique session identifier                                                                       |
| `workflow_id` | string  | ID of the workflow used                                                                         |
| `access_link` | string  | Public URL for end-user to complete KYC                                                         |
| `expires_at`  | integer | Unix timestamp when session expires                                                             |
| `custom_tags` | object  | Key-value metadata from the workflow (snapshot at session creation). `null` if none configured. |
| `timestamp`   | string  | ISO8601 timestamp of the event                                                                  |

***

### kyc.session.completed

Sent when the end-user completes all workflow steps. The session is now ready for processing.

<Note>
  This event means the user has finished their part. Processing (document extraction, validations, automation rules) happens next - either automatically if `auto_process` is enabled, or when you call `POST /kyc/sessions/{id}/process`.
</Note>

```json theme={null}
{
  "id": "evt_def456ghi789",
  "type": "kyc.session.completed",
  "created": 1735230600,
  "data": {
    "event_type": "kyc.session.completed",
    "tenant_id": "tenant_abc",
    "session_id": "sess_123456",
    "workflow_id": "wf_789012",
    "current_step_index": 4,
    "total_steps": 5,
    "completed_at": "2024-12-26T12:30:00Z",
    "timestamp": "2024-12-26T12:30:00Z",
    "form_data": {
      "full_name": {
        "field_id": "field_abc123",
        "name": "Full Name",
        "type": "name",
        "value": "John Doe"
      },
      "date_of_birth": {
        "field_id": "field_xyz789",
        "name": "Date of Birth",
        "type": "date",
        "value": "1990-01-15"
      },
      "is_pep": {
        "field_id": "field_pep456",
        "name": "Are you a PEP?",
        "type": "boolean",
        "value": false
      }
    },
    "custom_tags": {
      "department": "onboarding",
      "region": "latam"
    }
  }
}
```

**Data Fields:**

| Field                | Type           | Description                                                                                                                                                                       |
| -------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `event_type`         | string         | Always `kyc.session.completed`                                                                                                                                                    |
| `tenant_id`          | string         | Your tenant ID                                                                                                                                                                    |
| `session_id`         | string         | Session identifier                                                                                                                                                                |
| `workflow_id`        | string         | Workflow ID used                                                                                                                                                                  |
| `current_step_index` | integer        | Index of the last completed step (0-based)                                                                                                                                        |
| `total_steps`        | integer        | Total number of steps in workflow                                                                                                                                                 |
| `completed_at`       | string         | ISO8601 timestamp when user finished                                                                                                                                              |
| `timestamp`          | string         | ISO8601 timestamp of the event                                                                                                                                                    |
| `form_data`          | object         | Form fields filled by the user (see below)                                                                                                                                        |
| `subsessions`        | array \| null  | KYB only: enriched data for each linked subsession (e.g. UBOs). `null` for non-KYB sessions. See [UBO subsessions and extracted facts](#kyb-ubo-subsessions-and-extracted-facts). |
| `extracted`          | object \| null | Curated facts extracted from this session's own documents. `null` when there are none. See [UBO subsessions and extracted facts](#kyb-ubo-subsessions-and-extracted-facts).       |
| `custom_tags`        | object         | Key-value metadata from the workflow. `null` if none configured.                                                                                                                  |

#### Form Data Object

The `form_data` object contains all form fields completed by the user, organized by a portable key. This makes it easy to access specific fields programmatically without relying on auto-generated field IDs.

**Structure:**

```json theme={null}
{
  "<field_key>": {
    "field_id": "field_abc123",
    "name": "Human-readable label",
    "type": "field_type",
    "value": "user_entered_value"
  }
}
```

| Property                | Type   | Description                                                                       |
| ----------------------- | ------ | --------------------------------------------------------------------------------- |
| Key (e.g., `full_name`) | string | The `field_key` defined in the workflow, or `field_id` if no key was set          |
| `field_id`              | string | Auto-generated field identifier                                                   |
| `name`                  | string | Human-readable field label                                                        |
| `type`                  | string | Field type: `text`, `date`, `boolean`, `select`, `name`, `email`, `country`, etc. |
| `value`                 | any    | The value entered by the user                                                     |

<Tip>
  **Portable Field Keys**: Define a `field_key` (e.g., `full_name`, `date_of_birth`) in your workflow to get consistent keys across environments. This eliminates the need to map auto-generated `field_id` values between staging and production.
</Tip>

***

### kyc.session.processed

Sent after session processing completes. This is the most important webhook for automation - it contains the final decision and all automation rule evaluations.

The `final_decision` can be one of:

* `approved` - Session automatically approved, no issues found
* `flagged` - Session approved but marked for attention (e.g., high-risk country, unusual patterns)
* `rejected` - Session automatically denied (e.g., sanctions match, fraud detected)
* `manual_review` - Requires human review before final decision

<Tabs>
  <Tab title="Manual Review Example">
    ```json theme={null}
    {
      "id": "evt_ghi789jkl012",
      "type": "kyc.session.processed",
      "created": 1735231200,
      "data": {
        "event_type": "kyc.session.processed",
        "tenant_id": "tenant_abc",
        "session_id": "sess_123456",
        "workflow_id": "wf_789012",
        "status": "manual_review",
        "final_decision": "manual_review",
        "automation_result": {
          "enabled": true,
          "final_action": "manual_review",
          "triggered_rules": [
            {
              "rule_type": "list_match",
              "rule_details": {
                "list_type": "ofac",
                "match_level": "high_confidence"
              },
              "action": "manual_review",
              "severity": "warning",
              "message": "Potential OFAC match found (85% confidence)"
            },
            {
              "rule_type": "country",
              "rule_details": {
                "field": "nationality",
                "countries": ["IR", "KP", "SY"]
              },
              "action": "flag",
              "severity": "info",
              "message": "Nationality requires additional review"
            }
          ],
          "risk_score": 45.5,
          "flags": ["potential_sanctions_match", "high_risk_nationality"],
          "recommendation": "Manual review required due to potential sanctions match",
          "face_dedup_result": {
            "found_matches": false,
            "previous_sessions": 0,
            "approved_sessions": 0,
            "rejected_sessions": 0,
            "pending_sessions": 0,
            "matched_session_ids": [],
            "similarity_scores": []
          },
          "email_verification_result": {
            "email": "user@example.com",
            "is_valid": true,
            "is_disposable": false,
            "is_role_based": false,
            "is_deliverable": true
          },
          "evaluated_at": "2024-12-26T12:35:00Z",
          "evaluation_time_ms": 1250
        },
        "form_data": {
          "full_name": {
            "field_id": "field_abc123",
            "name": "Full Name",
            "type": "name",
            "value": "John Doe"
          },
          "nationality": {
            "field_id": "field_nat456",
            "name": "Nationality",
            "type": "country",
            "value": "IR"
          }
        },
        "custom_tags": {
          "department": "onboarding",
          "region": "latam"
        },
        "timestamp": "2024-12-26T12:35:00Z"
      }
    }
    ```
  </Tab>

  <Tab title="Flagged Example">
    ```json theme={null}
    {
      "id": "evt_xyz789abc012",
      "type": "kyc.session.processed",
      "created": 1735231200,
      "data": {
        "event_type": "kyc.session.processed",
        "tenant_id": "tenant_abc",
        "session_id": "sess_789012",
        "workflow_id": "wf_789012",
        "status": "approved",
        "final_decision": "flagged",
        "automation_result": {
          "enabled": true,
          "final_action": "flag",
          "triggered_rules": [
            {
              "rule_type": "country",
              "rule_details": {
                "field": "nationality",
                "countries": ["RU", "BY"]
              },
              "action": "flag",
              "severity": "info",
              "message": "Nationality from high-risk region - approved with monitoring"
            },
            {
              "rule_type": "volume",
              "rule_details": {
                "threshold": 50000,
                "declared_value": 75000
              },
              "action": "flag",
              "severity": "info",
              "message": "High transaction volume declared"
            }
          ],
          "risk_score": 35.0,
          "flags": ["high_risk_nationality", "high_volume"],
          "recommendation": "Approved with enhanced monitoring due to risk factors"
        },
        "custom_tags": {
          "department": "onboarding",
          "region": "latam"
        },
        "timestamp": "2024-12-26T12:35:00Z"
      }
    }
    ```

    <Tip>
      Notice that `status` is `approved` but `final_decision` is `flagged`. The user can proceed (they're approved), but your system should track the flags for ongoing monitoring.
    </Tip>
  </Tab>
</Tabs>

**Data Fields:**

| Field               | Type           | Description                                                                                                                                                                                                                                               |
| ------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `event_type`        | string         | Always `kyc.session.processed`                                                                                                                                                                                                                            |
| `tenant_id`         | string         | Your tenant ID                                                                                                                                                                                                                                            |
| `session_id`        | string         | Session identifier                                                                                                                                                                                                                                        |
| `workflow_id`       | string         | Workflow ID used                                                                                                                                                                                                                                          |
| `status`            | string         | Final session status: `approved`, `rejected`, `manual_review`                                                                                                                                                                                             |
| `final_decision`    | string         | Decision value: `approved`, `flagged`, `rejected`, `manual_review`                                                                                                                                                                                        |
| `automation_result` | object         | Full automation evaluation result (see below)                                                                                                                                                                                                             |
| `form_data`         | object         | Form fields filled by the user (same structure as `kyc.session.completed`)                                                                                                                                                                                |
| `subsessions`       | array \| null  | KYB only: enriched data for each linked subsession (e.g. UBOs), embedded so you receive the whole structure in a single parent webhook. `null` for non-KYB sessions. See [UBO subsessions and extracted facts](#kyb-ubo-subsessions-and-extracted-facts). |
| `extracted`         | object \| null | Curated facts extracted from this session's own documents (name, date of birth, nationality, residence country, address). `null` when there are none. See [UBO subsessions and extracted facts](#kyb-ubo-subsessions-and-extracted-facts).                |
| `custom_tags`       | object         | Key-value metadata from the workflow. `null` if none configured.                                                                                                                                                                                          |
| `timestamp`         | string         | ISO8601 timestamp of the event                                                                                                                                                                                                                            |

<Info>
  **Understanding `status` vs `final_decision`:**

  * `status` is the session state in the system (`approved`, `rejected`, `manual_review`)
  * `final_decision` is what the automation decided (`approved`, `flagged`, `rejected`, `manual_review`)

  A `flagged` decision results in `status: approved` because the session is approved, but the `final_decision: flagged` tells you it needs monitoring.
</Info>

#### Web validation in webhooks

When a session runs web validation (from an auto-validated URL form field **or** from `subject_data.website` / `web_url` / `url` / `domain` declared at creation), the **`kyc.session.processed`** webhook (and status events that carry `automation_result`, such as `approved`, `rejected`, `manual_review`) includes:

| Delivered in webhook                | Description                                                                                                                     |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `automation_result.triggered_rules` | Rules that fired: `web_ssl`, `web_industry`, `web_adverse_media`, `web_sanction`, `web_score`, `web_tranco`, policy rules, etc. |
| `automation_result.flags`           | Short string flags — see [Web validation flags](#web-validation-flags) below for the full list                                  |
| `automation_result.final_action`    | Highest-priority automation action driven in part by web rules                                                                  |
| `risk_scoring`                      | Risk score may incorporate web validation findings                                                                              |

The webhook does **not** include the full `web_validation_results` array (reliability score breakdown, SSL/whois/classifier objects, etc.). Fetch that from the Sessions API:

```
GET /kyc/sessions/{session_id}
```

→ `data.processing_results.web_validation_results`

See [Sessions — Subject data](/api-reference/sessions#subject-data) and [Web validation results](/api-reference/sessions#web-validation-results).

<Note>
  Pre-declaring a URL in `subject_data` does not add a separate webhook event at creation time. The URL is validated during session processing; webhook consumers should listen for `kyc.session.processed`.
</Note>

#### Web validation flags

`automation_result.flags` only contains flags for rules that **triggered** — i.e. conditions the website failed or that require attention. A clean website produces no web flags, with one exception: `high_website_score` is a positive flag emitted when a score rule triggers and the reliability score is ≥ 80.

Every flag comes from a triggered rule, so each entry in `flags` has a corresponding entry in `triggered_rules` whose `rule_type` is listed below. To identify what was rejected, filter `triggered_rules` by `action` (`auto_deny`, `manual_review`) rather than relying on flags alone — some triggered rules (e.g. custom score thresholds, missing social platforms) do not emit a flag.

| `rule_type`         | Possible flags                                                                                                                                                                                                                                                                                       |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `web_score`         | `low_website_score` (score \< 50), `high_website_score` (score ≥ 80)                                                                                                                                                                                                                                 |
| `web_ssl`           | `no_ssl`, `invalid_ssl`, `ssl_near_expiry`                                                                                                                                                                                                                                                           |
| `web_page_status`   | `page_<status>` — lowercase page status: `page_down`, `page_under_construction`, `page_maintenance`, `page_not_available`, `page_geo_restricted`, `page_incomplete_template`                                                                                                                         |
| `web_industry`      | `blocked_industry`, `flagged_industry`, `industry_mismatch`                                                                                                                                                                                                                                          |
| `web_adverse_media` | `adverse_media_high`, `adverse_media_medium`, `adverse_media_low`                                                                                                                                                                                                                                    |
| `web_sanction`      | `domain_sanctioned`                                                                                                                                                                                                                                                                                  |
| `web_social`        | `no_social_media`, `invalid_social_links`                                                                                                                                                                                                                                                            |
| `web_tranco`        | `not_ranked_tranco`                                                                                                                                                                                                                                                                                  |
| `web_evidence`      | `unrealistic_promises`, `no_contact_info`, `no_real_products`, `no_navigation`, `missing_required_contact`                                                                                                                                                                                           |
| `web_policy_terms`  | `no_terms_policy`, `generic_terms_policy`, `terms_missing_rut`, `terms_missing_legal_name`, `terms_missing_address`, `terms_missing_is_of_age`, `terms_missing_applicable_law`, `terms_missing_dos_clause`, `terms_missing_termination_clause`, `terms_missing_privacy_policy`, `terms_rut_mismatch` |
| `web_policy_aml`    | `no_aml_policy`, `generic_aml_policy`, `aml_missing_rut`, `aml_missing_legal_name`, `aml_missing_sanction_screening`, `aml_missing_pep_screening`, `aml_missing_transaction_monitoring`, `aml_missing_risk_based_approach`                                                                           |
| `web_policy_return` | `no_return_policy`, `generic_return_policy`                                                                                                                                                                                                                                                          |
| `web_policy_refund` | `no_refund_policy`, `generic_refund_policy`                                                                                                                                                                                                                                                          |

<Note>
  Separately from `automation_result.flags`, the `risk_scoring` block has its own `critical_flags` array which may include `web_low_reliability` (reliability score \< 30 or validation error) and `web_adverse_media` (HIGH\_RISK adverse media). These are risk-scoring flags, not automation rule flags.
</Note>

#### Automation Result Object

| Field                       | Type    | Description                                                                                |
| --------------------------- | ------- | ------------------------------------------------------------------------------------------ |
| `enabled`                   | boolean | Whether automation rules were evaluated                                                    |
| `final_action`              | string  | Highest-priority action: `auto_deny`, `manual_review`, `flag`, `auto_approve`, `no_action` |
| `triggered_rules`           | array   | List of rules that fired (see below)                                                       |
| `risk_score`                | number  | Calculated risk score (0-100)                                                              |
| `flags`                     | array   | String flags for UI display                                                                |
| `recommendation`            | string  | Human-readable recommendation                                                              |
| `face_dedup_result`         | object  | Cross-session face deduplication results                                                   |
| `email_verification_result` | object  | Email validation results                                                                   |
| `evaluated_at`              | string  | ISO8601 timestamp of evaluation                                                            |
| `evaluation_time_ms`        | integer | Processing time in milliseconds                                                            |

#### Triggered Rule Object

| Field          | Type   | Description                                                                         |
| -------------- | ------ | ----------------------------------------------------------------------------------- |
| `rule_type`    | string | Type of rule (see table below)                                                      |
| `rule_details` | object | Configuration details of the rule                                                   |
| `action`       | string | Action triggered: `auto_deny`, `manual_review`, `flag`, `auto_approve`, `no_action` |
| `severity`     | string | Severity level: `critical`, `warning`, `info`                                       |
| `message`      | string | Human-readable explanation                                                          |

#### Rule Types

| Rule Type                     | Description                                                                                                             |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `country`                     | Nationality, residence, or incorporation country checks                                                                 |
| `list_match`                  | Sanctions/watchlist matching (OFAC, UN, EU, UK, PEP, etc.)                                                              |
| `face_dedup`                  | Cross-session face deduplication detection                                                                              |
| `time_to_complete`            | Session completion time thresholds                                                                                      |
| `document`                    | Document age, expiry validation                                                                                         |
| `volume`                      | Declared transaction volume thresholds                                                                                  |
| `email`                       | Email validity, disposable, role-based checks                                                                           |
| `web_validation`              | Website reliability, SSL, industry, adverse media (legacy umbrella type)                                                |
| `web_score`                   | Reliability score thresholds                                                                                            |
| `web_ssl`                     | SSL certificate presence and validity                                                                                   |
| `web_industry`                | Detected vs allowed industry                                                                                            |
| `web_adverse_media`           | Adverse media on the website                                                                                            |
| `web_sanction`                | Domain/entity sanction list matches                                                                                     |
| `web_tranco`                  | Tranco popularity rank                                                                                                  |
| `web_page_status`             | HTTP response / reachability                                                                                            |
| `web_social`                  | Social link checks                                                                                                      |
| `web_evidence`                | Evidence quality signals                                                                                                |
| `web_policy_*`                | Terms, AML, return, refund policy analysis                                                                              |
| `face_match`                  | ID photo vs selfie face matching confidence                                                                             |
| `form_field`                  | User-declared form field value rules (dropdowns, checkboxes, PEP declaration, etc.)                                     |
| `extraction_validation`       | Validation of extracted document fields (sanctions/PEP/adverse media on extracted names, cross-check against form data) |
| `document_validity`           | Document type and image quality validation                                                                              |
| `document_forensics`          | Document forensics / tampering risk                                                                                     |
| `account_country_consistency` | Declared account country vs detected signals                                                                            |
| `security_check_incomplete`   | Security checks could not be completed                                                                                  |
| `risk_score`                  | Calculated session risk score thresholds                                                                                |

#### Flags by rule type (non-web)

Like the [web validation flags](#web-validation-flags), these flags only appear when the corresponding rule triggers. Some triggered rules do not emit a flag, so always use `triggered_rules` (filtered by `action`) as the source of truth for what caused a rejection.

| `rule_type`                   | Possible flags                                                                                            |
| ----------------------------- | --------------------------------------------------------------------------------------------------------- |
| `country`                     | `country_<code>` — lowercase ISO code of the matched country, e.g. `country_ir`, `country_kp`             |
| `list_match`                  | `sanction_list_match`                                                                                     |
| `face_dedup`                  | `previous_sessions_detected`, `previously_rejected`                                                       |
| `face_match`                  | `face_match_failed`                                                                                       |
| `time_to_complete`            | `fast_completion`, `slow_completion`                                                                      |
| `document`                    | `expired_document`, `near_expiry_document`, `new_document`, `old_document`                                |
| `document_validity`           | `invalid_document_type`, `poor_image_quality`                                                             |
| `document_forensics`          | `document_forensics_risk`                                                                                 |
| `volume`                      | `high_volume`                                                                                             |
| `email`                       | `invalid_email`, `disposable_email`, `role_based_email`, `unverifiable_email`                             |
| `form_field`                  | `form_field_<field_id>` — e.g. `form_field_pep_status`                                                    |
| `extraction_validation`       | `sanctions_match_extracted`, `pep_match_extracted`, `adverse_media_extracted`, `extraction_form_mismatch` |
| `account_country_consistency` | `account_country_mismatch:<declared_country>`                                                             |
| `security_check_incomplete`   | `security_check_incomplete`                                                                               |
| `risk_score`                  | `critical_risk_score`, `high_risk_score`, `medium_risk_score`                                             |

#### Action Priority

Actions are evaluated in priority order. The highest-priority triggered action becomes the `final_action`:

| Priority    | Action          | `final_decision` | `status`        | Result                             |
| ----------- | --------------- | ---------------- | --------------- | ---------------------------------- |
| 5 (Highest) | `auto_deny`     | `rejected`       | `rejected`      | Session rejected automatically     |
| 4           | `manual_review` | `manual_review`  | `manual_review` | Sent to review queue               |
| 3           | `flag`          | `flagged`        | `approved`      | Approved but marked for monitoring |
| 2           | `auto_approve`  | `approved`       | `approved`      | Session approved automatically     |
| 1 (Lowest)  | `no_action`     | `manual_review`  | `manual_review` | Default (safety fallback)          |

***

## KYB: UBO subsessions and extracted facts

For business (KYB) verifications, a company session can have **subsessions** — separate verification flows for each Ultimate Beneficial Owner (UBO), director, or legal representative. Instead of correlating multiple individual webhooks, the platform **embeds the relevant subsession data inside the parent company's webhook**, so you receive the full structure in a single delivery.

Two related blocks are included:

* **`subsessions`** — an array with one entry per linked subsession (e.g. each UBO), carrying its status, decision, classification (`tag`/`label`/`roles`), submitted `form_data`, and a curated `extracted` block.
* **`extracted`** (top level) — curated facts pulled from the **parent session's own documents**. This is used when the subject's data lives on the main session rather than a subsession (e.g. an individual or sole-trader flow with no UBO subsessions).

<Info>
  These blocks are included in the `kyc.session.completed`, `kyc.session.processed`, `kyc.session.approved`, `kyc.session.rejected`, `kyc.session.manual_review`, `kyc.session.correction_requested`, and `kyc.session.correction_resolved` events. They are `null` when not applicable (e.g. an individual session has no `subsessions`; a session whose documents yielded no facts has no `extracted`).
</Info>

### `subsessions[]` object

| Field            | Type           | Description                                                                                                       |
| ---------------- | -------------- | ----------------------------------------------------------------------------------------------------------------- |
| `subsession_id`  | string         | Session identifier of the subsession                                                                              |
| `workflow_id`    | string         | Workflow used for the subsession                                                                                  |
| `status`         | string         | Subsession status: `approved`, `rejected`, `manual_review`, etc.                                                  |
| `final_decision` | string \| null | Decision for the subsession: `approved`, `flagged`, `rejected`, `manual_review`                                   |
| `tag`            | string \| null | Category tag, e.g. `UBO`, `Director`, `Legal Representative`                                                      |
| `label`          | string \| null | Human-readable label, e.g. `"Juan García - UBO"`                                                                  |
| `roles`          | array          | Role values selected for this person                                                                              |
| `completed_at`   | string \| null | ISO8601 timestamp when the subsession finished                                                                    |
| `form_data`      | object \| null | Form fields submitted in the subsession (same portable-key structure as the parent's `form_data`). `null` if none |
| `extracted`      | object \| null | Curated facts extracted from the subsession's documents (see below). `null` if none                               |

### `extracted` object

The `extracted` block (both at the top level and inside each subsession) is a flat object with the curated facts found in that session's documents. Keys are present only when a value was found:

| Field               | Type   | Description                                                            |
| ------------------- | ------ | ---------------------------------------------------------------------- |
| `full_name`         | string | Full name read from an identity document                               |
| `date_of_birth`     | string | Date of birth (`YYYY-MM-DD`)                                           |
| `nationality`       | string | Nationality (ISO 3166-1 alpha-2)                                       |
| `residence_country` | string | Country of residence (ISO 3166-1 alpha-2), resolved as described below |
| `address`           | string | Domicile address as printed on the document                            |

#### How residence country is resolved

The residence country is derived from the strongest available evidence, in priority order:

1. **A dedicated proof-of-address document** — its country is used as the country of residence.
2. **An identity document that also carries a printed address** (e.g. the Argentine DNI, whose reverse shows the holder's domicile and is accepted in lieu of a separate proof of address) — the document's issuing country is taken as the country of residence.

If neither a proof of address nor an identity document with an address is present, `residence_country` is omitted.

<Note>
  To populate `residence_country` from an Argentine DNI, the **back/reverse** of the document (the side with the address) must be captured. If only the front is provided, the address — and therefore the residence country — cannot be extracted.
</Note>

### Example: KYB `processed` webhook with UBO subsessions

```json theme={null}
{
  "id": "evt_kyb789",
  "type": "kyc.session.processed",
  "created": 1735231200,
  "data": {
    "event_type": "kyc.session.processed",
    "tenant_id": "tenant_abc",
    "session_id": "sess_company_001",
    "workflow_id": "wf_kyb_srl_sa",
    "status": "manual_review",
    "final_decision": "manual_review",
    "automation_result": { "enabled": true, "final_action": "manual_review", "triggered_rules": [] },
    "extracted": null,
    "subsessions": [
      {
        "subsession_id": "sess_ubo_001",
        "workflow_id": "wf_ubo_idv",
        "status": "approved",
        "final_decision": "approved",
        "tag": "UBO",
        "label": "Juan García - UBO",
        "roles": [],
        "completed_at": "2024-12-26T12:30:00Z",
        "form_data": {
          "expected_monthly_transactions": {
            "field_id": "field_vol123",
            "name": "Expected monthly transactions",
            "type": "number",
            "value": 12000
          }
        },
        "extracted": {
          "full_name": "Juan García",
          "date_of_birth": "1985-03-15",
          "nationality": "AR",
          "address": "Av. Corrientes 1234, CABA, Argentina",
          "residence_country": "AR"
        }
      },
      {
        "subsession_id": "sess_ubo_002",
        "workflow_id": "wf_ubo_idv",
        "status": "approved",
        "final_decision": "approved",
        "tag": "UBO",
        "label": "John Smith - UBO",
        "roles": [],
        "completed_at": "2024-12-26T12:31:00Z",
        "form_data": null,
        "extracted": {
          "full_name": "John Smith",
          "date_of_birth": "1972-07-21",
          "nationality": "US",
          "address": "Rambla 4500, Montevideo, Uruguay",
          "residence_country": "UY"
        }
      }
    ],
    "timestamp": "2024-12-26T12:35:00Z"
  }
}
```

In this example the first UBO's `residence_country` (`AR`) was derived from the address on the Argentine DNI, while the second UBO's (`UY`) came from a dedicated proof-of-address document. Each UBO's `date_of_birth` and any declared `expected_monthly_transactions` are available directly in the parent webhook.

***

## Triggered Rules Reference

Each `triggered_rule` object in the `automation_result.triggered_rules` array contains a `message` field with a human-readable explanation. Use this to communicate rejection reasons to your users.

<Note>
  The `message` field is designed to be user-friendly and can be displayed directly to your customers. The `rule_details` object contains machine-readable data for programmatic handling.
</Note>

### Sanctions & Watchlist Match (`list_match`)

Triggered when a name matches sanctions or watchlist databases.

```json theme={null}
{
  "rule_type": "list_match",
  "action": "auto_deny",
  "severity": "critical",
  "message": "Found 2 match(es) in sanction lists",
  "rule_details": {
    "list_type": "ofac",
    "match_level": "high_confidence",
    "matching_entries": 2,
    "lists_matched": ["OFAC SDN", "UN Consolidated"]
  }
}
```

| Detail Field       | Type    | Description                                                           |
| ------------------ | ------- | --------------------------------------------------------------------- |
| `list_type`        | string  | Rule filter: `ofac`, `un`, `eu`, `uk`, `pep`, or `any`                |
| `match_level`      | string  | Confidence filter: `exact` (≥95%), `high_confidence` (≥80%), or `any` |
| `matching_entries` | integer | Number of matches found                                               |
| `lists_matched`    | array   | Names of lists where matches were found                               |

***

### Country Restriction (`country`)

Triggered when nationality, residence, or incorporation country matches a blocked list.

```json theme={null}
{
  "rule_type": "country",
  "action": "auto_deny",
  "severity": "critical",
  "message": "Country KP detected in nationality. Reason: Sanctioned country",
  "rule_details": {
    "country_code": "KP",
    "applies_to": ["all"],
    "matched_fields": ["nationality"]
  }
}
```

| Detail Field     | Type   | Description                                                                                                                          |
| ---------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| `country_code`   | string | ISO 3166-1 alpha-2 country code                                                                                                      |
| `applies_to`     | array  | Which fields to check: `nationality`, `residence`, `incorporation`, `tax_residence`, `birth_country`, `business_operation`, or `all` |
| `matched_fields` | array  | Which fields actually matched                                                                                                        |

***

### Face Match Failed (`face_match`)

Triggered when the selfie doesn't match the ID photo.

```json theme={null}
{
  "rule_type": "face_match",
  "action": "auto_deny",
  "severity": "critical",
  "message": "Face match failed (confidence: 45.00%, threshold: 80.00%)",
  "rule_details": {
    "confidence": 0.45,
    "threshold": 0.8,
    "is_match": false
  }
}
```

| Detail Field | Type    | Description              |
| ------------ | ------- | ------------------------ |
| `confidence` | number  | Match confidence (0-1)   |
| `threshold`  | number  | Required threshold (0-1) |
| `is_match`   | boolean | Whether face matched     |

***

### Face Deduplication (`face_dedup`)

Triggered when the same face is detected in previous sessions.

```json theme={null}
{
  "rule_type": "face_dedup",
  "action": "manual_review",
  "severity": "warning",
  "message": "Face detected in 3 previous session(s) (2 approved, 1 rejected)",
  "rule_details": {
    "min_sessions_required": 1,
    "sessions_found": 3,
    "approved": 2,
    "rejected": 1,
    "pending": 0,
    "matched_session_ids": ["sess_abc", "sess_def", "sess_ghi"]
  }
}
```

| Detail Field          | Type    | Description                             |
| --------------------- | ------- | --------------------------------------- |
| `sessions_found`      | integer | Total previous sessions with this face  |
| `approved`            | integer | Previously approved sessions            |
| `rejected`            | integer | Previously rejected sessions            |
| `pending`             | integer | Pending sessions                        |
| `matched_session_ids` | array   | Up to 5 session IDs (for investigation) |

***

### Document Expired or Near Expiry (`document`)

Triggered when documents are expired or about to expire.

**Expired document:**

```json theme={null}
{
  "rule_type": "document",
  "action": "auto_deny",
  "severity": "critical",
  "message": "Document id_document expired 45 days ago",
  "rule_details": {
    "document_type": "id_document",
    "doc_id": "doc_abc123",
    "check_type": "expired",
    "expiry_date": "2024-11-01",
    "days_expired": 45
  }
}
```

**Document too old (e.g., proof of address):**

```json theme={null}
{
  "rule_type": "document",
  "action": "manual_review",
  "severity": "info",
  "message": "Document proof_of_address issued 120 days ago (max: 90)",
  "rule_details": {
    "document_type": "proof_of_address",
    "doc_id": "doc_def456",
    "check_type": "too_old",
    "issue_date": "2024-09-01",
    "days_since_issue": 120,
    "max_allowed": 90
  }
}
```

| Detail Field       | Type    | Description                                                  |
| ------------------ | ------- | ------------------------------------------------------------ |
| `document_type`    | string  | Type: `id_document`, `proof_of_address`, `bank_letter`, etc. |
| `check_type`       | string  | `expired`, `near_expiry`, `too_old`, or `too_new`            |
| `expiry_date`      | string  | ISO date of expiration (for expiry checks)                   |
| `issue_date`       | string  | ISO date of issue (for age checks)                           |
| `days_expired`     | integer | Days since expiration                                        |
| `days_since_issue` | integer | Days since document was issued                               |

***

### Invalid Document Type (`document_validity`)

Triggered when uploaded document doesn't match expected type.

```json theme={null}
{
  "rule_type": "document_validity",
  "action": "auto_deny",
  "severity": "critical",
  "message": "Invalid document: expected id_document, got receipt. Document type validation failed",
  "rule_details": {
    "doc_id": "doc_abc123",
    "expected_type": "id_document",
    "detected_type": "receipt",
    "validation_reason": "Document type validation failed"
  }
}
```

| Detail Field        | Type   | Description                          |
| ------------------- | ------ | ------------------------------------ |
| `expected_type`     | string | What was expected                    |
| `detected_type`     | string | What was detected                    |
| `validation_reason` | string | Explanation of why validation failed |

***

### Email Verification (`email`)

Triggered when email validation detects issues.

```json theme={null}
{
  "rule_type": "email",
  "action": "auto_deny",
  "severity": "critical",
  "message": "Email test@tempmail.com is from a disposable/temporary domain",
  "rule_details": {
    "email": "test@tempmail.com",
    "check_type": "disposable"
  }
}
```

| Detail Field | Type   | Description                                              |
| ------------ | ------ | -------------------------------------------------------- |
| `email`      | string | The email address                                        |
| `check_type` | string | `invalid`, `disposable`, `role_based`, or `unverifiable` |

**Possible `check_type` values:**

| Value          | Description                                                  |
| -------------- | ------------------------------------------------------------ |
| `invalid`      | Email doesn't exist or is malformed                          |
| `disposable`   | From temporary email service (e.g., tempmail, guerrillamail) |
| `role_based`   | Generic address (info@, admin@, support@)                    |
| `unverifiable` | Valid format but catch-all domain (can't verify existence)   |

***

### Transaction Volume (`volume`)

Triggered when declared transaction volume exceeds thresholds.

```json theme={null}
{
  "rule_type": "volume",
  "action": "manual_review",
  "severity": "warning",
  "message": "Transaction volume: volume $150,000.00/month",
  "rule_details": {
    "monthly_volume": 150000,
    "monthly_transactions": 500,
    "rule_min_volume": 100000,
    "rule_max_volume": null,
    "rule_min_transactions": null,
    "rule_max_transactions": null
  }
}
```

| Detail Field           | Type    | Description                        |
| ---------------------- | ------- | ---------------------------------- |
| `monthly_volume`       | number  | Declared monthly volume            |
| `monthly_transactions` | integer | Declared monthly transaction count |
| `rule_min_volume`      | number  | Rule's minimum threshold           |
| `rule_max_volume`      | number  | Rule's maximum threshold           |

***

### Time to Complete (`time_to_complete`)

Triggered when session completion time is suspicious.

**Too fast (possible bot):**

```json theme={null}
{
  "rule_type": "time_to_complete",
  "action": "manual_review",
  "severity": "warning",
  "message": "Session completed in 15s (threshold: 60s)",
  "rule_details": {
    "completion_seconds": 15,
    "min_seconds": null,
    "max_seconds": 60
  }
}
```

| Detail Field         | Type    | Description                           |
| -------------------- | ------- | ------------------------------------- |
| `completion_seconds` | integer | Actual time to complete (seconds)     |
| `min_seconds`        | integer | Maximum allowed time (slow detection) |
| `max_seconds`        | integer | Minimum required time (bot detection) |

***

### Form Field Declaration (`form_field`)

Triggered when user declares high-risk information in forms.

```json theme={null}
{
  "rule_type": "form_field",
  "action": "manual_review",
  "severity": "warning",
  "message": "User declared PEP status (field 'pep_status' = 'Former PEP')",
  "rule_details": {
    "field_id": "pep_status",
    "field_value": "Former PEP",
    "operator": "in",
    "rule_value": ["PEP", "Former PEP", "Related to PEP"],
    "reason": "User declared PEP status"
  }
}
```

| Detail Field  | Type   | Description                                       |
| ------------- | ------ | ------------------------------------------------- |
| `field_id`    | string | Form field identifier                             |
| `field_value` | any    | Value submitted by user                           |
| `operator`    | string | Comparison: `equals`, `in`, `is_true`, `gt`, etc. |
| `rule_value`  | any    | Value(s) the rule checks against                  |
| `reason`      | string | Human-readable reason                             |

***

<h3 id="web-validation-rules">
  Web Validation Rules
</h3>

These rules appear in `automation_result.triggered_rules` on **`kyc.session.processed`** and related status webhooks. For the complete validator output (scores, SSL, whois, classifier, policies), use `GET /kyc/sessions/{session_id}` → `processing_results.web_validation_results`.

Multiple rule types for website validation:

#### SSL Certificate (`web_ssl`)

```json theme={null}
{
  "rule_type": "web_ssl",
  "action": "auto_deny",
  "severity": "critical",
  "message": "Website example.com has no SSL certificate",
  "rule_details": { "url": "example.com", "has_ssl": false }
}
```

#### Blocked Industry (`web_industry`)

```json theme={null}
{
  "rule_type": "web_industry",
  "action": "auto_deny",
  "severity": "critical",
  "message": "Website example.com industry 'gambling' is blocked",
  "rule_details": { "url": "example.com", "industry": "gambling", "allowed": false }
}
```

#### Adverse Media (`web_adverse_media`)

```json theme={null}
{
  "rule_type": "web_adverse_media",
  "action": "auto_deny",
  "severity": "critical",
  "message": "Website example.com has HIGH_RISK adverse media (score: 85)",
  "rule_details": { "url": "example.com", "decision": "HIGH_RISK", "risk_score": 85 }
}
```

#### Domain Sanctioned (`web_sanction`)

```json theme={null}
{
  "rule_type": "web_sanction",
  "action": "auto_deny",
  "severity": "critical",
  "message": "Website example.com found on sanction lists",
  "rule_details": { "url": "example.com", "matches": ["OFAC"] }
}
```

***

### Extraction Validation (`extraction_validation`)

Triggered when extracted document data fails validation.

**Sanctions match on extracted name:**

```json theme={null}
{
  "rule_type": "extraction_validation",
  "action": "manual_review",
  "severity": "critical",
  "message": "Extracted full_name 'John Doe' matched in sanctions lists",
  "rule_details": {
    "validation_type": "sanctions_lists",
    "extracted_field": "full_name",
    "extracted_value": "John Doe",
    "doc_id": "doc_abc",
    "doc_type": "id_document"
  }
}
```

**Form data mismatch:**

```json theme={null}
{
  "rule_type": "extraction_validation",
  "action": "flag",
  "severity": "warning",
  "message": "Extracted full_name 'JOHN D. DOE' differs from form value 'John Doe' (similarity: 85%)",
  "rule_details": {
    "validation_type": "cross_validate_form",
    "extracted_field": "full_name",
    "extracted_value": "JOHN D. DOE",
    "form_field": "full_name",
    "form_value": "John Doe",
    "similarity": 0.85,
    "threshold": 0.8
  }
}
```

| Detail Field      | Type   | Description                                                               |
| ----------------- | ------ | ------------------------------------------------------------------------- |
| `validation_type` | string | `sanctions_lists`, `adverse_media`, `cross_validate_form`, or `pep_check` |
| `extracted_field` | string | Field name from document                                                  |
| `extracted_value` | string | Value extracted from document                                             |
| `form_field`      | string | Corresponding form field (for cross-validation)                           |
| `form_value`      | string | Value from form (for cross-validation)                                    |
| `similarity`      | number | Match similarity (0-1)                                                    |

***

## Using Triggered Rules in Your Application

### Displaying Rejection Reasons

```javascript theme={null}
function getCustomerFacingMessage(triggeredRules) {
  // Get the most critical rule
  const criticalRule = triggeredRules.find(r => r.severity === 'critical');
  
  if (criticalRule) {
    // Map rule types to user-friendly messages
    const messages = {
      'list_match': 'Your information could not be verified against our security databases.',
      'country': 'Unfortunately, we cannot accept applications from your country at this time.',
      'face_match': 'The photo on your ID could not be matched with your selfie. Please try again.',
      'document': 'Your document appears to be expired. Please upload a valid document.',
      'document_validity': 'The uploaded document type could not be verified. Please upload a valid ID.',
      'email': 'The email address provided could not be verified.',
    };
    
    return messages[criticalRule.rule_type] || criticalRule.message;
  }
  
  return 'Your application requires additional review.';
}
```

### Logging for Compliance

```python theme={null}
def log_session_decision(webhook_data):
    automation = webhook_data['data'].get('automation_result', {})
    
    for rule in automation.get('triggered_rules', []):
        logger.info({
            'event': 'automation_rule_triggered',
            'session_id': webhook_data['data']['session_id'],
            'rule_type': rule['rule_type'],
            'action': rule['action'],
            'severity': rule['severity'],
            'message': rule['message'],
            'details': rule['rule_details']
        })
```

***

### kyc.session.approved

Sent when a compliance officer manually approves a session. Includes the full session context: automation evaluation from processing, form data, and workflow information.

```json theme={null}
{
  "id": "evt_jkl012mno345",
  "type": "kyc.session.approved",
  "created": 1735238400,
  "data": {
    "event_type": "kyc.session.approved",
    "tenant_id": "tenant_abc",
    "session_id": "sess_123456",
    "status": "approved",
    "workflow_id": "wf_789",
    "notes": "Verified via phone call with customer. Documentation confirmed.",
    "updated_by": "compliance@yourcompany.com",
    "automation_result": {
      "enabled": true,
      "final_action": "manual_review",
      "triggered_rules": [
        {
          "rule_type": "list_match",
          "action": "manual_review",
          "severity": "warning",
          "message": "Potential OFAC match found"
        }
      ],
      "risk_score": 45.0,
      "flags": ["sanctions_match"],
      "recommendation": "Review sanctions match before approving"
    },
    "form_data": {
      "full_name": {
        "field_id": "fld_abc123",
        "name": "Full Name",
        "type": "name",
        "value": "John Doe"
      },
      "date_of_birth": {
        "field_id": "fld_xyz789",
        "name": "Date of Birth",
        "type": "date",
        "value": "1990-01-15"
      }
    },
    "custom_tags": {
      "department": "onboarding",
      "region": "latam"
    },
    "timestamp": "2024-12-26T14:00:00Z"
  }
}
```

**Data Fields:**

| Field               | Type           | Description                                                                                                                                            |
| ------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `event_type`        | string         | Always `kyc.session.approved`                                                                                                                          |
| `tenant_id`         | string         | Your tenant ID                                                                                                                                         |
| `session_id`        | string         | Session identifier                                                                                                                                     |
| `status`            | string         | Always `approved`                                                                                                                                      |
| `workflow_id`       | string \| null | Workflow ID. `null` if not available.                                                                                                                  |
| `notes`             | string \| null | Optional notes from the reviewer                                                                                                                       |
| `updated_by`        | string \| null | ID of the compliance officer who approved                                                                                                              |
| `automation_result` | object \| null | Original automation evaluation from processing. `null` if automation was not enabled. See [Automation Result](#automation-result) for the full schema. |
| `form_data`         | object \| null | Enriched form field data with portable keys. `null` if no form data was collected.                                                                     |
| `custom_tags`       | object \| null | Key-value metadata from the workflow. `null` if none configured.                                                                                       |
| `timestamp`         | string         | ISO8601 timestamp                                                                                                                                      |

***

### kyc.session.rejected

Sent when a compliance officer manually rejects a session. Includes the full session context: automation evaluation from processing, form data, and workflow information.

```json theme={null}
{
  "id": "evt_mno345pqr678",
  "type": "kyc.session.rejected",
  "created": 1735238400,
  "data": {
    "event_type": "kyc.session.rejected",
    "tenant_id": "tenant_abc",
    "session_id": "sess_123456",
    "status": "rejected",
    "workflow_id": "wf_789",
    "notes": "Document appears to be fraudulent. Inconsistent information provided.",
    "updated_by": "compliance@yourcompany.com",
    "automation_result": {
      "enabled": true,
      "final_action": "manual_review",
      "triggered_rules": [
        {
          "rule_type": "list_match",
          "action": "manual_review",
          "severity": "warning",
          "message": "Potential OFAC match found"
        }
      ],
      "risk_score": 65.0,
      "flags": ["sanctions_match", "high_risk_country"]
    },
    "form_data": {
      "full_name": {
        "field_id": "fld_abc123",
        "name": "Full Name",
        "type": "name",
        "value": "Jane Smith"
      }
    },
    "custom_tags": {
      "department": "onboarding",
      "region": "latam"
    },
    "timestamp": "2024-12-26T14:00:00Z"
  }
}
```

**Data Fields:**

| Field               | Type           | Description                                                                                                                                            |
| ------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `event_type`        | string         | Always `kyc.session.rejected`                                                                                                                          |
| `tenant_id`         | string         | Your tenant ID                                                                                                                                         |
| `session_id`        | string         | Session identifier                                                                                                                                     |
| `status`            | string         | Always `rejected`                                                                                                                                      |
| `workflow_id`       | string \| null | Workflow ID. `null` if not available.                                                                                                                  |
| `notes`             | string \| null | Optional rejection reason                                                                                                                              |
| `updated_by`        | string \| null | ID of the compliance officer who rejected                                                                                                              |
| `automation_result` | object \| null | Original automation evaluation from processing. `null` if automation was not enabled. See [Automation Result](#automation-result) for the full schema. |
| `form_data`         | object \| null | Enriched form field data with portable keys. `null` if no form data was collected.                                                                     |
| `custom_tags`       | object \| null | Key-value metadata from the workflow. `null` if none configured.                                                                                       |
| `timestamp`         | string         | ISO8601 timestamp                                                                                                                                      |

***

### kyc.session.manual\_review

Sent when a session is flagged for manual review (either automatically or manually). Includes the full session context: automation evaluation from processing, form data, and workflow information.

```json theme={null}
{
  "id": "evt_pqr678stu901",
  "type": "kyc.session.manual_review",
  "created": 1735238400,
  "data": {
    "event_type": "kyc.session.manual_review",
    "tenant_id": "tenant_abc",
    "session_id": "sess_123456",
    "status": "manual_review",
    "workflow_id": "wf_789",
    "notes": "Additional documentation required",
    "updated_by": "system",
    "automation_result": {
      "enabled": true,
      "final_action": "manual_review",
      "triggered_rules": [
        {
          "rule_type": "country",
          "action": "manual_review",
          "severity": "warning",
          "message": "High risk country detected"
        }
      ],
      "risk_score": 40.0,
      "flags": ["high_risk_country"]
    },
    "form_data": {
      "full_name": {
        "field_id": "fld_abc123",
        "name": "Full Name",
        "type": "name",
        "value": "Alex Johnson"
      }
    },
    "custom_tags": {
      "department": "onboarding",
      "region": "latam"
    },
    "timestamp": "2024-12-26T14:00:00Z"
  }
}
```

**Data Fields:**

| Field               | Type           | Description                                                                                                                                            |
| ------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `event_type`        | string         | Always `kyc.session.manual_review`                                                                                                                     |
| `tenant_id`         | string         | Your tenant ID                                                                                                                                         |
| `session_id`        | string         | Session identifier                                                                                                                                     |
| `status`            | string         | Always `manual_review`                                                                                                                                 |
| `workflow_id`       | string \| null | Workflow ID. `null` if not available.                                                                                                                  |
| `notes`             | string \| null | Optional notes                                                                                                                                         |
| `updated_by`        | string \| null | ID of who triggered the review, or `system` if automatic                                                                                               |
| `automation_result` | object \| null | Original automation evaluation from processing. `null` if automation was not enabled. See [Automation Result](#automation-result) for the full schema. |
| `form_data`         | object \| null | Enriched form field data with portable keys. `null` if no form data was collected.                                                                     |
| `custom_tags`       | object \| null | Key-value metadata from the workflow. `null` if none configured.                                                                                       |
| `timestamp`         | string         | ISO8601 timestamp                                                                                                                                      |

***

### kyc.session.correction\_requested

Sent when a reviewer requests customer remediation with `POST /kyc/sessions/{session_id}/corrections`. The session status becomes `awaiting_client_correction` and the affected steps become `needs_correction`.

```json theme={null}
{
  "id": "evt_corr_requested_123",
  "type": "kyc.session.correction_requested",
  "created": 1770000000,
  "data": {
    "event_type": "kyc.session.correction_requested",
    "tenant_id": "tenant_abc",
    "session_id": "sess_123456",
    "status": "awaiting_client_correction",
    "workflow_id": "wf_789",
    "notes": "Name mismatch and blurry ID image",
    "updated_by": "reviewer_123",
    "correction_ids": ["corr_abc123"],
    "step_ids": ["personal_info"],
    "correction_requests": [
      {
        "id": "corr_abc123",
        "step_id": "personal_info",
        "field_ids": ["full_name"],
        "document_types": [],
        "message": "Please correct your legal name so it matches your ID.",
        "status": "open",
        "created_at": "2026-05-25T15:00:00Z",
        "resolved_at": null
      }
    ],
    "form_data": {
      "full_name": {
        "field_id": "fld_abc123",
        "name": "Full Name",
        "type": "name",
        "value": "Jon Doe"
      }
    },
    "custom_tags": {
      "department": "onboarding",
      "region": "latam"
    },
    "timestamp": "2026-05-25T15:00:00Z"
  }
}
```

**Data Fields:**

| Field                 | Type           | Description                                                                          |
| --------------------- | -------------- | ------------------------------------------------------------------------------------ |
| `event_type`          | string         | Always `kyc.session.correction_requested`                                            |
| `tenant_id`           | string         | Your tenant ID                                                                       |
| `session_id`          | string         | Session identifier                                                                   |
| `status`              | string         | Always `awaiting_client_correction`                                                  |
| `workflow_id`         | string \| null | Workflow ID. `null` if not available.                                                |
| `notes`               | string \| null | Optional reviewer note or comment                                                    |
| `updated_by`          | string \| null | ID of the reviewer who requested corrections                                         |
| `correction_ids`      | array \| null  | IDs of the correction requests created                                               |
| `step_ids`            | array \| null  | Workflow step IDs that need remediation                                              |
| `correction_requests` | array \| null  | Customer-facing correction instructions. Internal reviewer metadata is not included. |
| `form_data`           | object \| null | Enriched form field data with portable keys. `null` if no form data was collected.   |
| `custom_tags`         | object \| null | Key-value metadata from the workflow. `null` if none configured.                     |
| `timestamp`           | string         | ISO8601 timestamp                                                                    |

***

### kyc.session.correction\_resolved

Sent when the customer completes a step that had an open correction request.

The resulting `status` depends on whether corrections remain:

* **More corrections pending** → `status` stays `awaiting_client_correction` and `next_step_id` points to the next step that still needs correction.
* **All corrections resolved** → `status` follows the workflow's `validation_config.correction_resolution.mode`:

| Mode                      | `status` in webhook | Behavior after the event                                                                                                                                                    |
| ------------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `manual_review` (default) | `manual_review`     | Session waits for a reviewer decision                                                                                                                                       |
| `completed`               | `completed`         | Session is marked completed. Processing is **not** automatic — you must call `POST /kyc/sessions/{session_id}/process` to get a decision                                    |
| `auto_process`            | `completed`         | Session is marked completed and processing is triggered automatically. A separate [`kyc.session.processed`](#kyc-session-processed) webhook follows with the final decision |

<Info>
  Both the `completed` and `auto_process` modes emit this webhook with **`status: "completed"`**. The `status` field never contains the literal value `auto_process` — that is a workflow configuration mode, not a session status. The difference is behavioral: `auto_process` automatically kicks off processing (and a `kyc.session.processed` webhook), while `completed` waits for you to call `POST /process`.
</Info>

<Tabs>
  <Tab title="More Corrections Pending">
    `status` stays `awaiting_client_correction` because another step still has an open correction. Use `next_step_id` to point the customer to it.

    ```json theme={null}
    {
      "id": "evt_corr_resolved_123",
      "type": "kyc.session.correction_resolved",
      "created": 1770000600,
      "data": {
        "event_type": "kyc.session.correction_resolved",
        "tenant_id": "tenant_abc",
        "session_id": "sess_123456",
        "status": "awaiting_client_correction",
        "workflow_id": "wf_789",
        "updated_by": "public-user",
        "correction_ids": ["corr_abc123"],
        "step_ids": ["personal_info"],
        "next_step_id": "address_proof",
        "correction_requests": [
          {
            "id": "corr_abc123",
            "step_id": "personal_info",
            "field_ids": ["full_name"],
            "document_types": [],
            "message": "Please correct your legal name so it matches your ID.",
            "status": "resolved",
            "created_at": "2026-05-25T15:00:00Z",
            "resolved_at": "2026-05-25T15:10:00Z"
          }
        ],
        "timestamp": "2026-05-25T15:10:00Z"
      }
    }
    ```
  </Tab>

  <Tab title="manual_review Mode">
    All corrections resolved. The workflow's `correction_resolution.mode` is `manual_review` (the default), so the session returns to the review queue.

    ```json theme={null}
    {
      "id": "evt_corr_resolved_123",
      "type": "kyc.session.correction_resolved",
      "created": 1770000600,
      "data": {
        "event_type": "kyc.session.correction_resolved",
        "tenant_id": "tenant_abc",
        "session_id": "sess_123456",
        "status": "manual_review",
        "workflow_id": "wf_789",
        "updated_by": "public-user",
        "correction_ids": ["corr_abc123"],
        "step_ids": ["personal_info"],
        "next_step_id": null,
        "correction_requests": [
          {
            "id": "corr_abc123",
            "step_id": "personal_info",
            "field_ids": ["full_name"],
            "document_types": [],
            "message": "Please correct your legal name so it matches your ID.",
            "status": "resolved",
            "created_at": "2026-05-25T15:00:00Z",
            "resolved_at": "2026-05-25T15:10:00Z"
          }
        ],
        "timestamp": "2026-05-25T15:10:00Z"
      }
    }
    ```
  </Tab>

  <Tab title="completed Mode">
    All corrections resolved with `correction_resolution.mode: "completed"`. The session is marked `completed`, but **no processing happens automatically** — call `POST /kyc/sessions/{session_id}/process` to obtain a decision.

    ```json theme={null}
    {
      "id": "evt_corr_resolved_123",
      "type": "kyc.session.correction_resolved",
      "created": 1770000600,
      "data": {
        "event_type": "kyc.session.correction_resolved",
        "tenant_id": "tenant_abc",
        "session_id": "sess_123456",
        "status": "completed",
        "workflow_id": "wf_789",
        "updated_by": "public-user",
        "correction_ids": ["corr_abc123"],
        "step_ids": ["personal_info"],
        "next_step_id": null,
        "correction_requests": [
          {
            "id": "corr_abc123",
            "step_id": "personal_info",
            "field_ids": ["full_name"],
            "document_types": [],
            "message": "Please correct your legal name so it matches your ID.",
            "status": "resolved",
            "created_at": "2026-05-25T15:00:00Z",
            "resolved_at": "2026-05-25T15:10:00Z"
          }
        ],
        "timestamp": "2026-05-25T15:10:00Z"
      }
    }
    ```
  </Tab>

  <Tab title="auto_process Mode">
    All corrections resolved with `correction_resolution.mode: "auto_process"`. The body is identical to `completed` mode (`status: "completed"`), but processing is triggered automatically. Expect a [`kyc.session.processed`](#kyc-session-processed) webhook shortly after, carrying the `final_decision`.

    ```json theme={null}
    {
      "id": "evt_corr_resolved_123",
      "type": "kyc.session.correction_resolved",
      "created": 1770000600,
      "data": {
        "event_type": "kyc.session.correction_resolved",
        "tenant_id": "tenant_abc",
        "session_id": "sess_123456",
        "status": "completed",
        "workflow_id": "wf_789",
        "updated_by": "public-user",
        "correction_ids": ["corr_abc123"],
        "step_ids": ["personal_info"],
        "next_step_id": null,
        "correction_requests": [
          {
            "id": "corr_abc123",
            "step_id": "personal_info",
            "field_ids": ["full_name"],
            "document_types": [],
            "message": "Please correct your legal name so it matches your ID.",
            "status": "resolved",
            "created_at": "2026-05-25T15:00:00Z",
            "resolved_at": "2026-05-25T15:10:00Z"
          }
        ],
        "timestamp": "2026-05-25T15:10:00Z"
      }
    }
    ```

    <Tip>
      With `auto_process`, the `correction_resolved` event (`status: "completed"`) is immediately followed by a `kyc.session.processed` event. Use the `processed` event — not `correction_resolved` — to read the final decision and automation results.
    </Tip>
  </Tab>
</Tabs>

**Data Fields:**

| Field                 | Type           | Description                                                                                                                                                                                    |
| --------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `event_type`          | string         | Always `kyc.session.correction_resolved`                                                                                                                                                       |
| `tenant_id`           | string         | Your tenant ID                                                                                                                                                                                 |
| `session_id`          | string         | Session identifier                                                                                                                                                                             |
| `status`              | string         | Session status after the corrected step is submitted: `awaiting_client_correction` (more corrections pending), `manual_review`, or `completed` (for both `completed` and `auto_process` modes) |
| `workflow_id`         | string \| null | Workflow ID. `null` if not available.                                                                                                                                                          |
| `updated_by`          | string \| null | Always `public-user` for end-user remediation submissions                                                                                                                                      |
| `correction_ids`      | array \| null  | IDs of the correction requests resolved by this submission                                                                                                                                     |
| `step_ids`            | array \| null  | Workflow step IDs resolved by this submission                                                                                                                                                  |
| `next_step_id`        | string \| null | Next step with open corrections, or `null` if remediation is complete                                                                                                                          |
| `correction_requests` | array \| null  | Resolved correction request details. Internal reviewer metadata is not included.                                                                                                               |
| `timestamp`           | string         | ISO8601 timestamp                                                                                                                                                                              |

***

## Handling Webhooks

### Recommended Architecture

```mermaid theme={null}
flowchart LR
    A[KYC API] -->|Webhook| B[Your Endpoint]
    B -->|Verify & Ack| C[Return 200]
    B -->|Queue| D[Message Queue]
    D -->|Process| E[Worker]
    E -->|Update| F[Your Database]
    E -->|Notify| G[Your Users]
```

### Example: Full Webhook Handler

```python theme={null}
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
import hmac
import hashlib
import time
import json
import logging

app = FastAPI()
logger = logging.getLogger(__name__)

WEBHOOK_SECRET = "whsec_your_secret"
MAX_TIMESTAMP_AGE = 300  # 5 minutes

async def process_session_created(data: dict):
    """Handle new session - maybe send link to user."""
    session_id = data['session_id']
    access_link = data['access_link']
    logger.info(f"New session created: {session_id}")
    # Send email to user with access_link
    
async def process_session_completed(data: dict):
    """Handle completed session - user finished their part."""
    session_id = data['session_id']
    logger.info(f"Session completed by user: {session_id}")
    # Notify internal team that documents are ready

async def process_session_processed(data: dict):
    """Handle processed session - main decision webhook."""
    session_id = data['session_id']
    decision = data['final_decision']
    custom_tags = data.get('custom_tags') or {}
    automation = data.get('automation_result', {})
    risk_score = automation.get('risk_score', 0)
    flags = automation.get('flags', [])
    
    logger.info(f"Session {session_id} processed: {decision} (risk: {risk_score})")
    
    if decision == 'approved':
        # Activate user account immediately
        await activate_user(session_id)
    elif decision == 'flagged':
        # Activate user but add to monitoring queue
        await activate_user(session_id)
        await add_to_monitoring_queue(session_id, flags, risk_score)
    elif decision == 'rejected':
        # Send rejection notification
        await notify_rejection(session_id, automation.get('recommendation'))
    else:  # manual_review
        # Queue for compliance team
        await queue_for_review(session_id, automation)

async def process_status_update(data: dict):
    """Handle manual status updates."""
    session_id = data['session_id']
    status = data['status']
    notes = data.get('notes', '')
    
    logger.info(f"Session {session_id} status changed to: {status}")
    
    if status == 'approved':
        await activate_user(session_id)
    elif status == 'rejected':
        await notify_rejection(session_id, notes)

EVENT_HANDLERS = {
    'kyc.session.created': process_session_created,
    'kyc.session.completed': process_session_completed,
    'kyc.session.processed': process_session_processed,
    'kyc.session.approved': process_status_update,
    'kyc.session.rejected': process_status_update,
    'kyc.session.manual_review': process_status_update,
    'kyc.session.correction_requested': process_status_update,
    'kyc.session.correction_resolved': process_status_update,
}

@app.post("/webhooks/kyc")
async def handle_kyc_webhook(request: Request, background_tasks: BackgroundTasks):
    # Get raw body for signature verification
    body = await request.body()
    
    # Get headers
    timestamp = request.headers.get('X-Webhook-Timestamp')
    signature = request.headers.get('X-Webhook-Signature')
    event_id = request.headers.get('X-Webhook-ID')
    
    # Validate headers
    if not timestamp or not signature:
        raise HTTPException(400, "Missing signature headers")
    
    # Verify timestamp freshness
    try:
        ts = int(timestamp)
        if abs(time.time() - ts) > MAX_TIMESTAMP_AGE:
            raise HTTPException(401, "Timestamp too old")
    except ValueError:
        raise HTTPException(400, "Invalid timestamp")
    
    # Verify signature
    sig = signature.replace('sha256=', '')
    message = f"{timestamp}.{body.decode('utf-8')}"
    expected = hmac.new(
        WEBHOOK_SECRET.encode(),
        message.encode(),
        hashlib.sha256
    ).hexdigest()
    
    if not hmac.compare_digest(expected, sig):
        raise HTTPException(401, "Invalid signature")
    
    # Parse payload
    payload = json.loads(body)
    event_type = payload.get('type')
    data = payload.get('data', {})
    
    # Check idempotency (optional but recommended)
    if await is_already_processed(event_id):
        return {"received": True, "status": "already_processed"}
    
    # Find and queue handler
    handler = EVENT_HANDLERS.get(event_type)
    if handler:
        background_tasks.add_task(handler, data)
        await mark_as_processed(event_id)
    else:
        logger.warning(f"Unknown event type: {event_type}")
    
    return {"received": True, "event_id": event_id}
```

***

## Testing Webhooks

### Using the Staging Environment

Always test webhooks in staging first:

1. Configure your staging webhook URL
2. Create test sessions
3. Complete the flow to trigger events
4. Verify your handler processes them correctly

### Local Development with ngrok

```bash theme={null}
# Start your local server
python -m uvicorn main:app --port 8000

# In another terminal, expose it
ngrok http 8000

# Use the ngrok URL as your webhook_url
# https://abc123.ngrok.io/webhooks/kyc
```

### Webhook Payload Generator

For testing your signature verification, generate test payloads:

```python theme={null}
import hmac
import hashlib
import time
import json

def generate_test_webhook(secret: str, event_type: str, data: dict) -> dict:
    """Generate a test webhook payload with valid signature."""
    timestamp = int(time.time())
    
    envelope = {
        "id": f"evt_test_{timestamp}",
        "type": event_type,
        "created": timestamp,
        "data": data
    }
    
    payload = json.dumps(envelope)
    message = f"{timestamp}.{payload}"
    signature = hmac.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest()
    
    return {
        "headers": {
            "X-Webhook-ID": envelope["id"],
            "X-Webhook-Timestamp": str(timestamp),
            "X-Webhook-Signature": f"sha256={signature}",
            "Content-Type": "application/json"
        },
        "body": envelope
    }

# Generate test webhook
test = generate_test_webhook(
    secret="whsec_test123",
    event_type="kyc.session.processed",
    data={
        "event_type": "kyc.session.processed",
        "tenant_id": "test_tenant",
        "session_id": "test_session",
        "status": "approved",
        "final_decision": "approved"
    }
)
print(json.dumps(test, indent=2))
```

***

## Error Handling

### Your Endpoint Should:

1. **Return 2xx quickly** - Within 10 seconds
2. **Process asynchronously** - Use background tasks
3. **Be idempotent** - Handle duplicate deliveries
4. **Log failures** - For debugging

### Common Issues

| Issue                      | Solution                                          |
| -------------------------- | ------------------------------------------------- |
| Signature mismatch         | Verify you're using the raw body, not parsed JSON |
| Timestamp validation fails | Check server clock synchronization                |
| Missing events             | Ensure endpoint returns 2xx, check logs           |
| Duplicate processing       | Implement idempotency using `X-Webhook-ID`        |

***

## Rate Limits

Webhooks are not subject to API rate limits, but your endpoint should handle bursts:

* **Peak**: Up to 100 webhooks/minute during high activity
* **Timeout**: 10 seconds per webhook delivery
* **No retries**: Failed webhooks are not automatically retried

<Tip>
  For high-volume scenarios, consider using a queue-based architecture (SQS, Redis, RabbitMQ) to buffer incoming webhooks.
</Tip>

***

## See Also

<CardGroup cols={2}>
  <Card title="Sessions API" icon="user-check" href="/api-reference/sessions">
    Create and manage KYC sessions
  </Card>

  <Card title="Workflows API" icon="sitemap" href="/api-reference/workflows">
    Configure automation rules and steps
  </Card>

  <Card title="Tenant Configuration" icon="gear" href="/api-reference/tenants">
    Manage notification channels
  </Card>

  <Card title="Webhooks Overview" icon="webhook" href="/api-reference/webhooks/overview">
    General webhook concepts and security
  </Card>
</CardGroup>
