⌘I

Verificar firmas de webhook

Updated February 28, 2026
Also available in:

A continuación te explicamos cómo verificar las firmas de webhook de Formspree en el código de tu receptor.

El detalle clave es que Formspree no firma solo el cuerpo en bruto. Firma tanto la marca de tiempo como el cuerpo en bruto, en este formato:

{timestamp}.{raw_body}

La firma se envía en el encabezado Formspree-Signature usando este formato:

t=<unix>,v1=<hex>

Requisitos clave

  • Usa el cuerpo de la solicitud en bruto exactamente como se recibió.
  • Analiza el encabezado Formspree-Signature para extraer t y v1.
  • Calcula HMAC_SHA256(secret, f"{t}.{raw_body}") y compáralo con v1.
  • Opcional: rechaza solicitudes más antiguas que una ventana de tolerancia (protección contra replay).

Ejemplos de código

Aquí tienes algunos fragmentos de código para ayudarte a empezar.

Python (Flask)

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

app = Flask(__name__)

SIGNING_SECRET = "your_signing_secret"  # Set this to the secret shown in Formspree

def verify_formspree_signature(secret, signature_header, raw_body, tolerance=300):
    if not signature_header:
        return False

    try:
        # Header format: "t=<unix>,v1=<hex>"
        parts = dict(item.split("=", 1) for item in signature_header.split(","))
        timestamp = int(parts.get("t", "0"))
        received_sig = parts.get("v1")
    except Exception:
        return False

    if not received_sig:
        return False

    # Optional: reject old requests to prevent replay attacks
    if abs(int(time.time()) - timestamp) > tolerance:
        return False

    # Formspree signs: "{timestamp}.{raw_body}"
    signed_payload = f"{timestamp}.{raw_body}"

    # Compute HMAC-SHA256 with the shared secret
    expected_sig = hmac.new(
        key=secret.encode("utf-8"),
        msg=signed_payload.encode("utf-8"),
        digestmod=hashlib.sha256,
    ).hexdigest()

    # Constant-time compare to avoid timing attacks
    return hmac.compare_digest(expected_sig, received_sig)

@app.post("/webhook")
def webhook():
    # Important: read the raw request body as sent
    raw_body = request.get_data(as_text=True)
    signature = request.headers.get("Formspree-Signature", "")

    if not verify_formspree_signature(SIGNING_SECRET, signature, raw_body):
        abort(401)

    return ("ok", 200)

JavaScript (Node.js + Express)

const crypto = require("crypto");
const express = require("express");
const app = express();

const SIGNING_SECRET = "your_signing_secret"; // Set this to the secret shown in Formspree

// Capture the raw body before JSON parsing changes it
app.use(express.json({
  verify: (req, res, buf) => {
    req.rawBody = buf.toString("utf8");
  }
}));

function verifyFormspreeSignature(secret, signatureHeader, rawBody, tolerance = 300) {
  if (!signatureHeader) return false;

  // Header format: "t=<unix>,v1=<hex>"
  const parts = Object.fromEntries(
    signatureHeader.split(",").map(s => s.split("=", 2))
  );
  const timestamp = parseInt(parts.t || "0", 10);
  const receivedSig = parts.v1;

  if (!receivedSig) return false;

  // Optional: reject old requests to prevent replay attacks
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > tolerance) return false;

  // Formspree signs: "{timestamp}.{raw_body}"
  const signedPayload = `${timestamp}.${rawBody}`;

  // Compute HMAC-SHA256 with the shared secret
  const expectedSig = crypto
    .createHmac("sha256", secret)
    .update(signedPayload, "utf8")
    .digest("hex");

  // Constant-time compare to avoid timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(expectedSig, "utf8"),
    Buffer.from(receivedSig, "utf8")
  );
}

app.post("/webhook", (req, res) => {
  const signature = req.header("Formspree-Signature") || "";

  if (!verifyFormspreeSignature(SIGNING_SECRET, signature, req.rawBody)) {
    return res.status(401).send("invalid signature");
  }

  res.status(200).send("ok");
});

app.listen(3000);

Resolución de problemas y problemas comunes

Aquí tienes algunos errores comunes a tener en cuenta:

  • Re-serializar JSON antes de firmar: Verifica contra los bytes del cuerpo de la solicitud en bruto exactamente como se recibieron, ya que analizar y volver a serializar JSON cambiará la secuencia de bytes y romperá el HMAC.
  • Firmar solo el cuerpo (sin el prefijo t.): Como se mencionó arriba, Formspree firma la marca de tiempo y la carga útil juntas, así que debes calcular el HMAC sobre t.<raw_body> (incluyendo el delimitador t.) en lugar de solo el cuerpo.
  • Comparar contra toda la cadena del encabezado en lugar del valor v1: Extrae el valor de la firma v1 del encabezado de la firma y compara solo ese hex digest, no el contenido completo del encabezado separado por comas.
  • Usar un secret diferente al signing_secret configurado del webhook: Asegúrate de que el secret que usas para HMAC sea exactamente el secret de firma del webhook desde la configuración del webhook de Formspree, no tu API key u otro secret del proyecto.