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

# Integración de Webhooks

> Recibir notificaciones en tiempo real cuando cambia el estado de verificación KYC — en la API KYC de gu1 para flujos de verificación de identidad.

## Resumen

Los webhooks te permiten recibir notificaciones en tiempo real cuando cambia el estado de verificación KYC. Gu1 envía automáticamente solicitudes HTTP POST a tu endpoint webhook configurado cada vez que se actualiza el estado de una validación.

## ¿Por Qué Usar Webhooks?

<CardGroup cols={2}>
  <Card title="Actualizaciones en Tiempo Real" icon="bolt">
    Recibe notificaciones instantáneas cuando cambia el estado de verificación
  </Card>

  <Card title="Eficiente" icon="gauge-high">
    No necesitas consultar la API repetidamente
  </Card>

  <Card title="Flujos Automáticos" icon="robot">
    Actualiza automáticamente cuentas de usuario según los resultados de verificación
  </Card>

  <Card title="Mejor UX" icon="face-smile">
    Notifica a los clientes inmediatamente después de la verificación
  </Card>
</CardGroup>

## Eventos de Webhook

Gu1 envía webhooks para los siguientes eventos de validación KYC:

| Tipo de Evento               | Descripción                 | Cuándo se Dispara                             |
| ---------------------------- | --------------------------- | --------------------------------------------- |
| `kyc.validation_created`     | Sesión de validación creada | Cuando creas una nueva validación KYC         |
| `kyc.validation_in_progress` | Cliente inició verificación | Cliente comienza el proceso de verificación   |
| `kyc.validation_approved`    | Verificación aprobada       | Identidad verificada exitosamente             |
| `kyc.validation_rejected`    | Verificación rechazada      | Verificación de identidad falló               |
| `kyc.validation_abandoned`   | Cliente abandonó el proceso | Cliente abandonó sin completar                |
| `kyc.validation_expired`     | Sesión de validación expiró | Sesión expiró (típicamente después de 7 días) |

## Configurar Webhooks

### Paso 1: Configurar Webhook en el Dashboard

Configura la URL de tu webhook en el dashboard de Gu1:

1. **Navega a Configuración de Webhooks**
   * Inicia sesión en tu dashboard de Gu1
   * Ve a **Configuración** → **Webhooks**

2. **Crear Nuevo Webhook**
   * Haz clic en **Agregar Webhook** o **Crear Webhook**
   * Ingresa un nombre descriptivo (ej., "Webhook KYC Producción")
   * Ingresa la URL de tu webhook (debe ser HTTPS): `https://tuapp.com/webhooks/kyc`

3. **Seleccionar Ambiente**
   * Elige **Sandbox** para pruebas
   * Elige **Producción** para eventos en vivo

4. **Suscribirse a Eventos**
   * Selecciona todos los eventos KYC o específicos:
     * `kyc.validation_created`
     * `kyc.validation_in_progress`
     * `kyc.validation_approved`
     * `kyc.validation_rejected`
     * `kyc.validation_abandoned`
     * `kyc.validation_expired`

5. **Copiar el secreto generado (no hace falta que proporciones uno)**
   * No necesitas crear ni proporcionar un secreto. Al guardar el webhook, Gu1 **genera automáticamente** un secreto.
   * **Copia y guarda el secreto** cuando se muestre — lo necesitarás en tu servidor para verificar el header `X-Webhook-Signature`. No podrás verlo de nuevo (pero puedes regenerarlo después en la configuración del webhook).

6. **Activar el Webhook**
   * Activa el webhook marcándolo como **Habilitado**
   * Haz clic en **Guardar**

<Note>
  Puedes crear webhooks separados para los ambientes sandbox y producción con URLs diferentes.
</Note>

### Paso 2: Crear un Endpoint de Webhook

