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

# Webhooks

> Recibe eventos en tiempo real sobre cambios en expedientes KYB

Los webhooks permiten a tu sistema recibir notificaciones en tiempo real cuando ocurren eventos dentro de NUFI, evitando la necesidad de consultar continuamente la API.

Cuando ocurre un evento, NUFI enviará una solicitud `POST` a la URL que configures junto con la información del evento.

***

## Configuración

Para utilizar webhooks, debes:

1. Definir una URL pública que pueda recibir solicitudes `POST`
2. Configurar un `secret` para validar la autenticidad del webhook
3. Seleccionar al menos un evento

<Note>
  Debes seleccionar al menos un evento para registrar un webhook.
</Note>

***

## Eventos disponibles

<CardGroup cols={2}>
  <Card title="status_changed" icon="arrows-rotate-reverse">
    Se dispara cuando cambia el estado general de un expediente (Ej: Pendiente, En Revisión, Completado).
  </Card>

  <Card title="document_submitted" icon="upload">
    Notifica cuando un documento es enviado o actualizado.
  </Card>

  <Card title="document_verdict" icon="check-circle">
    Se dispara cuando un documento recibe un veredicto (Aprobado/Rechazado).
  </Card>

  <Card title="document_verdict_v2" icon="list-check">
    Se dispara cuando un documento recibe un veredicto con el detalle de cada validación aplicada.
  </Card>

  <Card title="channel_changed" icon="shuffle">
    Se activa cuando cambia el canal de comunicación o método de verificación.
  </Card>

  <Card title="section_feedback" icon="messages">
    Notifica cuando se actualiza una sección del expediente con retroalimentación.
  </Card>

  <Card title="document_json_export" icon="file-text">
    Envía la información completa del expediente en formato JSON al finalizar el proceso.
  </Card>

  <Card title="authorized_enrollment" icon="id-card">
    Notifica cuando un representante legal completa su enrolamiento biométrico con estatus AUTORIZADO.
  </Card>
</CardGroup>

***

## Estructura del Request

### Headers

```json theme={null}
{
  "content-type": "application/json",
  "x-nufi-event": "status_changed",
  "x-nufi-signature": "v1=firma_hmac_sha256",
  "x-nufi-webhook-timestamp": "timestamp_unix",
  "x-nufi-webhook-id": "uuid_del_webhook"
}
```

### Body (Ejemplo)

Se anexan ejemplos para cada tipo de evento

#### status\_changed

```json theme={null}
{
  "Type": "status_changed",
  "DocumentId": "27a2e1b1-d3f0-47f3-bf69-fb339c5fd861",
  "Timestamp": "2026-04-07T23:31:09.104111Z",
  "User": "4a5d3f24c0a247b88d7432ca25a2f51b",
  "NewStatus": "Pendiente de revisión por analista",
  "NextResponsible": "Analista",
  "ExternalId": ""
}
```

#### document\_submitted

```json theme={null}
{
  "Type": "document_submitted",
  "DocumentId": "27a2e1b1-d3f0-47f3-bf69-fb339c5fd861",
  "ExternalId": null,
  "Timestamp": "2026-04-07T23:31:09.7698766Z",
  "PreviousStatus": "Ingresado",
  "NewStatus": "Recibido"
}
```

#### document\_verdict

```json theme={null}
{
  "Type": "document_verdict",
  "DocumentId": "27a2e1b1-d3f0-47f3-bf69-fb339c5fd861",
  "ExternalId": null,
  "Timestamp": "2026-04-07T23:31:18.5780469Z",
  "Approved": true,
  "Comments": "Expediente APROBADO: Se encontró un documento de identificación oficial (Pasaporte) para la persona registrada. No es necesario presentar INE o Licencia si ya existe un Pasaporte válido en el expediente."
}
```

#### document\_verdict\_v2

Incluye el resultado global del veredicto (`Approved`) y un arreglo `Results` con el detalle de cada validación ejecutada. Cada elemento contiene:

* `id`: Identificador de la validación
* `valid`: Indica si la validación pasó (`true`) o falló (`false`)
* `message`: Descripción del resultado de la validación

