# 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.

Source: https://pidgely.com/es/blog/webhooks-email-verificar-firmas/ · Published: 2026-10-12 · Pidgely

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

```js
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:

```js
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á.

