Leypal API Docs

Verificación de Firma de Webhook

Cómo verificar firmas HMAC-SHA256 de webhooks usando comparación segura en tiempo para proteger tu punto final de eventos falsificados.

Verificación de Firma de Webhook

Cada webhook que Leypal envía está firmado criptográficamente. Verificar esa firma no es opcional — es la única forma de probar que un webhook provino de Leypal y no fue modificado en tránsito.

Esta guía explica el algoritmo de firma, muestra cómo implementar la verificación en Node.js/Express y cubre cómo probar tu punto final localmente.


¿Por Qué Verificar Webhooks?

Cuando expones un punto final público para webhooks, cualquier atacante en internet puede enviar una solicitud POST a esa URL. Sin verificación, tu aplicación procesaría alegremente esos eventos falsificados.

Escenario de ataque: Un atacante descubre tu punto final de webhook en https://tudominio.com/webhooks. Envía un payload falso: { "event": "signature.completed", "data": { "signatureId": "sig_abc" } }. Tu aplicación ve un evento de firma completada y automáticamente envía el documento firmado a la parte equivocada o marca un pago como completo — sin que Leypal haya completado ninguna firma.

Cómo Leypal previene esto:

  • Leypal genera un secreto de webhook único (sk_webhook_xxx) cuando registras un punto final de webhook
  • Para cada webhook enviado, Leypal calcula HMAC-SHA256(secreto_de_webhook, cuerpo_de_solicitud_sin_procesar) e incluye el resultado en el encabezado X-Leypal-Signature
  • Tu punto final calcula el mismo HMAC usando el secreto compartido y lo compara con el valor del encabezado
  • Si coinciden, el webhook es auténtico — provino de Leypal y no fue modificado en tránsito
  • Si no coinciden, rechaza la solicitud inmediatamente con 401 Unauthorized

Regla: Siempre verifica la firma antes de ejecutar cualquier lógica de negocio. Registra y descarta las solicitudes que fallen la verificación.


HMAC-SHA256 Explicado

HMAC (Hash-based Message Authentication Code) es un algoritmo estándar que combina una clave secreta con un mensaje para producir una firma de longitud fija.

Cómo funciona:

  1. Leypal toma el cuerpo de la solicitud HTTP sin procesar — los bytes exactos que llegan a tu punto final
  2. Leypal aplica HMAC-SHA256(secreto_de_webhook, cuerpo) — esto produce una cadena hexadecimal de 64 caracteres
  3. Leypal incluye esa cadena hexadecimal en el encabezado de solicitud X-Leypal-Signature
  4. Tu código realiza el mismo cálculo con el mismo secreto y compara la salida

¿Por qué SHA-256? Es un algoritmo de hash estándar de la industria — rápido, criptográficamente sólido y universalmente disponible en todos los lenguajes de programación. La construcción HMAC añade una clave secreta para que sólo las partes que poseen el secreto puedan reproducir la firma. Sin la clave, un atacante no puede calcular la firma correcta incluso si conoce el cuerpo.

Cómo se ve tu secreto de webhook:

# Se muestra una vez en el Panel de control cuando registras un punto final de webhook
sk_webhook_abc123def456...

Guárdalo inmediatamente — Leypal no lo mostrará de nuevo. Si lo pierdes, rota el secreto desde el Panel de control (esto invalida el secreto anterior y genera uno nuevo).

Flujo de firma (pseudocódigo):

# Lo que hace Leypal (del lado del servidor, antes de enviar)
signature = HMAC-SHA256(webhook_secret, raw_body)
send HTTP POST with header: X-Leypal-Signature: {signature}

# Lo que hace tu punto final (al recibir)
expected  = HMAC-SHA256(webhook_secret, req.rawBody)
received  = req.headers["X-Leypal-Signature"]
valid     = timingSafeEqual(expected, received)

Comparación Segura en Tiempo

La comparación segura en tiempo no es negociable. Omitir este paso crea una vulnerabilidad de seguridad real — incluso si tu lógica HMAC es correcta en todos los demás aspectos.

El problema con la comparación de cadenas ingenua:

El operador === de JavaScript hace un cortocircuito — devuelve false tan pronto como encuentra el primer byte que difiere. Esto significa que comparar una firma que empieza con "a" contra la suposición de un atacante "b..." falla en nanosegundos, mientras que comparar "a..." contra "a..." (prefijo correcto, sufijo incorrecto) tarda ligeramente más.

