Saltar al contenido
Pidgely
  • webhooks
  • seguridad
  • infraestructura

Webhooks de email: cómo verificar firmas y evitar duplicados

Cómo recibir webhooks de eventos de email de forma segura: verificación de firmas con Standard Webhooks, protección contra replay, reintentos e idempotencia.

Equipo PidgelyPublicado el 4 min de lecturaVersión en Markdown

En este artículo
  1. Qué eventos llegan por webhook
  2. Standard Webhooks en 30 segundos
  3. Verificación en Node.js
  4. Verificación en Go
  5. Protección contra replay
  6. Reintentos e idempotencia
  7. Eventos fuera de orden
  8. Rotación de secretos
  9. Errores comunes
  10. Checklist
  11. En Pidgely
  12. Preguntas frecuentes

Para recibir webhooks de eventos de email de forma segura debes verificar la firma HMAC de cada petición sobre el cuerpo crudo, rechazar timestamps fuera de una ventana corta para impedir ataques de replay, responder 2xx rápido y procesar en segundo plano, y deduplicar por ID de evento, porque los reintentos hacen que la entrega sea "al menos una vez". La especificación Standard Webhooks define exactamente qué headers usar, qué se firma y cómo, para que la verificación sea igual con cualquier proveedor que la adopte.

Qué eventos llegan por webhook

Una plataforma de email suele notificar eventos como:

  • email.sent / email.delivered: el servidor receptor aceptó el mensaje.
  • email.deferred: demora temporal (4xx), se reintentará.
  • email.bounced: rebote duro o fallo definitivo.
  • email.complained: el destinatario lo marcó como spam (vía feedback loop).
  • email.unsubscribed: baja, por ejemplo con un clic (RFC 8058).
  • email.opened / email.clicked: interacción, si el seguimiento está activo.

Varios de estos eventos tienen consecuencias: un bounced o un complained debería cambiar el estado de un usuario en tu base de datos. Por eso la seguridad del endpoint importa: un evento falso es una acción falsa.

Standard Webhooks en 30 segundos

Cada petición incluye tres headers:

HTTP
POST /webhooks/email HTTP/1.1
Content-Type: application/json
webhook-id: msg_2mS8f1Kq9ZbX
webhook-timestamp: 1791763200
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{"type":"email.bounced","data":{"email_id":"em_91f","recipient":"ana@example.es","status":"5.1.1"}}
  • webhook-id: identificador único del mensaje. Se mantiene igual en los reintentos.
  • webhook-timestamp: segundos Unix del momento del envío.
  • webhook-signature: una o más firmas separadas por espacios, cada una con prefijo de versión. v1 es HMAC-SHA256 en base64.

El contenido firmado es la concatenación:

text
{webhook-id}.{webhook-timestamp}.{cuerpo crudo}

El secreto se entrega con el prefijo whsec_ seguido de bytes en base64. La clave HMAC son esos bytes decodificados, no el string completo.

Verificación en Node.js

JavaScript
import crypto from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

export function verifyWebhook(rawBody, headers, secret) {
  const id = headers["webhook-id"];
  const ts = headers["webhook-timestamp"];
  const sigHeader = headers["webhook-signature"];
  if (!id || !ts || !sigHeader) throw new Error("faltan headers");

  const now = Math.floor(Date.now() / 1000);
  const tsNum = Number(ts);
  if (!Number.isInteger(tsNum) || Math.abs(now - tsNum) > TOLERANCE_SECONDS) {
    throw new Error("timestamp fuera de la ventana");
  }

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = crypto
    .createHmac("sha256", key)
    .update(`${id}.${ts}.${rawBody}`)
    .digest();

  const ok = sigHeader.split(" ").some((part) => {
    const [version, sig] = part.split(",");
    if (version !== "v1" || !sig) return false;
    const received = Buffer.from(sig, "base64");
    return (
      received.length === expected.length &&
      crypto.timingSafeEqual(received, expected)
    );
  });
  if (!ok) throw new Error("firma inválida");
}

Con Express, lee el cuerpo como Buffer sin parsear:

JavaScript
app.post("/webhooks/email", express.raw({ type: "application/json" }), (req, res) => {
  try {
    verifyWebhook(req.body.toString("utf8"), req.headers, process.env.WEBHOOK_SECRET);
  } catch {
    return res.status(401).end();
  }
  queue.enqueue(req.headers["webhook-id"], req.body);
  res.status(204).end();
});

Verificación en Go

Go
func Verify(body []byte, h http.Header, secret string) error {
	id, ts, sigs := h.Get("webhook-id"), h.Get("webhook-timestamp"), h.Get("webhook-signature")
	if id == "" || ts == "" || sigs == "" {
		return errors.New("faltan headers")
	}
	t, err := strconv.ParseInt(ts, 10, 64)
	if err != nil {
		return err
	}
	if d := time.Since(time.Unix(t, 0)); d > 5*time.Minute || d < -5*time.Minute {
		return errors.New("timestamp fuera de la ventana")
	}
	key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
	if err != nil {
		return err
	}
	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(id + "." + ts + "."))
	mac.Write(body)
	expected := mac.Sum(nil)
	for _, s := range strings.Fields(sigs) {
		v, sig, ok := strings.Cut(s, ",")
		if !ok || v != "v1" {
			continue
		}
		got, err := base64.StdEncoding.DecodeString(sig)
		if err == nil && hmac.Equal(got, expected) {
			return nil
		}
	}
	return errors.New("firma inválida")
}