Crea un endpoint en tu aplicación para recibir solicitudes POST de webhook:

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

  const app = express();

  // IMPORTANTE: Usar body raw para verificación de firma
  app.use(express.json({
    verify: (req, res, buf) => {
      req.rawBody = buf.toString('utf8');
    }
  }));

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

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

      // Extraer datos del webhook
      const { event, timestamp, organizationId, payload } = req.body;

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

      // Procesar el webhook según el tipo de evento
      await handleKycWebhook(event, payload);

      // Retornar 200 para confirmar recepción
      res.status(200).json({
        success: true,
        message: 'Webhook recibido'
      });
    } catch (error) {
      console.error('Error en webhook:', error);
      // Aún así retornar 200 para prevenir reintentos
      res.status(200).json({
        success: false,
        error: error.message
      });
    }
  });

  // Verificar firma HMAC
  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;

    // Actualizar tu base de datos con el ID de validación de Gu1
    await db.updateEntity(entity.externalId, {
      kycValidationId: validationId,
      kycStatus: status,
      lastUpdated: new Date()
    });

    // Realizar acciones según el tipo de evento
    switch (event) {
      case 'kyc.validation_created':
        console.log('Validación KYC creada para:', entity.name);
        break;

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

      case 'kyc.validation_approved':
        // Extraer datos verificados
        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, 'verificacion-aprobada');
        break;

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

        await notifyCustomer(entity.externalId, 'verificacion-rechazada');
        break;

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

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

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

  app = Flask(__name__)

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

          # Obtener body raw para verificación de firma
          raw_body = request.get_data(as_text=True)

          # Verificar firma
          if not verify_signature(raw_body, signature, webhook_secret):
              logging.error('Firma de webhook inválida')
              return jsonify({'error': 'Firma inválida'}), 401

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

          logging.info(f'Webhook KYC recibido: {event}')

          # Procesar el webhook
          handle_kyc_webhook(event, data)

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

      except Exception as e:
          logging.error(f'Error en webhook: {e}')
          # Aún así retornar 200 para prevenir reintentos
          return jsonify({
              'success': False,
              'error': str(e)
          }), 200

  def verify_signature(raw_body, signature, secret):
      """Verificar firma HMAC SHA-256"""
      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']

      # Actualizar base de datos
      db.update_entity(
          external_id=entity['externalId'],
          kyc_validation_id=validation_id,
          kyc_status=status,
          last_updated=datetime.now()
      )

      # Manejar diferentes eventos
      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'], 'verificacion-aprobada')

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

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

  ```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) {
      // Leer body raw para verificación de firma
      rawBody, err := ioutil.ReadAll(c.Request.Body)
      if err != nil {
          c.JSON(500, gin.H{"error": "Error al leer body"})
          return
      }

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

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

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

      log.Printf("Webhook recibido: %s", payload.Event)

      // Procesar webhook
      handleWebhook(payload)

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

  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":
          // Manejar aprobación
          log.Println("KYC aprobado")
      case "kyc.validation_rejected":
          // Manejar rechazo
          log.Println("KYC rechazado")
      // ... manejar otros eventos
      }
  }
  ```
</CodeGroup>

### Paso 3: Hacer tu Endpoint Públicamente Accesible

Tu endpoint de webhook debe:

* Ser **públicamente accesible** vía HTTPS
* **Poder recibir solicitudes POST**
* **Retornar código de estado 200** rápidamente (dentro de 30 segundos)

<Note>
  Para desarrollo local, usa herramientas como [ngrok](https://ngrok.com/) para crear una URL pública que haga túnel a tu servidor local.
</Note>

## Seguridad: Verificar Firmas de Webhook

**Siempre verifica las firmas de webhook** para asegurar que las solicitudes provienen de Gu1.

### Cómo Funciona la Verificación de Firmas

1. Gu1 genera una firma HMAC SHA-256 del payload del webhook usando tu secreto
2. La firma se envía en el header `X-Webhook-Signature`
3. Tu servidor recalcula la firma usando el mismo secreto
4. Compara las firmas - si coinciden, el webhook es auténtico

### Ejemplos de Verificación de Firma

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

  // En tu endpoint de webhook:
  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: 'Firma inválida' });
    }

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

  # En tu ruta de Flask:
  @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': 'Firma inválida'}), 401

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

<Warning>
  Nunca omitas la verificación de firma en producción. Sin ella, cualquiera puede enviar webhooks falsos a tu endpoint.
</Warning>

## Headers HTTP

Cada solicitud de webhook incluye estos headers:

| Header                | Descripción                     | Ejemplo                                |
| --------------------- | ------------------------------- | -------------------------------------- |
| `Content-Type`        | Siempre `application/json`      | `application/json`                     |
| `X-Webhook-Event`     | Tipo de evento                  | `kyc.validation_approved`              |
| `X-Webhook-ID`        | ID de configuración del webhook | `550e8400-e29b-41d4-a716-446655440000` |
| `X-Webhook-Timestamp` | Timestamp ISO 8601              | `2025-01-15T10:30:00.000Z`             |
| `X-Webhook-Signature` | Firma HMAC SHA-256              | `abc123...`                            |

## Estructura del Payload de Webhook

Todos los webhooks siguen esta estructura estándar:

```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"
    // ... campos específicos del evento
  }
}
```

### Campos Comunes del Payload

<ResponseField name="event" type="string">
  El tipo de evento (ej., `kyc.validation_approved`)
</ResponseField>

<ResponseField name="timestamp" type="string">
  Timestamp ISO 8601 cuando ocurrió el evento
</ResponseField>

<ResponseField name="organizationId" type="string">
  Tu ID de organización
</ResponseField>

<ResponseField name="payload.validationId" type="string">
  El ID de validación KYC en Gu1
</ResponseField>