Ataque de temporización: Un atacante envía miles de solicitudes falsificadas con diferentes firmas, mide el tiempo de respuesta de cada una y explota la diferencia temporal para reconstruir tu firma esperada un byte a la vez. Este ataque está documentado en sistemas de producción (Stripe, AWS) y es práctico contra implementaciones ingenuas.

Ejemplo del ataque:

El atacante envía: X-Leypal-Signature: "aaaa..."  → tiempo de respuesta: 1.1ms  (falla en byte 1)
El atacante envía: X-Leypal-Signature: "baaa..."  → tiempo de respuesta: 1.1ms  (falla en byte 1)
El atacante envía: X-Leypal-Signature: "caaa..."  → tiempo de respuesta: 1.2ms  (¡byte 1 correcto! falla en byte 2)
# El atacante ahora sabe que el primer byte es "c" — repite para cada byte

La solución: crypto.timingSafeEqual() compara todos los bytes, cada vez, y siempre toma la misma cantidad de tiempo independientemente de dónde esté la discrepancia. No hay filtración de información.

// INCORRECTO — no uses esto
if (expectedSignature === headerSignature) { /* ... */ }

// CORRECTO — siempre usa timingSafeEqual
const isValid = crypto.timingSafeEqual(
  Buffer.from(expectedSignature, 'hex'),
  Buffer.from(headerSignature, 'hex'),
);

Nota: timingSafeEqual requiere que ambos buffers tengan la misma longitud. Si difieren, envuelve la llamada en un try/catch — una discrepancia de longitud es en sí misma prueba de una solicitud falsificada y debe tratarse como isValid = false.


Requisito del Cuerpo Sin Procesar

El orden importa: La captura del cuerpo sin procesar debe ocurrir antes del análisis JSON. Invertir este orden rompe la verificación de firma silenciosamente — tu HMAC nunca coincidirá.

El problema: Leypal calcula la firma HMAC sobre los bytes sin procesar del cuerpo de la solicitud — la secuencia exacta de bytes que llegaron a través de la red. Si tu framework web analiza el cuerpo JSON primero y luego intentas re-serializarlo para calcular el HMAC, obtendrás una secuencia de bytes diferente:

Leypal envía:      {"event":"signature.completed","data":{"id":"sig_abc"}}
Tú serializas:     {"event":"signature.completed","data":{"id":"sig_abc"}}  // Podría coincidir
# Pero los frameworks pueden reordenar claves o alterar espacios en blanco durante el análisis → serialización:
Tú re-serializas:  {"data":{"id":"sig_abc"},"event":"signature.completed"}  // No coincide

Incluso un espacio extra o una clave reordenada produce un HMAC completamente diferente. Este es un problema fundamentalmente irresoluble si analizas primero — debes capturar los bytes sin procesar antes de que tu framework toque el cuerpo.

Solución con Express: Usa el middleware express.raw() en tu ruta de webhook, antes de cualquier análisis JSON:

app.post(
  '/webhooks',
  express.raw({ type: 'application/json' }),  // DEBE ir primero — captura el Buffer sin procesar
  verifyWebhookSignature,                      // Lee req.body como Buffer para HMAC
  handleWebhook,                               // Manejador — el cuerpo es JSON analizado después del middleware
);

Con express.raw(), req.body es un Buffer que contiene los bytes sin procesar. Tu middleware de verificación puede calcular el HMAC directamente desde ese Buffer, luego analizar el JSON por sí mismo antes de llamar a next().


Implementación en Node.js / Express

Implementación completa lista para copiar y pegar. Agrega esto a tu servidor Express:

import express, { Request, Response, NextFunction } from 'express';
import crypto from 'crypto';

const app = express();

// ─── Ruta: Punto Final de Webhook ─────────────────────────────────────────────
//
// El orden del middleware es crítico:
//   1. express.raw()           — captura el cuerpo sin procesar como Buffer (antes de cualquier análisis JSON)
//   2. verifyWebhookSignature  — valida X-Leypal-Signature contra HMAC
//   3. handleWebhook           — procesa el evento (el cuerpo es ahora JSON analizado)
//
app.post(
  '/webhooks',
  express.raw({ type: 'application/json' }),
  verifyWebhookSignature,
  handleWebhook,
);

