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

# Webhook Integration

> Receive real-time KYC webhooks from gu1 when verification status, risk score, or alert state changes, with payload schema and signature validation.

## Overview

Webhooks allow you to receive real-time notifications when a KYC verification status changes. Gu1 automatically sends HTTP POST requests to your configured webhook endpoint whenever a validation status updates.

## Why Use Webhooks?

<CardGroup cols={2}>
  <Card title="Real-Time Updates" icon="bolt">
    Get instant notifications when verification status changes
  </Card>

  <Card title="Efficient" icon="gauge-high">
    No need to poll the API repeatedly
  </Card>

  <Card title="Automated Workflows" icon="robot">
    Automatically update user accounts based on verification results
  </Card>

  <Card title="Better UX" icon="face-smile">
    Notify customers immediately after verification
  </Card>
</CardGroup>

## Webhook Events

Gu1 sends webhooks for the following KYC validation events:

| Event Type                   | Description                   | When Triggered                           |
| ---------------------------- | ----------------------------- | ---------------------------------------- |
| `kyc.validation_created`     | Validation session created    | When you create a new KYC validation     |
| `kyc.validation_in_progress` | Customer started verification | Customer begins the verification process |
| `kyc.validation_approved`    | Verification approved         | Identity successfully verified           |
| `kyc.validation_rejected`    | Verification rejected         | Identity verification failed             |
| `kyc.validation_abandoned`   | Customer abandoned process    | Customer left without completing         |
| `kyc.validation_expired`     | Validation session expired    | Session expired (typically after 7 days) |

## Setting Up Webhooks

### Step 1: Configure Webhook in Dashboard

Configure your webhook URL in the Gu1 dashboard:

1. **Navigate to Webhooks Settings**
   * Log in to your Gu1 dashboard
   * Go to **Settings** → **Webhooks**

2. **Create New Webhook**
   * Click **Add Webhook** or **Create Webhook**
   * Enter a descriptive name (e.g., "Production KYC Webhook")
   * Enter your webhook URL (must be HTTPS): `https://yourapp.com/webhooks/kyc`

3. **Select Environment**
   * Select **Sandbox** for testing
   * Select **Production** for live events

4. **Subscribe to Events**
   * Select all KYC events or specific ones:
     * `kyc.validation_created`
     * `kyc.validation_in_progress`
     * `kyc.validation_approved`
     * `kyc.validation_rejected`
     * `kyc.validation_abandoned`
     * `kyc.validation_expired`