<ResponseField name="payload.entityId" type="string">
  El ID de entidad (persona) siendo verificada
</ResponseField>

<ResponseField name="payload.entity" type="object">
  Información de la entidad incluyendo tu `externalId` para fácil búsqueda
</ResponseField>

<ResponseField name="payload.status" type="string">
  Estado actual de validación: `pending`, `in_progress`, `in_review`, `approved`, `rejected`, `abandoned`, `expired`, `cancelled`
</ResponseField>

## Payloads Específicos por Evento

### kyc.validation\_created

Enviado cuando se crea una nueva validación KYC.

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

Enviado cuando un cliente inicia el proceso de verificación.

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

Enviado cuando la verificación se completa exitosamente.

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

**Campos Adicionales:**

* `verifiedAt`: Timestamp cuando se aprobó la verificación
* `extractedData`: Información personal extraída del documento
* `verifiedFields`: Array de campos que fueron verificados exitosamente
* `warnings`: Array de advertencias detectadas durante la verificación
* `decision`: Resultados de verificación (imágenes/URLs removidas por seguridad)

### kyc.validation\_rejected

Enviado cuando la verificación falla.

```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": [
      "Falló la verificación de autenticidad del documento",
      "Confianza baja en coincidencia facial"
    ],
    "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

Enviado cuando un cliente inicia pero no completa la verificación.

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

Enviado cuando una sesión de validación expira sin completarse.

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

## Política de Reintentos

Si tu endpoint de webhook falla en responder con un código de estado 2xx, Gu1 automáticamente reintentará la entrega.

**Política de Reintentos Predeterminada:**

* **Reintentos Máximos**: 3 intentos
* **Delay Inicial**: 1000ms (1 segundo)
* **Multiplicador de Backoff**: 2x
* **Secuencia de Reintentos**: 1s → 2s → 4s

**Ejemplo de Timeline:**

1. Intento inicial en `T+0s`
2. Primer reintento en `T+1s`
3. Segundo reintento en `T+3s` (1s + 2s)
4. Tercer reintento en `T+7s` (1s + 2s + 4s)

**Criterio de Éxito:**

* Códigos de estado HTTP 200-299 se consideran exitosos
* Cualquier otro código de estado o error de red dispara un reintento

**Timeout:**

* Cada intento tiene un timeout de 30 segundos
* Si tu endpoint no responde dentro de 30 segundos, el intento se marca como fallido

<Tip>
  Puedes ver todos los intentos de entrega de webhook (incluyendo reintentos) en la sección **Webhooks → Historial** de tu dashboard.
</Tip>

## Mejores Prácticas

<AccordionGroup>
  <Accordion title="Retornar 200 Rápidamente">
    Siempre retorna un código de estado 200 lo más rápido posible para confirmar la recepción. Procesa el webhook asincrónicamente si es necesario.

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

      // Procesar asincrónicamente
      processWebhook(req.body).catch(console.error);
    });
    ```
  </Accordion>

  <Accordion title="Verificar Firmas">
    Siempre verifica el header `X-Webhook-Signature` para asegurar que el webhook es auténtico.

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

  <Accordion title="Manejar Idempotencia">
    Podrías recibir el mismo webhook múltiples veces. Usa el `validationId` para asegurar que procesas cada evento solo una vez.

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

      if (alreadyProcessed) {
        return; // Omitir duplicado
      }

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

      // Marcar como procesado
      await db.markWebhookProcessed(
        webhook.payload.validationId,
        webhook.event
      );
    }
    ```
  </Accordion>

  <Accordion title="Usar entity.externalId para Búsqueda">
    El webhook incluye `entity.externalId` que es el ID que proporcionaste al crear la entidad. Úsalo para buscar al cliente en tu base de datos.

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

  <Accordion title="Almacenar IDs de Validación">
    Almacena el `validationId` de Gu1 en tu base de datos. Esto te permite consultar detalles de validación después si es necesario.

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

  <Accordion title="Manejar Errores Graciosamente">
    Si el procesamiento falla, registra el error pero aún así retorna 200 para prevenir reintentos. Almacena webhooks fallidos para revisión manual.

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

      // Aún así retornar 200
      res.status(200).json({ success: false });
    }
    ```
  </Accordion>

  <Accordion title="Usar Webhooks Específicos por Ambiente">
    Crea configuraciones de webhook separadas para los ambientes sandbox y producción.

    * **Sandbox**: Usar para pruebas con datos de prueba
    * **Producción**: Usar para verificaciones de clientes en vivo

    Esto te permite probar el manejo de webhooks de forma segura sin afectar sistemas de producción.
  </Accordion>
</AccordionGroup>

## Probar Webhooks

### Probar en el Dashboard

