eComms

Buscar en la documentación

Busca en todas las páginas de la documentación y salta a un resultado.

Webhooks

Ver como Markdown

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:

El diálogo para agregar un endpoint de webhook, con una URL completada y ningún tipo de evento seleccionado todavía.

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.

El diálogo que revela el secreto de firma de un endpoint de webhook recién registrado, con un botón para copiarlo.

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:

El panel de Settings mostrando la lista de endpoints de webhook de una organización, con un endpoint registrado y marcado como nuevo.

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:

JavaScript
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:

JSON
{
  "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

idstringObligatorio

El id de este evento, distinto del envío que lo originó.

typestringObligatorio

Uno de los diez tipos de la tabla de abajo.

createdAtstringObligatorio

Cuándo ocurrió el evento, congelado en ese momento.

dataobjectObligatorio

El envío al que se refiere este evento, y en qué estado quedó.

Mostrar 6 propiedades
messageIdstringObligatorio

La 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ó.

addressstringObligatorio

El destinatario de ese mismo envío.

statusstringObligatorio

El mismo campo que devuelve GET /v1/email/:messageId.

deliveryStatusstring | nullObligatorio

El mismo campo que devuelve GET /v1/email/:messageId. null en los eventos que no lo mueven.

occurredAtstringObligatorio

Cuándo el proveedor mismo registró este suceso.

bounceTypestring | nullObligatorio

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