Saltar al contenido principal

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:read para consultar y webhooks:write para el resto.
  • Un evento llega normalmente a los pocos segundos de ocurrir. Cada entrega es un POST que agrupa uno o más eventos de un mismo tipo.

Eventos​

TipoOcurre cuando
mail.sentEl servidor del destinatario aceptó el correo.
mail.bouncedEl correo rebotó, al entregarlo o después, por un aviso de rebote del servidor del destinatario.
mail.droppedLa plataforma descartó el correo sin entregarlo.
mail.openedEl destinatario abrió el correo.
mail.clickedEl destinatario hizo clic en un enlace del correo.
mail.unsubscribedEl destinatario se dio de baja.
mail.resubscribedEl destinatario revirtió su baja.
mail.complainedEl destinatario reportó el correo como abuso.
webhook.testAlguien 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​

HeaderValor
content-typeapplication/json
user-agentFidelizador-Webhooks/1
webhook-idIdentificador de la entrega (msg_…). Es el mismo en todos sus reintentos.
webhook-timestampMomento del intento, en segundos Unix.
webhook-signatureFirma 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",
"recipient": "[email protected]",
"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:

CampoDescripción
mail_idIdentificador del correo, el mismo de la API.
msg_idIdentificador del mensaje: el que devuelve el envío por API.
custom_msg_idEl identificador propio que se indicó al enviar, o null.
custom_group_codeEl código de grupo que se indicó al enviar, o null.
recipientDirección del destinatario.
tagsEtiquetas 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:

TipoCampos
mail.sentmx_response: respuesta del servidor del destinatario.
mail.bouncedbounce.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.droppedreason: código del motivo del descarte.
mail.openedis_bot: true si la apertura la hizo un sistema automático.
mail.clickedis_bot y url, el enlace en el que se hizo clic.
mail.unsubscribedunsubscribe.type y unsubscribe.comment. Los tipos son los de la lista de bajas.
mail.resubscribedNinguno.
mail.complainedcomplaint.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:

  1. Arme el contenido firmado: {webhook-id}.{webhook-timestamp}.{body}, con el body exactamente como se recibió, sin volver a serializar el JSON.
  2. Calcule un HMAC-SHA256 con la clave que resulta de decodificar en base64 la parte del secreto de firma que sigue a whsec_.
  3. Compare, en tiempo constante, el resultado en base64 con cada firma v1,… del header webhook-signature. Puede traer varias separadas por espacio; basta con que una coincida.
  4. Rechace la entrega si webhook-timestamp difiere 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 2xx dentro 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 3xx tambié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-id y el mismo body; cambian webhook-timestamp y 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) y failing_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 devuelve failing_since a null.
  • Un webhook pausado automáticamente tiene is_active en false y auto_paused_at con el momento de la pausa. Una pausa manual deja auto_paused_at en null.
  • 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 el id de cada evento: un evento conserva su id en todas las entregas que lo lleven.
  • Las entregas no llegan necesariamente en el orden en que ocurrieron los eventos. Para ordenarlos, use el timestamp de 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.