Venflow API

Verificar la firma

Verificar la firma

Cada entrega firmada incluye el header X-Webhook-Signature. Verificarlo te confirma dos cosas:

  1. El evento fue emitido por Venflow con el secret de ese webhook.
  2. El cuerpo de la solicitud llegó tal cual se envió.

La firma se está habilitando organización por organización (despliegue progresivo). Hasta que termine el despliegue, tu endpoint puede recibir entregas con el header y otras sin él. Escribe la verificación a la defensiva: si el header viene, valídalo y rechaza la solicitud cuando no coincida; si no viene, procesa el evento como hasta ahora. No rechaces todavía las solicitudes sin firma, o dejarás de procesar eventos legítimos.

Formato del header

X-Webhook-Signature: t=1789415801,v1=b03900efcfc5b467a9192c1e448a82bd77187fb70cda36cefffc810d7564170c
ComponenteSignificado
tMomento del envío, en segundos Unix (no milisegundos)
v1HMAC-SHA256 en hexadecimal, calculado sobre la cadena <t>.<cuerpo crudo> usando el secret del webhook como llave

La cadena que se firma es literalmente el valor de t, un punto, y el cuerpo de la solicitud sin modificar:

1789415801.{"eventType":"PAYMENT_SUCCESS_EVENT","client":{...}}

El prefijo v1 identifica la versión del esquema de firma. Por eso conviene leer el header por nombre de componente (buscar v1=) en lugar de asumir posiciones fijas: así una versión futura no rompe tu código.

El cuerpo debe ser el crudo

Este es el error más común. La firma se calcula sobre los bytes exactos que Venflow envió. Si tu framework interpreta el JSON y tú vuelves a serializarlo con JSON.stringify, el resultado casi nunca es idéntico (cambia el orden de claves, los espacios o el escapado de acentos) y la verificación falla aunque el evento sea legítimo.

Guarda el cuerpo crudo antes de interpretarlo:

Express: conservar el cuerpo crudo
import express from 'express';

const app = express();

app.use(
  '/webhooks/venflow',
  express.json({
    verify: (req, _res, buf) => {
      // Guarda los bytes originales para poder verificar la firma.
      (req as express.Request & { rawBody: string }).rawBody = buf.toString('utf8');
    },
  }),
);

En otros entornos el equivalente es: await request.text() en Next.js Route Handlers o Hono, request.body sin parsear en Fastify con rawBody, request.get_data() en Flask.

Función de verificación

verificarFirmaVenflow.ts
import { createHmac, timingSafeEqual } from 'node:crypto';

/** Ventana de tolerancia para el timestamp: 5 minutos. */
const TOLERANCIA_SEGUNDOS = 5 * 60;

/**
 * Verifica el header X-Webhook-Signature de una entrega de Venflow.
 *
 * @param firmaHeader Valor del header `X-Webhook-Signature` (undefined si no vino).
 * @param rawBody     Cuerpo CRUDO de la solicitud, tal como llegó.
 * @param secreto     El `secret` configurado en ese webhook.
 */
export function verificarFirmaVenflow(
  firmaHeader: string | undefined,
  rawBody: string,
  secreto: string,
  toleranciaSegundos: number = TOLERANCIA_SEGUNDOS,
): boolean {
  if (!firmaHeader) return false;

  // "t=1789415801,v1=b039..." -> { t: "1789415801", v1: "b039..." }
  const partes = new Map<string, string>();
  for (const parte of firmaHeader.split(',')) {
    const separador = parte.indexOf('=');
    if (separador === -1) continue;
    partes.set(parte.slice(0, separador).trim(), parte.slice(separador + 1).trim());
  }

  const t = partes.get('t');
  const v1 = partes.get('v1');
  if (!t || !v1) return false;

  const timestamp = Number(t);
  if (!Number.isFinite(timestamp)) return false;

  // Rechaza reenvíos de una solicitud capturada hace rato.
  const ahora = Math.floor(Date.now() / 1000);
  if (Math.abs(ahora - timestamp) > toleranciaSegundos) return false;

  const esperado = createHmac('sha256', secreto)
    .update(`${t}.${rawBody}`, 'utf8')
    .digest('hex');

  // Comparación en tiempo constante: evita filtrar información por el tiempo de respuesta.
  const a = Buffer.from(esperado, 'hex');
  const b = Buffer.from(v1, 'hex');
  if (a.length !== b.length) return false;

  return timingSafeEqual(a, b);
}

Usarla en tu endpoint

Handler completo (Express)
import type { Request, Response } from 'express';
import { verificarFirmaVenflow } from './verificarFirmaVenflow';

export function handleVenflowWebhook(req: Request, res: Response) {
  const firma = req.header('X-Webhook-Signature');
  const rawBody = (req as Request & { rawBody: string }).rawBody;

  // Durante el despliegue progresivo la firma puede no venir todavía.
  // Cuando viene, tiene que ser válida.
  if (firma && !verificarFirmaVenflow(firma, rawBody, process.env.VENFLOW_WEBHOOK_SECRET!)) {
    return res.status(401).json({ error: 'Firma inválida' });
  }

  const evento = JSON.parse(rawBody);

  // Responde rápido (hay 5 segundos de límite) y procesa aparte si hace falta.
  res.status(200).json({ received: true });

  encolarParaProcesar(evento);
}

Por qué la tolerancia de tiempo

Sin revisar t, alguien que haya capturado una solicitud válida podría reenviarla mañana: la firma seguiría cuadrando, porque el cuerpo no cambió. Al firmar el timestamp junto con el cuerpo, ese reenvío solo pasa si el atacante conoce el secreto, cosa que no puede lograr cambiando el valor de t en el header.

Cinco minutos es una recomendación cómoda: absorbe el desfase normal de reloj entre servidores sin dejar una ventana amplia. Si tus servidores tienen NTP y el desfase es mínimo, puedes bajarla; si ves rechazos por timestamp, revisa primero la hora de tu servidor antes de subirla.

Lista de verificación

  • Guardas el cuerpo crudo antes de interpretar el JSON
  • Calculas el HMAC sobre <t>.<cuerpo crudo>, no solo sobre el cuerpo
  • Comparas con timingSafeEqual, no con ===
  • Rechazas timestamps fuera de la ventana de tolerancia
  • Verificas cuando el header está presente, sin romper cuando todavía no llega
  • El secreto vive en una variable de entorno, nunca en el código

Probarlo

Desde el panel puedes disparar un evento de prueba contra tu endpoint y revisar el resultado, incluidos los intentos fallidos. Ver Probar Webhooks.