5. **Copy the generated secret (you don't need to provide one)**
   * You do **not** need to create or provide a webhook secret. When you save the webhook, Gu1 **automatically generates** one for you.
   * **Copy and save the secret** when it is shown — you'll need it in your server to verify the `X-Webhook-Signature` header. You won't be able to see it again (but you can regenerate it later in webhook settings).

6. **Activate the Webhook**
   * Toggle the webhook to **Enabled**
   * Click **Save**

<Note>
  You can create separate webhooks for sandbox and production environments with different URLs.
</Note>

### Step 2: Create a Webhook Endpoint

Create an endpoint in your application to receive webhook POST requests:

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

  const app = express();

  // IMPORTANT: Use raw body for signature verification
  app.use(express.json({
    verify: (req, res, buf) => {
      req.rawBody = buf.toString('utf8');
    }
  }));

  app.post('/webhooks/kyc', async (req, res) => {
    try {
      // Verify webhook signature
      const signature = req.headers['x-webhook-signature'];
      const webhookSecret = process.env.GUENO_WEBHOOK_SECRET;

      if (!verifySignature(req.rawBody, signature, webhookSecret)) {
        console.error('Invalid webhook signature');
        return res.status(401).json({ error: 'Invalid signature' });
      }

      // Extract webhook data
      const { event, timestamp, organizationId, payload } = req.body;

      console.log('Received KYC webhook:', {
        event,
        validationId: payload.validationId,
        status: payload.status
      });

      // Process the webhook based on event type
      await handleKycWebhook(event, payload);

      // Return 200 to acknowledge receipt
      res.status(200).json({
        success: true,
        message: 'Webhook received'
      });
    } catch (error) {
      console.error('Webhook error:', error);
      // Still return 200 to prevent retries
      res.status(200).json({
        success: false,
        error: error.message
      });
    }
  });

  // Verify HMAC signature
  function verifySignature(rawBody, signature, secret) {
    const expectedSignature = crypto
      .createHmac('sha256', secret)
      .update(rawBody)
      .digest('hex');

    return signature === expectedSignature;
  }

  async function handleKycWebhook(event, data) {
    const { validationId, entityId, entity, status } = data;

    // Update your database with validation ID from Gu1
    await db.updateEntity(entity.externalId, {
      kycValidationId: validationId,
      kycStatus: status,
      lastUpdated: new Date()
    });

    // Perform actions based on event type
    switch (event) {
      case 'kyc.validation_created':
        console.log('KYC validation created for:', entity.name);
        break;

      case 'kyc.validation_in_progress':
        await notifyCustomer(entity.externalId, 'verification-started');
        break;

      case 'kyc.validation_approved':
        // Extract verified data
        const { extractedData, verifiedFields } = data;

        await db.updateEntity(entity.externalId, {
          verifiedData: extractedData,
          verifiedFields: verifiedFields,
          verifiedAt: data.verifiedAt,
          isVerified: true
        });

        await activateCustomerAccount(entity.externalId);
        await notifyCustomer(entity.externalId, 'verification-approved');
        break;

      case 'kyc.validation_rejected':
        await db.updateEntity(entity.externalId, {
          isVerified: false,
          rejectionReasons: data.warnings
        });

        await notifyCustomer(entity.externalId, 'verification-rejected');
        break;

      case 'kyc.validation_abandoned':
        await notifyCustomer(entity.externalId, 'verification-incomplete');
        break;

      case 'kyc.validation_expired':
        await notifyCustomer(entity.externalId, 'verification-expired');
        break;
    }
  }
  ```

  ```python Python (Flask) theme={null}
  from flask import Flask, request, jsonify
  import hmac
  import hashlib
  import json
  import logging

  app = Flask(__name__)

  @app.route('/webhooks/kyc', methods=['POST'])
  def kyc_webhook():
      try:
          # Get signature from header
          signature = request.headers.get('X-Webhook-Signature')
          webhook_secret = os.getenv('GUENO_WEBHOOK_SECRET')

          # Get raw body for signature verification
          raw_body = request.get_data(as_text=True)

          # Verify signature
          if not verify_signature(raw_body, signature, webhook_secret):
              logging.error('Invalid webhook signature')
              return jsonify({'error': 'Invalid signature'}), 401

          # Parse payload
          payload = request.json
          event = payload.get('event')
          data = payload.get('payload')

          logging.info(f'Received KYC webhook: {event}')

          # Process the webhook
          handle_kyc_webhook(event, data)

          return jsonify({
              'success': True,
              'message': 'Webhook received'
          }), 200

      except Exception as e:
          logging.error(f'Webhook error: {e}')
          # Still return 200 to prevent retries
          return jsonify({
              'success': False,
              'error': str(e)
          }), 200

  def verify_signature(raw_body, signature, secret):
      """Verify HMAC SHA-256 signature"""
      expected_signature = hmac.new(
          secret.encode('utf-8'),
          raw_body.encode('utf-8'),
          hashlib.sha256
      ).hexdigest()

      return signature == expected_signature

  def handle_kyc_webhook(event, data):
      validation_id = data['validationId']
      entity_id = data['entityId']
      entity = data['entity']
      status = data['status']

      # Update database
      db.update_entity(
          external_id=entity['externalId'],
          kyc_validation_id=validation_id,
          kyc_status=status,
          last_updated=datetime.now()
      )

      # Handle different events
      if event == 'kyc.validation_approved':
          db.update_entity(
              external_id=entity['externalId'],
              verified_data=data.get('extractedData'),
              verified_fields=data.get('verifiedFields'),
              verified_at=data.get('verifiedAt'),
              is_verified=True
          )
          activate_customer_account(entity['externalId'])
          notify_customer(entity['externalId'], 'verification-approved')

      elif event == 'kyc.validation_rejected':
          notify_customer(entity['externalId'], 'verification-rejected')

      elif event == 'kyc.validation_abandoned':
          notify_customer(entity['externalId'], 'verification-incomplete')
  ```

  ```go Go (Gin) theme={null}
  package main

  import (
      "crypto/hmac"
      "crypto/sha256"
      "encoding/hex"
      "encoding/json"
      "io/ioutil"
      "log"
      "net/http"
      "os"

      "github.com/gin-gonic/gin"
  )

  type WebhookPayload struct {
      Event          string                 `json:"event"`
      Timestamp      string                 `json:"timestamp"`
      OrganizationID string                 `json:"organizationId"`
      Payload        map[string]interface{} `json:"payload"`
  }

  func main() {
      r := gin.Default()
      r.POST("/webhooks/kyc", handleKYCWebhook)
      r.Run(":8080")
  }

  func handleKYCWebhook(c *gin.Context) {
      // Read raw body for signature verification
      rawBody, err := ioutil.ReadAll(c.Request.Body)
      if err != nil {
          c.JSON(500, gin.H{"error": "Failed to read body"})
          return
      }

      // Verify signature
      signature := c.GetHeader("X-Webhook-Signature")
      webhookSecret := os.Getenv("GUENO_WEBHOOK_SECRET")

      if !verifySignature(rawBody, signature, webhookSecret) {
          log.Println("Invalid webhook signature")
          c.JSON(401, gin.H{"error": "Invalid signature"})
          return
      }

      // Parse payload
      var payload WebhookPayload
      if err := json.Unmarshal(rawBody, &payload); err != nil {
          c.JSON(400, gin.H{"error": "Invalid JSON"})
          return
      }

      log.Printf("Received webhook: %s", payload.Event)

      // Process webhook
      handleWebhook(payload)

      c.JSON(200, gin.H{
          "success": true,
          "message": "Webhook received",
      })
  }

  func verifySignature(rawBody []byte, signature, secret string) bool {
      mac := hmac.New(sha256.New, []byte(secret))
      mac.Write(rawBody)
      expectedSignature := hex.EncodeToString(mac.Sum(nil))

      return signature == expectedSignature
  }

  func handleWebhook(payload WebhookPayload) {
      switch payload.Event {
      case "kyc.validation_approved":
          // Handle approval
          log.Println("KYC approved")
      case "kyc.validation_rejected":
          // Handle rejection
          log.Println("KYC rejected")
      // ... handle other events
      }
  }
  ```
</CodeGroup>

### Step 3: Make Your Endpoint Publicly Accessible

Your webhook endpoint must be:

* **Publicly accessible** via HTTPS
* **Able to receive POST requests**
* **Return a 200 status code** quickly (within 30 seconds)

<Note>
  For local development, use tools like [ngrok](https://ngrok.com/) to create a public URL that tunnels to your local server.
</Note>

## Security: Verifying Webhook Signatures

**Always verify webhook signatures** to ensure requests are coming from Gu1.

### How Signature Verification Works

1. Gu1 generates an HMAC SHA-256 signature of the webhook payload using your secret
2. The signature is sent in the `X-Webhook-Signature` header
3. Your server recalculates the signature using the same secret
4. Compare the signatures - if they match, the webhook is authentic

### Signature Verification Examples

<CodeGroup>
  ```javascript Node.js theme={null}
  const crypto = require('crypto');

  function verifySignature(rawBody, signature, secret) {
    const expectedSignature = crypto
      .createHmac('sha256', secret)
      .update(rawBody)
      .digest('hex');

    return signature === expectedSignature;
  }

  // In your webhook endpoint:
  app.post('/webhooks/kyc', (req, res) => {
    const signature = req.headers['x-webhook-signature'];
    const secret = process.env.GUENO_WEBHOOK_SECRET;

    if (!verifySignature(req.rawBody, signature, secret)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }

    // Process webhook...
  });
  ```

  ```python Python theme={null}
  import hmac
  import hashlib

  def verify_signature(raw_body: str, signature: str, secret: str) -> bool:
      expected_signature = hmac.new(
          secret.encode('utf-8'),
          raw_body.encode('utf-8'),
          hashlib.sha256
      ).hexdigest()

      return signature == expected_signature

  # In your Flask route:
  @app.route('/webhooks/kyc', methods=['POST'])
  def webhook():
      signature = request.headers.get('X-Webhook-Signature')
      secret = os.getenv('GUENO_WEBHOOK_SECRET')
      raw_body = request.get_data(as_text=True)

      if not verify_signature(raw_body, signature, secret):
          return jsonify({'error': 'Invalid signature'}), 401

      # Process webhook...
  ```
</CodeGroup>

<Warning>
  Never skip signature verification in production. Without it, anyone can send fake webhooks to your endpoint.
</Warning>

## HTTP Headers

Every webhook request includes these headers:

| Header                | Description               | Example                                |
| --------------------- | ------------------------- | -------------------------------------- |
| `Content-Type`        | Always `application/json` | `application/json`                     |
| `X-Webhook-Event`     | Event type                | `kyc.validation_approved`              |
| `X-Webhook-ID`        | Webhook configuration ID  | `550e8400-e29b-41d4-a716-446655440000` |
| `X-Webhook-Timestamp` | ISO 8601 timestamp        | `2025-01-15T10:30:00.000Z`             |
| `X-Webhook-Signature` | HMAC SHA-256 signature    | `abc123...`                            |

## Webhook Payload Structure

All webhooks follow this standard structure:

```json theme={null}
{
  "event": "kyc.validation_approved",
  "timestamp": "2025-01-15T11:00:00Z",
  "organizationId": "org-123",
  "payload": {
    "validationId": "550e8400-e29b-41d4-a716-446655440000",
    "entityId": "123e4567-e89b-12d3-a456-426614174000",
    "entity": {
      "id": "123e4567-e89b-12d3-a456-426614174000",
      "externalId": "customer_xyz789",
      "name": "John Doe",
      "type": "person"
    },
    "status": "approved"
    // ... event-specific fields
  }
}
```

### Common Payload Fields

<ResponseField name="event" type="string">
  The event type (e.g., `kyc.validation_approved`)
</ResponseField>

<ResponseField name="timestamp" type="string">
  ISO 8601 timestamp when the event occurred
</ResponseField>

<ResponseField name="organizationId" type="string">
  Your organization ID
</ResponseField>

<ResponseField name="payload.validationId" type="string">
  The KYC validation ID in Gu1
</ResponseField>

<ResponseField name="payload.entityId" type="string">
  The entity (person) ID being verified
</ResponseField>

<ResponseField name="payload.entity" type="object">
  Entity information including your `externalId` for easy lookup
</ResponseField>

<ResponseField name="payload.status" type="string">
  Current validation status: `pending`, `in_progress`, `in_review`, `approved`, `rejected`, `abandoned`, `expired`, `cancelled`
</ResponseField>

## Event-Specific Payloads

### kyc.validation\_created

Sent when a new KYC validation is created.

```json theme={null}
{
  "event": "kyc.validation_created",
  "timestamp": "2025-01-15T10:30:00Z",
  "organizationId": "org-123",
  "payload": {
    "validationId": "550e8400-e29b-41d4-a716-446655440000",
    "entityId": "123e4567-e89b-12d3-a456-426614174000",
    "entity": {
      "id": "123e4567-e89b-12d3-a456-426614174000",
      "externalId": "customer_xyz789",
      "name": "John Doe",
      "type": "person"
    },
    "status": "pending"
  }
}
```

### kyc.validation\_in\_progress

Sent when a customer starts the verification process.

```json theme={null}
{
  "event": "kyc.validation_in_progress",
  "timestamp": "2025-01-15T10:35:00Z",
  "organizationId": "org-123",
  "payload": {
    "validationId": "550e8400-e29b-41d4-a716-446655440000",
    "entityId": "123e4567-e89b-12d3-a456-426614174000",
    "entity": { ... },
    "status": "in_progress"
  }
}
```

### kyc.validation\_approved

Sent when verification is successfully completed.

```json theme={null}
{
  "event": "kyc.validation_approved",
  "timestamp": "2025-01-15T11:00:00Z",
  "organizationId": "org-123",
  "payload": {
    "validationId": "550e8400-e29b-41d4-a716-446655440000",
    "entityId": "123e4567-e89b-12d3-a456-426614174000",
    "entity": { ... },
    "status": "approved",
    "verifiedAt": "2025-01-15T11:00:00Z",
    "extractedData": {
      "firstName": "John",
      "lastName": "Doe",
      "dateOfBirth": "1990-05-20",
      "nationality": "US",
      "documentNumber": "AB123456",
      "documentType": "passport"
    },
    "verifiedFields": [
      "firstName",
      "lastName",
      "dateOfBirth",
      "nationality",
      "documentNumber"
    ],
    "warnings": [],
    "decision": {
      "status": "Approved",
      "workflow_type": "standard",
      "session_id": "7c0fa22d-0cfa-4a78-8090-7c842397e788",
      "session_number": 921,
      "features": ["ID_VERIFICATION", "LIVENESS", "FACE_MATCH", "IP_ANALYSIS"],
      "images": {
        "documentFront": "kyc/global_gueno_validation_kyc/org-uuid/entity-uuid/validation-uuid/document-front.jpg",
        "documentBack": "kyc/global_gueno_validation_kyc/org-uuid/entity-uuid/validation-uuid/document-back.jpg",
        "selfie": "kyc/global_gueno_validation_kyc/org-uuid/entity-uuid/validation-uuid/selfie.jpg"
      },
      "id_verification": {
        "status": "Approved",
        "node_id": "feature_ocr",
        "document_type": "Passport",
        "document_number": "AB123456",
        "first_name": "John",
        "last_name": "Doe",
        "full_name": "John Doe",
        "date_of_birth": "1990-05-20",
        "nationality": "US",
        "gender": "M",
        "age": 35,
        "issuing_state": "US",
        "expiration_date": "2030-05-20",
        "date_of_issue": "2020-05-20",
        "front_image": "kyc/global_gueno_validation_kyc/org-uuid/entity-uuid/validation-uuid/document-front.jpg",
        "back_image": "kyc/global_gueno_validation_kyc/org-uuid/entity-uuid/validation-uuid/document-back.jpg",
        "portrait_image": "kyc/global_gueno_validation_kyc/org-uuid/entity-uuid/validation-uuid/selfie.jpg",
        "warnings": [],
        "matches": []
      },
      "id_verifications": [
        {
          "status": "Approved",
          "node_id": "feature_ocr",
          "document_type": "Passport",
          "document_number": "AB123456",
          "first_name": "John",
          "last_name": "Doe",
          "full_name": "John Doe",
          "date_of_birth": "1990-05-20",
          "nationality": "US",
          "gender": "M",
          "age": 35,
          "issuing_state": "US",
          "expiration_date": "2030-05-20",
          "date_of_issue": "2020-05-20",
          "front_image": "kyc/global_gueno_validation_kyc/org-uuid/entity-uuid/validation-uuid/document-front.jpg",
          "back_image": "kyc/global_gueno_validation_kyc/org-uuid/entity-uuid/validation-uuid/document-back.jpg",
          "portrait_image": "kyc/global_gueno_validation_kyc/org-uuid/entity-uuid/validation-uuid/selfie.jpg",
          "warnings": [],
          "matches": []
        }
      ],
      "liveness": {
        "status": "Approved",
        "node_id": "feature_liveness",
        "score": 98,
        "method": "PASSIVE",
        "reference_image": "kyc/global_gueno_validation_kyc/org-uuid/entity-uuid/validation-uuid/liveness-reference.jpg",
        "video_url": "kyc/global_gueno_validation_kyc/org-uuid/entity-uuid/validation-uuid/liveness-video.webm",
        "face_quality": 92.5,
        "warnings": [],
        "matches": []
      },
      "liveness_checks": [
        {
          "status": "Approved",
          "node_id": "feature_liveness",
          "score": 98,
          "method": "PASSIVE",
          "reference_image": "kyc/global_gueno_validation_kyc/org-uuid/entity-uuid/validation-uuid/liveness-reference.jpg",
          "video_url": "kyc/global_gueno_validation_kyc/org-uuid/entity-uuid/validation-uuid/liveness-video.webm",
          "face_quality": 92.5,
          "warnings": [],
          "matches": []
        }
      ],
      "face_match": {
        "status": "Approved",
        "node_id": "feature_face_match",
        "score": 95,
        "source_image": "kyc/global_gueno_validation_kyc/org-uuid/entity-uuid/validation-uuid/document-front.jpg",
        "target_image": "kyc/global_gueno_validation_kyc/org-uuid/entity-uuid/validation-uuid/selfie.jpg",
        "warnings": []
      },
      "face_matches": [
        {
          "status": "Approved",
          "node_id": "feature_face_match",
          "score": 95,
          "source_image": "kyc/global_gueno_validation_kyc/org-uuid/entity-uuid/validation-uuid/document-front.jpg",
          "target_image": "kyc/global_gueno_validation_kyc/org-uuid/entity-uuid/validation-uuid/selfie.jpg",
          "warnings": []
        }
      ],
      "aml_screening": {
        "status": "Approved",
        "node_id": "feature_aml",
        "warnings": []
      },
      "aml_screenings": [
        {
          "status": "Approved",
          "node_id": "feature_aml",
          "warnings": []
        }
      ],
      "ip_analysis": {
        "status": "Approved",
        "node_id": "feature_ip_analysis",
        "ip_address": "203.0.113.10",
        "country": "US",
        "region": "New York",
        "city": "New York",
        "is_vpn": false,
        "is_proxy": false,
        "warnings": []
      },
      "ip_analyses": [
        {
          "status": "Approved",
          "node_id": "feature_ip_analysis",
          "ip_address": "203.0.113.10",
          "country": "US",
          "region": "New York",
          "city": "New York",
          "is_vpn": false,
          "is_proxy": false,
          "warnings": []
        }
      ]
    }
  }
}
```

**Additional Fields:**

* `verifiedAt`: Timestamp when verification was approved
* `extractedData`: Personal information extracted from the document
* `verifiedFields`: Array of fields that were successfully verified
* `warnings`: Array of any warnings detected during verification
* `decision`: Verification results (images/URLs removed for security)

### kyc.validation\_rejected

Sent when verification fails.

```json theme={null}
{
  "event": "kyc.validation_rejected",
  "timestamp": "2025-01-15T11:00:00Z",
  "organizationId": "org-123",
  "payload": {
    "validationId": "550e8400-e29b-41d4-a716-446655440000",
    "entityId": "123e4567-e89b-12d3-a456-426614174000",
    "entity": { ... },
    "status": "rejected",
    "verifiedAt": "2025-01-15T11:00:00Z",
    "extractedData": {},
    "verifiedFields": [],
    "warnings": [
      "Document authenticity check failed",
      "Face match confidence low"
    ],
    "decision": {
      "status": "Declined",
      "workflow_type": "standard",
      "session_id": "7c0fa22d-0cfa-4a78-8090-7c842397e788",
      "session_number": 921,
      "features": ["ID_VERIFICATION", "LIVENESS", "FACE_MATCH"],
      "images": {
        "documentFront": "kyc/global_gueno_validation_kyc/org-uuid/entity-uuid/validation-uuid/document-front.jpg",
        "selfie": "kyc/global_gueno_validation_kyc/org-uuid/entity-uuid/validation-uuid/selfie.jpg"
      },
      "id_verification": {
        "status": "Declined",
        "node_id": "feature_ocr",
        "document_type": "Passport",
        "document_number": "AB123456",
        "first_name": "John",
        "last_name": "Doe",
        "full_name": "John Doe",
        "front_image": "kyc/global_gueno_validation_kyc/org-uuid/entity-uuid/validation-uuid/document-front.jpg",
        "portrait_image": "kyc/global_gueno_validation_kyc/org-uuid/entity-uuid/validation-uuid/selfie.jpg",
        "warnings": [
          {
            "risk": "DOCUMENT_AUTHENTICITY_FAILED",
            "feature": "ID_VERIFICATION",
            "short_description": "Document authenticity could not be verified",
            "long_description": "The document failed authenticity checks.",
            "log_type": "error"
          }
        ],
        "matches": []
      },
      "id_verifications": [
        {
          "status": "Declined",
          "node_id": "feature_ocr",
          "document_type": "Passport",
          "document_number": "AB123456",
          "first_name": "John",
          "last_name": "Doe",
          "full_name": "John Doe",
          "front_image": "kyc/global_gueno_validation_kyc/org-uuid/entity-uuid/validation-uuid/document-front.jpg",
          "portrait_image": "kyc/global_gueno_validation_kyc/org-uuid/entity-uuid/validation-uuid/selfie.jpg",
          "warnings": [
            {
              "risk": "DOCUMENT_AUTHENTICITY_FAILED",
              "feature": "ID_VERIFICATION",
              "short_description": "Document authenticity could not be verified",
              "long_description": "The document failed authenticity checks.",
              "log_type": "error"
            }
          ],
          "matches": []
        }
      ],
      "liveness": {
        "status": "Declined",
        "node_id": "feature_liveness",
        "score": 42,
        "method": "PASSIVE",
        "reference_image": "kyc/global_gueno_validation_kyc/org-uuid/entity-uuid/validation-uuid/liveness-reference.jpg",
        "warnings": [
          {
            "risk": "LIVENESS_FAILED",
            "feature": "LIVENESS",
            "short_description": "Liveness detection failed",
            "long_description": "The liveness check did not pass the required threshold.",
            "log_type": "error"
          }
        ],
        "matches": []
      },
      "liveness_checks": [
        {
          "status": "Declined",
          "node_id": "feature_liveness",
          "score": 42,
          "method": "PASSIVE",
          "reference_image": "kyc/global_gueno_validation_kyc/org-uuid/entity-uuid/validation-uuid/liveness-reference.jpg",
          "warnings": [
            {
              "risk": "LIVENESS_FAILED",
              "feature": "LIVENESS",
              "short_description": "Liveness detection failed",
              "long_description": "The liveness check did not pass the required threshold.",
              "log_type": "error"
            }
          ],
          "matches": []
        }
      ],
      "face_match": {
        "status": "Declined",
        "node_id": "feature_face_match",
        "score": 38,
        "source_image": "kyc/global_gueno_validation_kyc/org-uuid/entity-uuid/validation-uuid/document-front.jpg",
        "target_image": "kyc/global_gueno_validation_kyc/org-uuid/entity-uuid/validation-uuid/selfie.jpg",
        "warnings": [
          {
            "risk": "FACE_MATCH_LOW_CONFIDENCE",
            "feature": "FACE_MATCH",
            "short_description": "Face match confidence below threshold",
            "long_description": "The selfie did not match the document portrait with sufficient confidence.",
            "log_type": "error"
          }
        ]
      },
      "face_matches": [
        {
          "status": "Declined",
          "node_id": "feature_face_match",
          "score": 38,
          "source_image": "kyc/global_gueno_validation_kyc/org-uuid/entity-uuid/validation-uuid/document-front.jpg",
          "target_image": "kyc/global_gueno_validation_kyc/org-uuid/entity-uuid/validation-uuid/selfie.jpg",
          "warnings": [
            {
              "risk": "FACE_MATCH_LOW_CONFIDENCE",
              "feature": "FACE_MATCH",
              "short_description": "Face match confidence below threshold",
              "long_description": "The selfie did not match the document portrait with sufficient confidence.",
              "log_type": "error"
            }
          ]
        }
      ]
    }
  }
}
```

### kyc.validation\_abandoned

Sent when a customer starts but doesn't complete the verification.

```json theme={null}
{
  "event": "kyc.validation_abandoned",
  "timestamp": "2025-01-15T10:45:00Z",
  "organizationId": "org-123",
  "payload": {
    "validationId": "550e8400-e29b-41d4-a716-446655440000",
    "entityId": "123e4567-e89b-12d3-a456-426614174000",
    "entity": { ... },
    "status": "abandoned"
  }
}
```

### kyc.validation\_expired

Sent when a validation session expires without completion.

```json theme={null}
{
  "event": "kyc.validation_expired",
  "timestamp": "2025-01-15T12:00:00Z",
  "organizationId": "org-123",
  "payload": {
    "validationId": "550e8400-e29b-41d4-a716-446655440000",
    "entityId": "123e4567-e89b-12d3-a456-426614174000",
    "entity": { ... },
    "status": "expired",
    "expiresAt": "2025-01-22T10:30:00Z"
  }
}
```

## Retry Policy

If your webhook endpoint fails to respond with a 2xx status code, Gu1 will automatically retry delivery.

**Default Retry Policy:**

* **Max Retries**: 3 attempts
* **Initial Delay**: 1000ms (1 second)
* **Backoff Multiplier**: 2x
* **Retry Sequence**: 1s → 2s → 4s

**Example Timeline:**

1. Initial attempt at `T+0s`
2. First retry at `T+1s`
3. Second retry at `T+3s` (1s + 2s)
4. Third retry at `T+7s` (1s + 2s + 4s)

**Success Criteria:**

* HTTP status codes 200-299 are considered successful
* Any other status code or network error triggers a retry

**Timeout:**

* Each attempt has a 30-second timeout
* If your endpoint doesn't respond within 30 seconds, the attempt is marked as failed

<Tip>
  You can view all webhook delivery attempts (including retries) in the **Webhooks → History** section of your dashboard.
</Tip>

## Best Practices

<AccordionGroup>
  <Accordion title="Return 200 Quickly">
    Always return a 200 status code as quickly as possible to acknowledge receipt. Process the webhook asynchronously if needed.

    ```javascript theme={null}
    app.post('/webhooks/kyc', async (req, res) => {
      // Acknowledge immediately
      res.status(200).send('OK');

      // Process asynchronously
      processWebhook(req.body).catch(console.error);
    });
    ```
  </Accordion>

  <Accordion title="Verify Signatures">
    Always verify the `X-Webhook-Signature` header to ensure the webhook is authentic.

    ```javascript theme={null}
    const signature = req.headers['x-webhook-signature'];
    if (!verifySignature(req.rawBody, signature, secret)) {
      return res.status(401).json({ error: 'Invalid signature' });
    }
    ```
  </Accordion>

  <Accordion title="Handle Idempotency">
    You might receive the same webhook multiple times. Use the `validationId` to ensure you process each event only once.

    ```javascript theme={null}
    async function handleWebhook(webhook) {
      const alreadyProcessed = await db.checkWebhookProcessed(
        webhook.payload.validationId,
        webhook.event
      );

      if (alreadyProcessed) {
        return; // Skip duplicate
      }

      // Process webhook
      await processValidation(webhook.payload);

      // Mark as processed
      await db.markWebhookProcessed(
        webhook.payload.validationId,
        webhook.event
      );
    }
    ```
  </Accordion>

  <Accordion title="Use entity.externalId for Lookup">
    The webhook includes `entity.externalId` which is the ID you provided when creating the entity. Use this to look up the customer in your database.

    ```javascript theme={null}
    const customer = await db.findCustomer({
      externalId: data.entity.externalId
    });
    ```
  </Accordion>

  <Accordion title="Store Validation IDs">
    Store the `validationId` from Gu1 in your database. This allows you to query validation details later if needed.

    ```javascript theme={null}
    await db.updateCustomer(customer.id, {
      kycValidationId: data.validationId,
      kycStatus: data.status
    });
    ```
  </Accordion>

  <Accordion title="Handle Errors Gracefully">
    If processing fails, log the error but still return 200 to prevent retries. Store failed webhooks for manual review.

    ```javascript theme={null}
    try {
      await processWebhook(payload);
    } catch (error) {
      await db.saveFailedWebhook({
        payload,
        error: error.message,
        receivedAt: new Date()
      });

      // Still return 200
      res.status(200).json({ success: false });
    }
    ```
  </Accordion>

  <Accordion title="Use Environment-Specific Webhooks">
    Create separate webhook configurations for sandbox and production environments.

    * **Sandbox**: Use for testing with test data
    * **Production**: Use for live customer verifications

    This allows you to safely test webhook handling without affecting production systems.
  </Accordion>
</AccordionGroup>

## Testing Webhooks

### Testing in Dashboard

1. Go to **Settings** → **Webhooks**
2. Select your webhook
3. Click **Test Webhook**
4. Gu1 will send a test event to your endpoint
5. Check the response status and logs

### Local Development

Use ngrok to expose your local server:

```bash theme={null}
# Start ngrok
ngrok http 3000

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

### Testing Flow

1. Create a sandbox webhook pointing to your development endpoint
2. Create a test KYC validation
3. Your webhook endpoint receives `kyc.validation_created`
4. Complete the verification (or simulate different outcomes)
5. Your webhook endpoint receives status updates

## Monitoring and Debugging

### Webhook Logs

View webhook delivery history in your dashboard:

1. Go to **Settings** → **Webhooks**
2. Select your webhook
3. Click **View Logs** or **History**

**Logs include:**

* Timestamp of each delivery attempt
* HTTP status code received
* Response body
* Response time
* Error messages (if any)
* Retry attempts

### Webhook Statistics

Each webhook displays:

* **Total Triggers**: Total number of times the webhook was triggered
* **Success Count**: Successful deliveries
* **Failure Count**: Failed deliveries
* **Last Triggered**: Timestamp of last attempt
* **Last Success**: Timestamp of last successful delivery
* **Last Failure**: Timestamp of last failed delivery

## Troubleshooting

<AccordionGroup>
  <Accordion title="Not Receiving Webhooks">
    **Check these items:**

    * Webhook URL is publicly accessible via HTTPS
    * Firewall allows incoming POST requests from Gu1
    * Endpoint returns 200 status code within 30 seconds
    * Webhook is configured and **enabled** in dashboard
    * Correct environment selected (sandbox vs production)
    * Check server logs for incoming requests
    * Verify webhook subscribed to correct event types
  </Accordion>

  <Accordion title="Signature Verification Failing">
    **Common causes:**

    * Using wrong secret (check dashboard for current secret)
    * Verifying signature on parsed JSON instead of raw body
    * Secret not properly saved after webhook creation
    * Encoding issues (ensure UTF-8)

    **Solution:**

    ```javascript theme={null}
    // WRONG - verifying parsed body
    const signature = crypto.createHmac('sha256', secret)
      .update(JSON.stringify(req.body))
      .digest('hex');

    // RIGHT - use raw body before parsing
    const signature = crypto.createHmac('sha256', secret)
      .update(req.rawBody)
      .digest('hex');
    ```
  </Accordion>

  <Accordion title="Receiving Duplicate Webhooks">
    This is normal behavior. Webhooks may be sent multiple times due to:

    * Network issues
    * Timeouts
    * Retries after failures

    **Always implement idempotency** using the webhook `validationId` and `event` type.
  </Accordion>

  <Accordion title="Webhook Endpoint Timing Out">
    Your endpoint must respond within **30 seconds**. If processing takes longer:

    ```javascript theme={null}
    app.post('/webhooks', async (req, res) => {
      // Respond immediately
      res.status(200).json({ received: true });

      // Process in background
      await queueWebhookProcessing(req.body);
    });
    ```
  </Accordion>

  <Accordion title="Missing extractedData">
    `extractedData` and `verifiedFields` are only included in:

    * `kyc.validation_approved`
    * `kyc.validation_rejected`

    They are not present in other event types like `validation_created` or `validation_in_progress`.
  </Accordion>

  <Accordion title="Webhook Secret Lost">
    If you lost your webhook secret:

    1. Go to **Settings** → **Webhooks**
    2. Select your webhook
    3. Click **Regenerate Secret**
    4. Save the new secret in your environment variables
    5. Update your application with the new secret

    **Note**: Old secret will stop working immediately after regeneration.
  </Accordion>
</AccordionGroup>

## Next Steps

<CardGroup cols={2}>
  <Card title="Check Verification Status" icon="magnifying-glass" href="/en/use-cases/kyc/check-status">
    Query validation results via API
  </Card>

  <Card title="Create KYC Validation" icon="play" href="/en/use-cases/kyc/create-validation">
    Start a new verification
  </Card>

  <Card title="Entity Events" icon="bell" href="/en/webhooks/overview">
    Learn about other webhook events
  </Card>

  <Card title="API Reference" icon="code" href="/en/webhooks/configuration">
    Webhook API documentation
  </Card>
</CardGroup>
