Webhooks
Para desarrolladores
Un webhook entrega los eventos de los correos de una instancia a un endpoint HTTPS de su sistema, a medida que ocurren: entrega, rebote, descarte, apertura, clic, baja, re-suscripción y queja. Evita consultar la API periódicamente para detectar un cambio.
Cómo funciona
- Cada webhook pertenece a una instancia y tiene un nombre, una URL, la lista de tipos de evento que recibe (o todos) y un estado activo o pausado. Se puede pausar manualmente, y la plataforma lo pausa automáticamente si su endpoint falla de forma persistente (ver Pausa automática).
- Al crear un webhook se entrega su secreto de firma (
whsec_…) una sola vez: no se puede recuperar después. Si lo pierde o necesita cambiarlo, puede rotarlo. - La cantidad de webhooks por instancia depende del plan.
- Se gestionan con la API pública: listar, ver, crear, editar, eliminar, rotar el secreto y enviar un evento de prueba. La API key necesita el scope
webhooks:readpara consultar ywebhooks:writepara el resto. - Un evento llega normalmente a los pocos segundos de ocurrir. Cada entrega es un
POSTque agrupa uno o más eventos de un mismo tipo.
Eventos
| Tipo | Ocurre cuando |
|---|---|
mail.sent | El servidor del destinatario aceptó el correo. |
mail.bounced | El correo rebotó, al entregarlo o después, por un aviso de rebote del servidor del destinatario. |
mail.dropped | La plataforma descartó el correo sin entregarlo. |
mail.opened | El destinatario abrió el correo. |
mail.clicked | El destinatario hizo clic en un enlace del correo. |
mail.unsubscribed | El destinatario se dio de baja. |
mail.resubscribed | El destinatario revirtió su baja. |
mail.complained | El destinatario reportó el correo como abuso. |
webhook.test | Alguien probó el webhook desde el panel o por la API. Es el único tipo que no corresponde a un correo: llega con data vacío, no se reintenta y no altera el estado de entregas (ver Evento de prueba). |
Aperturas y clics siguen las mismas reglas que los reportes: varias aperturas (o clics) del mismo correo dentro del mismo segundo cuentan como una, y no se notifican los de correos más antiguos que el período de retención de reportes.
Formato de la entrega
| Header | Valor |
|---|---|
content-type | application/json |
user-agent | Fidelizador-Webhooks/1 |
webhook-id | Identificador de la entrega (msg_…). Es el mismo en todos sus reintentos. |
webhook-timestamp | Momento del intento, en segundos Unix. |
webhook-signature | Firma del intento: v1,<base64> (ver Verificar la firma). |
{
"events": [
{
"id": "evt_0db74ec52fb01a5a5a7b4413e5837253",
"type": "mail.sent",
"timestamp": "2026-09-13T12:00:00.123Z",
"data": {
"mail_id": "0198564e-c423-7069-8dd3-a052aeb270da",
"msg_id": "0198564e-c400-7000-8000-000000000001",
"custom_msg_id": "order-42",
"custom_group_code": "welcome",
"tags": [],
"mx_response": "250 2.0.0 OK"
}
}
]
}
Cada evento lleva id (identificador del evento, evt_…), type, timestamp (UTC, ISO 8601 con milisegundos) y data.
Campos de data en todos los tipos:
| Campo | Descripción |
|---|---|
mail_id | Identificador del correo, el mismo de la API. |
msg_id | Identificador del mensaje: el que devuelve el envío por API. |
custom_msg_id | El identificador propio que se indicó al enviar, o null. |
custom_group_code | El código de grupo que se indicó al enviar, o null. |
recipient | Dirección del destinatario. |
tags | Etiquetas de clasificación del correo, por ejemplo sandbox o test. |
msg_id, custom_msg_id, custom_group_code y recipient pueden llegar en null si la plataforma todavía no terminó de registrar el correo: la entrega espera hasta un minuto a que esos datos estén y, pasado ese plazo, sale sin ellos.
Campos adicionales por tipo:
| Tipo | Campos |
|---|---|
mail.sent | mx_response: respuesta del servidor del destinatario. |
mail.bounced | bounce.phase: sync si rebotó al entregarlo, async si llegó un aviso de rebote después. bounce.reason: motivo clasificado (por ejemplo mailbox_full), o null sin clasificación. bounce.mx_response: respuesta del servidor del destinatario. |
mail.dropped | reason: código del motivo del descarte. |
mail.opened | is_bot: true si la apertura la hizo un sistema automático. |
mail.clicked | is_bot y url, el enlace en el que se hizo clic. |
mail.unsubscribed | unsubscribe.type y unsubscribe.comment. Los tipos son los de la lista de bajas. |
mail.resubscribed | Ninguno. |
mail.complained | complaint.type y complaint.comment. Los tipos son los de la lista de quejas. |
Verificar la firma
La firma sigue la especificación Standard Webhooks, así que cualquiera de sus librerías oficiales la verifica. Para verificarla sin una librería, siga estos pasos:
- Arme el contenido firmado:
{webhook-id}.{webhook-timestamp}.{body}, con el body exactamente como se recibió, sin volver a serializar el JSON. - Calcule un HMAC-SHA256 con la clave que resulta de decodificar en base64 la parte del secreto de firma que sigue a
whsec_. - Compare, en tiempo constante, el resultado en base64 con cada firma
v1,…del headerwebhook-signature. Puede traer varias separadas por espacio; basta con que una coincida. - Rechace la entrega si
webhook-timestampdifiere en más de 5 minutos de la hora actual, para que una entrega capturada no se pueda reenviar después.
import base64
import hashlib
import hmac
import time
def is_valid(secret: str, headers: dict[str, str], body: bytes) -> bool:
timestamp = headers["webhook-timestamp"]
if abs(time.time() - int(timestamp)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{headers['webhook-id']}.{timestamp}.".encode() + body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
return any(
hmac.compare_digest(expected, signature.removeprefix("v1,"))
for signature in headers["webhook-signature"].split()
if signature.startswith("v1,")
)
Con la librería oficial de Python (standardwebhooks), Webhook(secret).verify(body, headers) lanza una excepción si la firma o el momento del intento no son válidos.
Respuesta y reintentos
- Una entrega es exitosa si el endpoint responde con un código
2xxdentro de 10 segundos. El body de la respuesta se ignora. - Cualquier otro código, un timeout o un error de conexión es un fallo. Las redirecciones no se siguen: un
3xxtambién es un fallo. - Tras un fallo, la entrega se reintenta a los 5 segundos, 5 minutos, 30 minutos, 2 horas, 5 horas, 10 horas y 10 horas. Si el último intento, unas 27 horas después del primero, también falla, la entrega se descarta.
- Cada reintento lleva el mismo
webhook-idy el mismo body; cambianwebhook-timestampy la firma. - Un reintento usa la configuración vigente del webhook. Si el webhook se eliminó o está pausado cuando le toca, el reintento se descarta. Si se rotó el secreto, se firma como indica Rotar el secreto.
- Cuando un endpoint acumula varios fallos seguidos, entra en una racha. En ese período:
- Las entregas nuevas no se intentan en el momento: parten directamente desde el primer reintento.
- Los reintentos se turnan: se hace un solo intento por minuto entre todos los webhooks de la instancia que están en racha. Con varios webhooks en racha, pasan varios minutos entre dos intentos del mismo webhook.
- Un reintento que no consigue turno pasa al siguiente de su calendario, o se descarta si era el último.
- La primera entrega exitosa termina la racha. Corregir la URL o reactivar el webhook también la termina, de inmediato.
Conviene que su endpoint responda de inmediato y procese el contenido después: si tarda cerca de 10 segundos en contestar, la entrega puede fallar por timeout.
Rotar el secreto
Rotar el secreto genera un secreto de firma nuevo. Durante las 24 horas siguientes, cada entrega, incluidos los reintentos y el evento de prueba, lleva dos firmas en webhook-signature: primero la del secreto nuevo y después la del anterior. Si su sistema verifica con cualquiera de las dos firmas, puede pasar al secreto nuevo sin rechazar entregas durante el cambio. El campo previous_secret_expires_at del webhook indica hasta cuándo firma el anterior.
- Rotar otra vez dentro de ese plazo descarta el secreto más antiguo: una entrega nunca lleva más de dos firmas.
- Si el secreto anterior quedó expuesto, rotar con
{"expire_previous": true}hace que deje de firmar de inmediato. - Al rotar, la plataforma avisa por correo a los usuarios de la instancia con rol Administrador en la app Mail, con la fecha en que el secreto anterior deja de firmar. El aviso no incluye el secreto nuevo. Ese secreto se muestra una sola vez a quien rotó, en la respuesta o en el panel. El aviso sirve para que quien despliega el cambio conozca el plazo aunque no haya rotado.
Pausa automática
Un webhook cuyo endpoint falla de forma persistente se pausa solo, cuando acumula 20 fallos seguidos a lo largo de 72 horas o más sin ninguna entrega exitosa. La racha se cuenta desde su primer fallo, así que un webhook que pasó días sin eventos no se pausa por fallar unos minutos.
- La racha en curso se lee en dos campos:
consecutive_failures(cuántos fallos lleva) yfailing_since(desde cuándo falla). Los dos hacen falta para saber cuánto le queda antes de pausarse, porque la condición exige las dos cosas a la vez. Una entrega exitosa devuelvefailing_sinceanull. - Un webhook pausado automáticamente tiene
is_activeenfalseyauto_paused_atcon el momento de la pausa. Una pausa manual dejaauto_paused_atennull. - Mientras está pausado no recibe entregas: los eventos de ese lapso no se entregan después, y un reintento que vence durante la pausa se descarta.
- Para reactivarlo se edita con
is_active: true. Reactivarlo, o cambiar su URL, reinicia el conteo de fallos: la entrega siguiente se intenta en el momento y un fallo aislado no lo vuelve a pausar. - La plataforma avisa por correo dos veces mientras el endpoint falla, a los usuarios de la instancia con rol Administrador en la app Mail, que son quienes pueden corregirlo:
- Tras una hora de fallo continuo. Indica desde cuándo falla, cuántos fallos acumuló, el último código de respuesta y las dos fechas límite: en cuál empiezan a descartarse los eventos por agotar sus reintentos y en cuál se pausará el webhook. Una sola entrega exitosa cancela ambas.
- Al pausarse, si la racha llegó hasta ahí.
- Cada aviso se envía una sola vez por racha, dentro de la hora siguiente al hecho. Una entrega exitosa cierra la racha en curso: si el endpoint vuelve a fallar más adelante, empieza una racha nueva y el ciclo de avisos se repite desde el primero.
- Una pausa hecha por usted, desde el panel o por la API, no genera aviso.
Duplicados y orden
- Una misma entrega puede llegar más de una vez, por ejemplo si el endpoint la procesó pero su respuesta no alcanzó a llegar. Descarte los duplicados por
webhook-id, o por elidde cada evento: un evento conserva suiden todas las entregas que lo lleven. - Las entregas no llegan necesariamente en el orden en que ocurrieron los eventos. Para ordenarlos, use el
timestampde cada evento.
Destinatarios sandbox
Los eventos de correos a destinatarios *.sandbox se entregan igual que los de producción, con sandbox en tags. Eso permite probar la integración completa (envío, eventos y verificación de la firma) sin tocar destinatarios reales. Los comportamientos que simula cada destinatario están en Sandbox.
Evento de prueba
La prueba de un webhook envía un único evento de tipo webhook.test, con data vacío, firmado igual que una entrega real (en las 24 horas siguientes a una rotación, con los dos secretos de firma). No se reintenta y no deja rastro en el estado de entregas del webhook.
La respuesta devuelve el intercambio completo: lo que se envió, con los headers de firma, y lo que respondió su endpoint, con su body. Así puede comprobar la verificación de la firma sin preparar nada de su lado. El evento de prueba es siempre webhook.test: no hay uno por cada tipo.
Requisitos del endpoint
- La URL debe ser
https://, sin usuario ni contraseña, y su host debe resolver a direcciones públicas. Una URL que no cumple se rechaza al crear o editar el webhook. - La misma comprobación se repite en cada intento: si el host pasa a resolver a una dirección privada, la entrega falla.