Email transaccional vs marketing: por qué separar los streams
Por qué separar el email transaccional del de marketing en streams, IPs y subdominios distintos, cómo clasificar cada mensaje y errores habituales al hacerlo.
5 min de lectura
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

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.
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.
Cada petición incluye tres headers:
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:
{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.
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:
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();
});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.
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.
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:
webhook-id en una tabla con restricción de unicidad y descarta los ya procesados.2xx. Procesar en línea (consultas pesadas, llamadas a otras APIs) provoca timeouts y más reintentos.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á.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 NOTHINGLos 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:
bounced, un delivered tardío no lo revierte.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.
==, vulnerable a ataques de temporización. Usa comparación en tiempo constante.whsec_... completo como clave.webhook-id.2xx inmediata y procesamiento en cola.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.
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.
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.
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.
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.
Por qué separar el email transaccional del de marketing en streams, IPs y subdominios distintos, cómo clasificar cada mensaje y errores habituales al hacerlo.
5 min de lectura
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
Comparativa entre operar tu propio MTA y usar una API de email: costos reales, control de la entrega, riesgos de vendor lock-in y cómo mantener la portabilidad.
5 min de lectura