Lectico

Por qué verificar

La URL de tu webhook es pública: cualquiera que la conozca puede enviarte un POST. Sin firma, un atacante podría inyectar leads o eventos falsos en tu sistema.

Lectico firma cada payload con HMAC-SHA256 usando el secret que recibiste al crear la suscripción. Antes de confiar en un webhook, debes verificar esa firma.

Cabecera

Cada POST llega con:

Lectico-Signature: t=1744550460,v1=a3f1...c2d8
  • t — timestamp Unix del momento del envío.
  • v1 — firma hex: HMAC_SHA256(secret, "<t>.<body_raw>").

Verificación en Node

Lo más cómodo es usar el SDK:

import express from "express";
import { constructEvent, WebhookSignatureError } from "@lectico/api/webhooks";

const app = express();

app.post(
  "/webhooks/lectico",
  express.raw({ type: "application/json" }),
  (req, res) => {
    try {
      const event = constructEvent(
        req.body,
        req.header("Lectico-Signature"),
        process.env.LECTICO_WEBHOOK_SECRET!,
      );

      switch (event.type) {
        case "lead.captured":
          // envia a tu CRM
          break;
        case "agent.created":
          // notifica al equipo
          break;
      }

      res.status(200).json({ received: true });
    } catch (err) {
      if (err instanceof WebhookSignatureError) {
        return res.status(400).send(`Invalid signature: ${err.message}`);
      }
      throw err;
    }
  },
);

constructEvent valida:

  1. Que la cabecera t=...,v1=... esté bien formada.
  2. Que el HMAC coincida con el body exacto (por eso se usa express.raw, no express.json).
  3. Que el timestamp esté dentro de una tolerancia de 5 minutos (configurable con toleranceSeconds). Esto bloquea ataques de replay.

Verificación manual (otros lenguajes)

Si no usas el SDK, el algoritmo es el mismo en cualquier lenguaje:

import hmac, hashlib, time

def verify(body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(kv.split("=", 1) for kv in header.split(","))
    t, v1 = int(parts["t"]), parts["v1"]
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(
        secret.encode(),
        f"{t}.{body.decode()}".encode(),
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, v1)

No uses == para comparar firmas. Es vulnerable a timing attacks. Usa hmac.compare_digest (Python), crypto.timingSafeEqual (Node) o equivalente.

Errores comunes

  • body parseado como JSON antes de verificar — el hash se calcula sobre el body crudo, no sobre el objeto. Si tu framework ya lo parseó, la firma nunca coincidirá.
  • Reconstruir el JSON — tampoco funciona: cualquier reordenación de claves o espacios cambia el hash.
  • Secret incorrecto — asegúrate de que la variable de entorno coincida con el secret que te dio POST /v1/webhooks.
  • Timestamp fuera de tolerancia — ocurre si tu servidor tiene el reloj desincronizado (usa NTP).