Webhooks
Registra un endpoint desde Settings, la forma del payload y los diez tipos de evento, y verifica la firma de cada entrega.
Registra un endpoint
Desde Settings → Webhooks, Agregar endpoint abre un diálogo que pide una URL y a cuáles de los diez tipos de evento suscribirte — ninguno viene marcado de entrada, el mismo criterio de mínimo privilegio con el que empiezan los permisos de una clave de API:
Al crearlo, el panel te muestra el secreto de firma una sola vez — la misma garantía que una clave de API: no queda guardado en ninguna columna, así que si lo pierdes, la única forma de volver a verlo es rotarlo, lo que también invalida el anterior. Cerrar sin copiarlo te pide confirmar, porque no hay forma de volver a esta pantalla después.
Cópialo en ese momento. Después, el panel lista el endpoint junto a cualquier otro que esta organización tenga registrado, señalado solo cuando algo necesita atención — con fallos o pausado, nunca una insignia de “activo” en uno saludable — con un menú para ver sus entregas, editar su URL o sus eventos, pausarlo o reanudarlo, o eliminarlo. Rotar el secreto vive un nivel más adentro, en la pantalla propia del endpoint, abierta desde su fila:
Verifica la firma
Cada entrega llega como un POST con este encabezado:
webhook-signature: v1=<firma en hexadecimal>La firma es HMAC-SHA256(secreto, cuerpo), calculada sobre los bytes exactos del cuerpo — no
sobre tu propia versión reserializada del JSON, que puede no coincidir byte a byte con lo que
enviamos. Verifícala sobre el cuerpo crudo, antes de parsearlo:
import { createHmac, timingSafeEqual } from "node:crypto"
function isValidSignature(rawBody, header, secret) {
const [scheme, signature] = header.split("=")
if (scheme !== "v1" || signature === undefined) return false
const expected = createHmac("sha256", secret).update(rawBody).digest("hex")
return (
expected.length === signature.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
)
}v1= es el esquema de la firma, no un separador cualquiera — dejamos espacio para introducir
un segundo esquema en el futuro sin romper a quien ya está parseando este.
Compara con timingSafeEqual, nunca con ===. Una comparación normal de strings termina
en el primer carácter que no coincide, y ese tiempo de respuesta es información: suficiente
para que alguien reconstruya la firma correcta un carácter a la vez, probando contra tu propio
endpoint.
Forma del payload
Toda entrega envía este mismo sobre, congelado en el momento en que el evento ocurrió — nunca recalculado después:
{
"id": "01a0ba31-3fe4-7cc4-a1c6-64bdaaa5d99c",
"type": "message.delivered",
"createdAt": "2026-09-19T12:00:04Z",
"data": {
"messageId": "01a0ba31-4a15-7c9a-9b3d-3f1e6c2b7a44",
"address": "ana@example.com",
"status": "sent",
"deliveryStatus": "delivered",
"occurredAt": "2026-09-19T12:00:03Z",
"bounceType": "Permanent"
}
}Atributos de la respuesta
idstringObligatorioEl id de este evento, distinto del envío que lo originó.
typestringObligatorioUno de los diez tipos de la tabla de abajo.
createdAtstringObligatorioCuándo ocurrió el evento, congelado en ese momento.
dataobjectObligatorioEl envío al que se refiere este evento, y en qué estado quedó.
Mostrar 6 propiedadesOcultar 6 propiedades
messageIdstringObligatorioLa misma clave que devolvió POST /v1/email y la que acepta GET /v1/email/:messageId — es lo que correlaciona un evento con el envío que lo originó.
addressstringObligatorioEl destinatario de ese mismo envío.
statusstringObligatorioEl mismo campo que devuelve GET /v1/email/:messageId.
deliveryStatusstring | nullObligatorioEl mismo campo que devuelve GET /v1/email/:messageId. null en los eventos que no lo mueven.
occurredAtstringObligatorioCuándo el proveedor mismo registró este suceso.
bounceTypestring | nullObligatorioSolo aparece en message.bounced y message.soft_bounced, y es el vocabulario propio del proveedor (Permanent, Transient, Undetermined), no nuestro enum cerrado. null en cualquier otro tipo.
El contenido del mensaje y el id interno del proveedor nunca viajan en el payload: ninguno de los dos es algo que le corresponda a un endpoint que puede estar mal configurado.
Los diez tipos
status y deliveryStatus son los mismos campos que devuelve GET /v1/email/:messageId — un
webhook es la otra forma de conocer los mismos dos valores, sin tener que consultar.
| Tipo | Cuándo se emite |
|---|---|
message.sent |
El intento de envío mismo tuvo éxito: el proveedor aceptó el mensaje. status pasa a sent. |
message.failed |
El intento de envío mismo falló, antes de llegar al proveedor. status pasa a failed — esta rama nunca llega a la escalera de deliveryStatus. |
message.deferred |
El proveedor retrasó la entrega de forma temporal. deliveryStatus pasa a deferred. |
message.delivered |
El servidor de destino aceptó el mensaje. deliveryStatus pasa a delivered. |
message.rejected |
El proveedor lo rechazó de plano, antes de intentar entregarlo. deliveryStatus pasa a rejected. |
message.bounced |
El servidor de destino lo devolvió de forma permanente (data.bounceType es Permanent). deliveryStatus pasa a bounced y la dirección se suprime. |
message.soft_bounced |
El servidor de destino lo devolvió de forma temporal — una respuesta de fuera de la oficina, un buzón lleno — con data.bounceType en Transient o Undetermined. No mueve deliveryStatus y no suprime nada. Un mensaje que rebota de forma temporal varias veces emite este tipo una vez por cada evento de rebote. |
message.complained |
La persona destinataria lo marcó como spam. deliveryStatus pasa a complained. |
message.opened |
Se abrió el mensaje. Un evento de interacción: no mueve deliveryStatus. |
message.clicked |
Se hizo clic en un enlace rastreado. También de interacción, tampoco mueve deliveryStatus. |
deliveryStatus es una escalera que nunca retrocede. Una queja que llega días después de
una entrega gana igual, sin importar que su propio evento sea más tarde — por eso un
message.complained puede llegar después de un message.delivered para el mismo mensaje, y
es lo correcto.