Existen librerías oficiales de Standard Webhooks para varios lenguajes; usarlas evita errores sutiles.

Protección contra replay

Una firma válida demuestra que el mensaje lo generó quien tiene el secreto, pero no que sea reciente. Un atacante que intercepte una petición podría reenviarla. Por eso el timestamp forma parte del contenido firmado y se rechaza si está fuera de una ventana (cinco minutos es lo habitual). Mantén el reloj de tus servidores sincronizado con NTP para no rechazar eventos legítimos.

Para cerrar la ventana restante, la deduplicación por webhook-id también bloquea repeticiones dentro de esos minutos.

Reintentos e idempotencia

El emisor reintenta cuando tu endpoint responde con un error, tarda demasiado o no responde. Normalmente con backoff exponencial durante horas o días. Consecuencias:

  • Recibirás duplicados. Guarda el webhook-id en una tabla con restricción de unicidad y descarta los ya procesados.
  • Responde rápido. Verifica, encola y devuelve 2xx. Procesar en línea (consultas pesadas, llamadas a otras APIs) provoca timeouts y más reintentos.
  • Usa los códigos correctamente. 2xx = recibido. 4xx = petición rechazada (por ejemplo, firma inválida); reintentarla no cambiará el resultado, aunque muchos emisores reintentan igualmente. Regístralos, porque suelen indicar un secreto mal configurado. 5xx = error tuyo, se reintentará.
SQL
CREATE TABLE webhook_events (
  webhook_id   text PRIMARY KEY,
  received_at  timestamptz NOT NULL DEFAULT now(),
  payload      jsonb NOT NULL
);
-- INSERT ... ON CONFLICT (webhook_id) DO NOTHING

Eventos fuera de orden

Los reintentos y la entrega en paralelo hacen que un delivered pueda llegar después de un bounced del mismo mensaje, o un opened antes que un delivered. No asumas orden:

  • Guarda el timestamp del evento (en el payload), no el de recepción.
  • Modela el estado como una máquina de estados donde ciertos estados son terminales: si un correo ya está en bounced, un delivered tardío no lo revierte.
  • Para supresiones, aplica la acción de forma idempotente: suprimir dos veces es inocuo.

Rotación de secretos

Standard Webhooks admite varias firmas en el mismo header. Durante una rotación, el emisor firma con el secreto antiguo y el nuevo a la vez; tu verificador acepta cualquiera de los dos. Cuando todos tus servicios usan el nuevo, retiras el antiguo. Por eso el código de ejemplo recorre todas las firmas.

Errores comunes

  • Verificar sobre el JSON re-serializado en lugar del cuerpo crudo.
  • Comparar firmas con ==, vulnerable a ataques de temporización. Usa comparación en tiempo constante.
  • Usar el string whsec_... completo como clave.
  • No validar el timestamp, dejando la puerta abierta al replay.
  • Procesar en línea y responder tarde.
  • Confiar en el orden de llegada.
  • Tratar las aperturas como señal fiable: la precarga de imágenes por funciones de privacidad de algunos clientes infla esa métrica.

Checklist

  • [ ] Cuerpo crudo disponible antes de cualquier parseo.
  • [ ] Verificación HMAC-SHA256 con comparación en tiempo constante.
  • [ ] Ventana de tolerancia del timestamp y NTP activo.
  • [ ] Deduplicación por webhook-id.
  • [ ] Respuesta 2xx inmediata y procesamiento en cola.
  • [ ] Estados terminales para eventos fuera de orden.
  • [ ] Soporte de múltiples firmas para rotar secretos.

En Pidgely

Los webhooks de Pidgely siguen la especificación Standard Webhooks, así que puedes verificarlos con las librerías oficiales en tu lenguaje. Cada rebote, queja o baja llega como un evento firmado con ID estable para deduplicar.

Preguntas frecuentes

¿Por qué tengo que verificar la firma de un webhook?

Porque tu endpoint es público. Sin verificación, cualquiera podría enviarte eventos falsos, por ejemplo rebotes inventados que supriman a usuarios válidos.

¿Qué es Standard Webhooks?

Una especificación abierta que define headers (webhook-id, webhook-timestamp, webhook-signature), el contenido firmado y el formato de la firma, para que todos los proveedores se verifiquen igual.

¿Cómo evito procesar un evento dos veces?

Guarda el webhook-id de cada evento procesado y descarta los repetidos. Los reintentos son normales y la entrega es al menos una vez, nunca exactamente una vez.

¿Puedo parsear el JSON antes de verificar la firma?

No. Debes verificar sobre el cuerpo crudo, byte a byte. Volver a serializar el JSON cambia espacios u orden de claves y la firma no coincidirá.

Equipo Pidgely

Publicado el

Construimos Pidgely, la API de email que te lleva a la bandeja de entrada. Escribimos sobre lo que aprendemos entregando emails, citando los estándares y las pautas de los proveedores detrás de cada afirmación.

También disponible en:
entregabilidadip

IP compartida vs dedicada para email: cómo elegir

IP compartida o dedicada para enviar email: reputación, volumen necesario, warm-up, control y riesgos, con criterios claros para elegir la opción adecuada.

5 min de lectura