1. Ve a **Configuración** → **Webhooks**
2. Selecciona tu webhook
3. Haz clic en **Probar Webhook**
4. Gu1 enviará un evento de prueba a tu endpoint
5. Verifica el estado de respuesta y logs

### Desarrollo Local

Usa ngrok para exponer tu servidor local:

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

# Usa la URL de ngrok como tu URL de webhook
https://abc123.ngrok.io/webhooks/kyc
```

### Flujo de Prueba

1. Crea un webhook sandbox apuntando a tu endpoint de desarrollo
2. Crea una validación KYC de prueba
3. Tu endpoint de webhook recibe `kyc.validation_created`
4. Completa la verificación (o simula diferentes resultados)
5. Tu endpoint de webhook recibe actualizaciones de estado

## Monitoreo y Debugging

### Logs de Webhook

Ver historial de entrega de webhook en tu dashboard:

1. Ve a **Configuración** → **Webhooks**
2. Selecciona tu webhook
3. Haz clic en **Ver Logs** o **Historial**

**Los logs incluyen:**

* Timestamp de cada intento de entrega
* Código de estado HTTP recibido
* Body de respuesta
* Tiempo de respuesta
* Mensajes de error (si hay)
* Intentos de reintento

### Estadísticas de Webhook

Cada webhook muestra:

* **Disparos Totales**: Número total de veces que se disparó el webhook
* **Conteo de Éxitos**: Entregas exitosas
* **Conteo de Fallos**: Entregas fallidas
* **Último Disparado**: Timestamp del último intento
* **Último Éxito**: Timestamp de última entrega exitosa
* **Último Fallo**: Timestamp de último fallo

## Solución de Problemas

<AccordionGroup>
  <Accordion title="No Recibir Webhooks">
    **Verifica estos elementos:**

    * URL del webhook es públicamente accesible vía HTTPS
    * Firewall permite solicitudes POST entrantes de Gu1
    * Endpoint retorna código de estado 200 dentro de 30 segundos
    * Webhook está configurado y **habilitado** en el dashboard
    * Ambiente correcto seleccionado (sandbox vs producción)
    * Verifica logs del servidor para solicitudes entrantes
    * Verifica que el webhook está suscrito a los tipos de evento correctos
  </Accordion>

  <Accordion title="Verificación de Firma Fallando">
    **Causas comunes:**

    * Usando secreto incorrecto (verifica el dashboard para el secreto actual)
    * Verificando firma en JSON parseado en lugar de body raw
    * Secreto no guardado correctamente después de crear webhook
    * Problemas de codificación (asegurar UTF-8)

    **Solución:**

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

    // CORRECTO - usar body raw antes de parsear
    const signature = crypto.createHmac('sha256', secret)
      .update(req.rawBody)
      .digest('hex');
    ```
  </Accordion>

  <Accordion title="Recibir Webhooks Duplicados">
    Este es un comportamiento normal. Los webhooks pueden enviarse múltiples veces debido a:

    * Problemas de red
    * Timeouts
    * Reintentos después de fallos

    **Siempre implementa idempotencia** usando el `validationId` del webhook y el tipo de `event`.
  </Accordion>

  <Accordion title="Endpoint de Webhook con Timeout">
    Tu endpoint debe responder dentro de **30 segundos**. Si el procesamiento toma más tiempo:

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

      // Procesar en segundo plano
      await queueWebhookProcessing(req.body);
    });
    ```
  </Accordion>

  <Accordion title="Falta extractedData">
    `extractedData` y `verifiedFields` solo se incluyen en:

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

    No están presentes en otros tipos de evento como `validation_created` o `validation_in_progress`.
  </Accordion>

  <Accordion title="Secreto de Webhook Perdido">
    Si perdiste tu secreto de webhook:

    1. Ve a **Configuración** → **Webhooks**
    2. Selecciona tu webhook
    3. Haz clic en **Regenerar Secreto**
    4. Guarda el nuevo secreto en tus variables de entorno
    5. Actualiza tu aplicación con el nuevo secreto

    **Nota**: El secreto antiguo dejará de funcionar inmediatamente después de la regeneración.
  </Accordion>
</AccordionGroup>

## Próximos Pasos

<CardGroup cols={2}>
  <Card title="Verificar Estado de Verificación" icon="magnifying-glass" href="/es/use-cases/kyc/check-status">
    Consultar resultados de validación vía API
  </Card>

  <Card title="Crear Validación KYC" icon="play" href="/es/use-cases/kyc/create-validation">
    Iniciar una nueva verificación
  </Card>

  <Card title="Eventos de Entidad" icon="bell" href="/es/webhooks/overview">
    Aprender sobre otros eventos de webhook
  </Card>

  <Card title="Referencia de API" icon="code" href="/es/webhooks/configuration">
    Documentación de API de webhooks
  </Card>
</CardGroup>
