# 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](#los-diez-tipos) 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:

```js

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

- `id` `string` — Obligatorio. El id de este evento, distinto del envío que lo originó.
- `type` `string` — Obligatorio. Uno de los diez tipos de la tabla de abajo.
- `createdAt` `string` — Obligatorio. Cuándo ocurrió el evento, congelado en ese momento.
- `data` `object` — Obligatorio. El envío al que se refiere este evento, y en qué estado quedó.
  - `messageId` `string` — Obligatorio. 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ó.
  - `address` `string` — Obligatorio. El destinatario de ese mismo envío.
  - `status` `string` — Obligatorio. El mismo campo que devuelve GET /v1/email/:messageId.
  - `deliveryStatus` `string | null` — Obligatorio. El mismo campo que devuelve GET /v1/email/:messageId. null en los eventos que no lo mueven.
  - `occurredAt` `string` — Obligatorio. Cuándo el proveedor mismo registró este suceso.
  - `bounceType` `string | null` — Obligatorio. 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.