// ─── Middleware: Verificar Firma ──────────────────────────────────────────────
function verifyWebhookSignature(
  req: Request,
  res: Response,
  next: NextFunction,
): void {
  const signatureHeader = req.headers['x-leypal-signature'] as string | undefined;
  const webhookSecret   = process.env.LEYPAL_WEBHOOK_SECRET;

  // 1. Asegurarse de que tanto el encabezado como el secreto estén presentes
  if (!signatureHeader) {
    res.status(401).json({
      error: 'Unauthorized',
      details: 'Missing X-Leypal-Signature header',
    });
    return;
  }

  if (!webhookSecret) {
    // Registrar del lado del servidor — este es un error de configuración, no un atacante
    console.error('[webhook] LEYPAL_WEBHOOK_SECRET is not set in environment');
    res.status(500).json({ error: 'Server configuration error' });
    return;
  }

  // 2. Calcular la firma esperada desde el cuerpo sin procesar
  //    req.body es un Buffer porque express.raw() se aplica antes de este middleware
  const expectedSignature = crypto
    .createHmac('sha256', webhookSecret)
    .update(req.body as Buffer)  // Bytes sin procesar — no una cadena, no JSON analizado
    .digest('hex');               // Cadena hexadecimal en minúsculas de 64 caracteres

  // 3. Comparación segura en tiempo — NUNCA uses === aquí
  let isValid = false;
  try {
    isValid = crypto.timingSafeEqual(
      Buffer.from(expectedSignature, 'hex'),
      Buffer.from(signatureHeader,   'hex'),
    );
  } catch {
    // timingSafeEqual lanza una excepción si los buffers tienen longitudes diferentes
    // Esto ocurre cuando el atacante envía una firma de longitud incorrecta
    isValid = false;
  }

  if (!isValid) {
    res.status(401).json({ error: 'Invalid webhook signature' });
    return;
  }

  // 4. La firma es válida — analiza el cuerpo y pasa al manejador
  //    En este punto req.body aún es el Buffer sin procesar; analízalo aquí
  try {
    req.body = JSON.parse((req.body as Buffer).toString('utf-8'));
  } catch {
    res.status(400).json({ error: 'Invalid JSON body' });
    return;
  }

  next();
}

// ─── Manejador: Procesar Evento de Webhook ────────────────────────────────────
async function handleWebhook(req: Request, res: Response): Promise<void> {
  const { event, data, webhookId, organizationId } = req.body;

  console.log(`[${webhookId}] Received event "${event}" for org ${organizationId}`);

  try {
    // Enrutar al manejador apropiado según el tipo de evento
    switch (event) {
      case 'signature.created':
        await handleSignatureCreated(data);
        break;

      case 'signature.completed':
        await handleSignatureCompleted(data);
        break;

      case 'signature.cancelled':
        await handleSignatureCancelled(data);
        break;

      case 'identity_verification.ml_pipeline_completed':
        await handleVerificationCompleted(data);
        break;

      default:
        // Registrar eventos desconocidos — útil para depurar nuevos tipos de eventos
        console.warn(`[${webhookId}] Unhandled event type: ${event}`);
    }

    // Siempre devuelve 2xx rápidamente — esto confirma la recepción y evita reintentos
    // NO esperes a que termine la lógica de negocio antes de responder
    res.status(200).json({ received: true, webhookId });
  } catch (error) {
    // Una respuesta 5xx le indica a Leypal que reintente la entrega del webhook
    console.error(`[${webhookId}] Error processing event "${event}":`, error);
    res.status(500).json({ error: 'Internal server error' });
  }
}

// ─── Manejadores de Eventos ───────────────────────────────────────────────────
async function handleSignatureCreated(data: { signatureId: string }): Promise<void> {
  console.log(`Signature created: ${data.signatureId}`);
  // Tu lógica de negocio: notificar al equipo, actualizar base de datos, enviar correo, etc.
}

async function handleSignatureCompleted(data: { signatureId: string; completedAt: string }): Promise<void> {
  console.log(`Signature completed: ${data.signatureId} at ${data.completedAt}`);
  // Tu lógica de negocio: activar entrega de documentos, actualizar estado de registro, etc.
}

async function handleSignatureCancelled(data: { signatureId: string }): Promise<void> {
  console.log(`Signature cancelled: ${data.signatureId}`);
  // Tu lógica de negocio: notificar a firmantes, marcar registro como cancelado, etc.
}