```json theme={null}
{
  "Type": "document_verdict_v2",
  "DocumentId": "84796f86-8849-49a6-860d-7719bad639c7",
  "ExternalId": null,
  "Timestamp": "2026-06-13T00:14:26.2660673Z",
  "Approved": false,
  "Results": [
    {
      "id": "104",
      "valid": true,
      "message": "Nombre y apellidos del INE coinciden con la captura de datos"
    },
    {
      "id": "456",
      "valid": false,
      "message": "Se detectaron inconsistencias entre el documento y las fuentes de información consultadas"
    },
    {
      "id": "455",
      "valid": false,
      "message": "Se detectaron inconsistencias entre los documentos y las fuentes de información consultadas"
    },
    {
      "id": "503",
      "valid": false,
      "message": "Se detectaron inconsistencias entre los documentos y las fuentes de información consultadas"
    },
    {
      "id": "153",
      "valid": false,
      "message": "El documento presentado no cumple con los criterios de validación requeridos"
    },
    {
      "id": "451",
      "valid": false,
      "message": "Se detectaron inconsistencias entre el documento y las fuentes de información consultadas"
    },
    {
      "id": "100",
      "valid": true,
      "message": "Datos personales presentes en identificación"
    },
    {
      "id": "301",
      "valid": false,
      "message": "Se detectaron inconsistencias entre los documentos y las fuentes de información consultadas"
    },
    {
      "id": "1001",
      "valid": false,
      "message": "INE VENCIDA"
    },
    {
      "id": "453",
      "valid": false,
      "message": "Se detectaron inconsistencias entre el documento y las fuentes de información consultadas"
    },
    {
      "id": "151",
      "valid": true,
      "message": "RFC, CURP y datos fiscales de la CSF validados correctamente"
    },
    {
      "id": "152",
      "valid": true,
      "message": "Domicilio fiscal validado correctamente"
    },
    {
      "id": "502",
      "valid": true,
      "message": "Información de la CSF consistente con QR SAT."
    },
    {
      "id": "156",
      "valid": true,
      "message": "Nombre y apellidos del INE coinciden con la captura de datos"
    },
    {
      "id": "157",
      "valid": false,
      "message": "Los datos del documento no coinciden con la información capturada o validada"
    }
  ]
}
```

#### channel\_changed

```json theme={null}
{
  "Type": "channel_changed",
  "DocumentId": "27a2e1b1-d3f0-47f3-bf69-fb339c5fd861",
  "Timestamp": "2026-04-07T23:32:03.1556061Z",
  "User": "Sistema",
  "PreviousChannel": "Sin canal",
  "NewChannel": "Validacion",
  "ExternalId": ""
}
```

#### section\_feedback

```json theme={null}
{
  "Type": "section_feedback",
  "DocumentId": "27a2e1b1-d3f0-47f3-bf69-fb339c5fd861",
  "Timestamp": "2026-04-07T23:32:49.0068553Z",
  "User": "soporte@nufi.mx",
  "Section": "ConstanciaFiscal",
  "Status": "Rechazado",
  "Comment": "Documento en B/N",
  "ExternalId": ""
}
```

#### document\_json\_export

```json theme={null}
{
  "Type": "document_json_export",
  "DocumentId": "27a2e1b1-d3f0-47f3-bf69-fb339c5fd861",
  "ExternalId": null,
  "Timestamp": "2026-04-07T23:33:13.1582278Z",
  "Data": { ... }
}
```

#### authorized\_enrollment

```json theme={null}
{
  "Type": "authorized_enrollment",
  "DocumentId": "3000f2cc-a26c-4b94-939c-01c679376a11",
  "Timestamp": "2026-04-27T23:37:57.2132318Z",
  "Source": null,
  "CompanyRfc": "OUAI970115287",
  "CompanyName": "IGNACIO RUBEN ORTUÑO ALBARRAN",
  "PersonId": "a9447718-e565-4109-95b0-9d6bed7869bb",
  "FullName": "IGNACIO RUBEN ORTUÑO ALBARRAN",
  "Email": "soporte@NUFI.MX",
  "Phone": "4433838550",
  "Rfc": "OUAI970115287",
  "Curp": "OUAI970115HGRRLG15",
  "FaceKeyId": "T000014885",
  "EnrollmentRequestAtUtc": "2026-04-27T23:34:27.5485955Z",
  "CallbackAtUtc": "2026-04-27T23:34:59.523272Z",
  "NotificationAtUtc": "2026-04-27T23:37:56.8825745Z",
  "EnrollmentStatus": "AUTORIZADO",
  "ExternalId": null
}
```

El campo `Source` permite identificar qué sistema creó el expediente. Este valor se devuelve en el webhook para que puedas enrutar o procesar la información según su origen

***

## Validación de firma

Para garantizar que el webhook proviene de NUFI, debes validar la firma utilizando tu secret. Antes de usar el HMACSHA256 deberás hacer un slice para quitar la versión del valor del signature, por defecto viene como `v1=` y seguido del hash hmac

Generación de la firma:

```text theme={null}
signedPayload = timestamp + "." + rawBody
signature = HMACSHA256(secret, signedPayload)
```

Donde:

* `timestamp`: Header `x-nufi-webhook-timestamp`
* `rawBody`: Body sin modificar (raw)
* `secret`: Tu clave privada

### JavaScript (Node.js)

```javascript theme={null}
const crypto = require("crypto");

function verifyWebhookSignature({ rawBody, signature, timestamp, secret }) {
  const signedPayload = `${timestamp}.${rawBody}`;

  const expectedSignature = crypto
    .createHmac("sha256", secret)
    .update(signedPayload, "utf8")
    .digest("hex");

  // Remover el prefijo "v1=" si existe
  const cleanSignature = signature.startsWith("v1=")
    ? signature.slice(3)
    : signature;

  return crypto.timingSafeEqual(
    Buffer.from(cleanSignature, "hex"),
    Buffer.from(expectedSignature, "hex")
  );
}

// Express
app.post("/webhook", (req, res) => {
  const signature = req.headers["x-nufi-signature"];
  const timestamp = req.headers["x-nufi-webhook-timestamp"];
  const rawBody = req.rawBody; // IMPORTANTE: usar body crudo
  const secret = process.env.NUFI_WEBHOOK_SECRET;

  const isValid = verifyWebhookSignature({
    rawBody,
    signature,
    timestamp,
    secret
  });

  if (!isValid) {
    return res.status(401).send("Invalid signature");
  }

  const event = JSON.parse(rawBody);

  console.log("Evento recibido:", event);

  res.status(200).send("OK");
});
```

### Python (Flask)

```python theme={null}
import hmac
import hashlib
from flask import Flask, request, abort
from secrets import compare_digest

app = Flask(__name__)

def verify_webhook_signature(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool:
    signed_payload = f"{timestamp}.{raw_body.decode('utf-8')}"

    expected_signature = hmac.new(
        key=secret.encode("utf-8"),
        msg=signed_payload.encode("utf-8"),
        digestmod=hashlib.sha256
    ).hexdigest()

    # Remover el prefijo "v1=" si existe
    clean_signature = signature[3:] if signature.startswith("v1=") else signature

    return compare_digest(clean_signature, expected_signature)

@app.post("/webhook")
def webhook():
    signature = request.headers.get("x-nufi-signature", "")
    timestamp = request.headers.get("x-nufi-webhook-timestamp", "")
    # Importante: obtener el body crudo sin modificar
    raw_body = request.get_data(cache=False, as_text=False)
    secret = os.environ.get("NUFI_WEBHOOK_SECRET", "")

    if not verify_webhook_signature(raw_body, signature, timestamp, secret):
        abort(401, "Invalid signature")

    event = request.get_json(force=True, silent=False)
    print("Evento recibido:", event)
    return ("OK", 200)
```

### C# (.NET / ASP.NET Core)

```csharp theme={null}
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;

[ApiController]
public class WebhookController : ControllerBase
{
    private static bool VerifyWebhookSignature(string rawBody, string signature, string timestamp, string secret)
    {
        var signedPayload = $"{timestamp}.{rawBody}";

        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
        var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(signedPayload));
        var expectedSignature = Convert.ToHexString(hash).ToLowerInvariant();

        // Remover el prefijo "v1=" si existe
        var cleanSignature = signature.StartsWith("v1=")
            ? signature.Substring(3)
            : signature;

        return CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(cleanSignature),
            Encoding.UTF8.GetBytes(expectedSignature)
        );
    }

    [HttpPost]
    [Route("webhook")]
    public IActionResult Post()
    {
        var signature = Request.Headers["x-nufi-signature"].ToString();
        var timestamp = Request.Headers["x-nufi-webhook-timestamp"].ToString();
        using var reader = new StreamReader(Request.Body, Encoding.UTF8);
        var rawBody = reader.ReadToEnd();
        var secret = Environment.GetEnvironmentVariable("NUFI_WEBHOOK_SECRET") ?? "";

        if (!VerifyWebhookSignature(rawBody, signature, timestamp, secret))
        {
            return Unauthorized("Invalid signature");
        }

        // Parsear evento
        // var evt = JsonSerializer.Deserialize<JsonElement>(rawBody);
        Console.WriteLine($"Evento recibido: {rawBody}");
        return Ok("OK");
    }
}
```

### PHP (Laravel/Plain PHP)

```php theme={null}
<?php
function verify_webhook_signature(string $rawBody, string $signature, string $timestamp, string $secret): bool {
    $signedPayload = $timestamp . "." . $rawBody;

    $expected = hash_hmac('sha256', $signedPayload, $secret);

    // Remover el prefijo "v1=" si existe
    $cleanSignature = str_starts_with($signature, 'v1=')
        ? substr($signature, 3)
        : $signature;

    return hash_equals($cleanSignature, $expected);
}

// Plain PHP
$signature = $_SERVER['HTTP_X_NUFI_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_NUFI_WEBHOOK_TIMESTAMP'] ?? '';
$rawBody = file_get_contents('php://input'); // Body crudo
$secret = getenv('NUFI_WEBHOOK_SECRET') ?: '';

if (!verify_webhook_signature($rawBody, $signature, $timestamp, $secret)) {
    http_response_code(401);
    echo "Invalid signature";
    exit;
}

$event = json_decode($rawBody, true);
error_log("Evento recibido: " . print_r($event, true));
http_response_code(200);
echo "OK";
```