async function handleVerificationCompleted(data: { verificationId: string; approved: boolean; approvedAt?: string }): Promise<void> {
  console.log(`Identity verification ${data.approved ? 'approved' : 'rejected'}: ${data.verificationId}`);
  // Tu lógica de negocio: desbloquear funciones, completar flujo KYC, etc.
}

app.listen(3000, () => console.log('Webhook server listening on port 3000'));

Configuración del entorno:

# .env  (nunca confirmes este archivo — agrégalo a .gitignore)
LEYPAL_WEBHOOK_SECRET=sk_webhook_your_actual_secret_here

Agrega .env a tu .gitignore:

echo ".env" >> .gitignore

Probando Tu Implementación

Opción 1: Evento de Prueba desde el Panel de Control de Leypal

La forma más fácil de probar. Desde el Panel de control de Leypal:

  1. Ve a Configuración → Webhooks
  2. Haz clic en tu punto final de webhook registrado
  3. Haz clic en Enviar evento de prueba
  4. Elige un tipo de evento (p. ej., signature.created)
  5. Revisa los registros de tu servidor para ver Received event "signature.created"

Esto envía un payload firmado real a tu punto final — si tu verificación pasa, sabes que está funcionando correctamente.

Opción 2: ngrok (Desarrollo Local)

Para probar localmente antes de desplegar:

# Instala ngrok (https://ngrok.com)
npm install -g ngrok

# Inicia tu servidor de webhook
node server.js   # o: ts-node server.ts

# En una terminal separada, expón tu puerto local
ngrok http 3000

# ngrok te da una URL pública como:
# https://abc123.ngrok.io

# Registra esa URL en el Panel de control de Leypal → Configuración → Webhooks
# https://abc123.ngrok.io/webhooks

Opción 3: Prueba Manual con curl

Si quieres probar la generación y verificación de firma directamente sin el Panel de control:

# Establece tu secreto de webhook
WEBHOOK_SECRET="sk_webhook_your_actual_secret_here"

# Crea un payload de muestra
BODY='{"event":"signature.created","webhookId":"wh_test123","organizationId":"org_abc","data":{"signatureId":"sig_xyz","createdAt":"2026-03-30T10:00:00Z"}}'

# Genera la firma HMAC-SHA256
SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}')

echo "Signature: $SIGNATURE"

# Envía la solicitud firmada a tu servidor local
curl -X POST http://localhost:3000/webhooks \
  -H "Content-Type: application/json" \
  -H "X-Leypal-Signature: $SIGNATURE" \
  -d "$BODY"

Respuesta esperada:

{ "received": true, "webhookId": "wh_test123" }

Prueba con una firma inválida:

curl -X POST http://localhost:3000/webhooks \
  -H "Content-Type: application/json" \
  -H "X-Leypal-Signature: 0000000000000000000000000000000000000000000000000000000000000000" \
  -d "$BODY"

Respuesta esperada:

{ "error": "Invalid webhook signature" }

Lista de Verificación de Validación

  • El punto final devuelve 200 para una firma válida
  • El punto final devuelve 401 para una firma inválida
  • El punto final devuelve 401 cuando falta el encabezado X-Leypal-Signature
  • El punto final devuelve 500 cuando falta la variable de entorno LEYPAL_WEBHOOK_SECRET (solo registro del lado del servidor)
  • Los registros muestran [{webhookId}] Received event "{event}" para cada webhook
  • La lógica de negocio se ejecuta (verifica en tu base de datos o sistema de destino)
  • El punto final responde en menos de 2 segundos (procesa trabajo pesado de forma asíncrona)

Depuración y Errores Comunes

"Invalid webhook signature" pero el secreto parece correcto

  • Confirma que estás firmando el Buffer sin procesar (req.body as Buffer), no una cadena o JSON re-serializado
  • Confirma que express.raw({ type: 'application/json' }) se aplica antes del middleware de verificación en la definición de la ruta
  • Copia el secreto de webhook desde el Panel de control de nuevo — verifica si hay espacios en blanco iniciales/finales o caracteres de nueva línea
  • Confirma que el secreto en tu .env coincide exactamente con el mostrado en el Panel de control

"Missing X-Leypal-Signature header"

  • Confirma que la URL del punto final de webhook está registrada en Panel de control → Configuración → Webhooks
  • Confirma que la Clave API usada para registrar el punto final tiene permisos de webhook (webhooks:write)
  • Si estás probando localmente, confirma que tu túnel ngrok está activo y que la URL está actualizada en el Panel de control

"Endpoint not reachable" (Leypal no puede entregar)

  • Usa ngrok o localtunnel para desarrollo local — localhost no es accesible desde los servidores de Leypal
  • Despliega en un servidor de staging antes de registrar una URL de webhook de producción
  • Verifica las reglas de firewall y la configuración del grupo de seguridad en la nube (el puerto 443/HTTPS debe estar abierto para tráfico entrante)
  • Confirma que tu dominio tiene un certificado TLS válido — Leypal solo entrega a puntos finales HTTPS

El webhook se entregó pero el cuerpo es incorrecto

  • Si req.body es undefined en tu manejador, verifica que estás llamando a JSON.parse(req.body.toString('utf-8')) en el middleware de verificación después de que pase la verificación HMAC
  • Si req.body es el Buffer sin procesar en tu manejador, puede que hayas eliminado accidentalmente el paso JSON.parse en el middleware

Para una lista completa de errores de entrega de webhook, lógica de reintentos y códigos de estado, consulta Errores y Solución de Problemas de Webhook.


Lista de Verificación de Seguridad

Antes de pasar a producción, confirma todos estos puntos:

  • Usando crypto.timingSafeEqual() para la comparación de firmas — no ===
  • Secreto de webhook almacenado en una variable de entorno — no codificado directamente en el código fuente
  • Archivo .env agregado a .gitignore — nunca confirmado en control de versiones
  • Punto final servido solo sobre HTTPS — sin HTTP en producción
  • La URL del webhook es un nombre de host accesible públicamente — no localhost ni una IP privada
  • Usando express.raw({ type: 'application/json' }) antes del análisis JSON en la ruta de webhook
  • Devolviendo 200 antes de ejecutar lógica de negocio pesada (usa una cola asíncrona/trabajo en segundo plano si es necesario)
  • Registrando webhookId en cada mensaje de registro para rastreo y depuración
  • Las firmas inválidas se registran con la dirección IP para monitoreo de seguridad

Variantes de Lenguaje (Referencia)

El algoritmo principal es el mismo en todos los lenguajes. Aquí hay implementaciones equivalentes si tu backend no es Node.js:

import hmac
import hashlib
from flask import Flask, request, abort

app = Flask(__name__)

@app.route('/webhooks', methods=['POST'])
def webhook():
    signature_header = request.headers.get('X-Leypal-Signature', '')
    webhook_secret   = os.environ['LEYPAL_WEBHOOK_SECRET'].encode('utf-8')
    raw_body         = request.get_data()  # Bytes sin procesar — antes de cualquier análisis

    # Calcular firma esperada
    expected = hmac.new(webhook_secret, raw_body, hashlib.sha256).hexdigest()

    # Comparación segura en tiempo — usa hmac.compare_digest(), no ==
    if not hmac.compare_digest(expected, signature_header):
        abort(401)

    # Analizar y procesar
    payload = request.get_json(force=True)
    print(f"[{payload['webhookId']}] Received {payload['event']}")
    return {'received': True}, 200
package main

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

func webhookHandler(w http.ResponseWriter, r *http.Request) {
  signatureHeader := r.Header.Get("X-Leypal-Signature")
  webhookSecret   := os.Getenv("LEYPAL_WEBHOOK_SECRET")

  // Leer el cuerpo sin procesar antes de cualquier análisis
  rawBody, err := io.ReadAll(r.Body)
  if err != nil {
    http.Error(w, "Cannot read body", http.StatusBadRequest)
    return
  }

  // Calcular firma esperada
  mac := hmac.New(sha256.New, []byte(webhookSecret))
  mac.Write(rawBody)
  expectedHex := hex.EncodeToString(mac.Sum(nil))

  // Decodificar la firma recibida para comparación a nivel de bytes
  receivedBytes, err := hex.DecodeString(signatureHeader)
  expectedBytes, _   := hex.DecodeString(expectedHex)

  // Comparación segura en tiempo — usa hmac.Equal(), no bytes.Equal()
  if err != nil || !hmac.Equal(expectedBytes, receivedBytes) {
    http.Error(w, "Invalid signature", http.StatusUnauthorized)
    return
  }

  fmt.Fprintf(w, `{"received":true}`)
}

Próximos Pasos

Tu punto final de webhook ahora es seguro. Aquí está a dónde ir a continuación:

On this page