Webhooks

Eel te avisa cuando algo pasa, firmado, en tu propio servidor.

Un webhook es una llamada que Eel te hace a ti. Cada vez que pasa algo en tu espacio de trabajo, enviamos ese evento a la dirección que nos des, en menos de un minuto, sin que preguntes nada.

Cómo se crea

En Nest, en Webhooks. Lo crean propietarios y administradores del espacio de trabajo, y solo ellos lo ven: la dirección de un webhook dice a qué servidor está llegando una copia de todo lo que pasa dentro.

El formulario pide tres cosas: la dirección https://, una descripción y los eventos que quieres recibir. Si no marcas ninguno, recibes todos, incluidos los que agreguemos más adelante.

Al crearlo verás el secreto de firma una sola vez. Guárdalo antes de cerrar la ventana: con él verificas que cada evento viene de nosotros.

Un webhook recibe los eventos de todo el espacio de trabajo, mensajes directos incluidos. Apúntalo a un servidor tuyo y a ninguno más.

La dirección

Aceptamos https:// y nada más, incluso dentro de una red privada. Rechazamos la dirección al crearla, con el motivo, cuando:

  • no empieza por https
  • lleva usuario o contraseña
  • apunta a un puerto reservado
  • el dominio no resuelve
  • el dominio resuelve a una dirección privada o de loopback

La misma comprobación corre otra vez en el momento de enviar: si el dominio ahora resuelve a una dirección privada, esa entrega se marca como bloqueada en vez de reintentarse, porque ningún reintento cambia a dónde resuelve el nombre. Si la resolución simplemente falla en ese momento (tu DNS tuvo un mal minuto), lo tratamos como algo pasajero y esa entrega se reintenta como cualquier otro fallo. Una redirección tampoco se sigue: un 3xx cuenta como intento fallido.

Los eventos

Cada evento pertenece a una app, y el formulario los agrupa por ella. Estos los emite Swarm.

Tipo Cuándo llega
task.created alguien crea una tarjeta, venga de donde venga
task.updated cambia el título, la descripción, la prioridad o la fecha
task.moved la tarjeta cambia de columna
task.assigned cambia quién es responsable de la tarjeta
task.comment.created alguien comenta una tarjeta
channel.message.created alguien escribe en un canal o en un mensaje directo

task.moved y task.assigned van aparte de task.updated para que no tengas que leer un diff para enterarte de un movimiento.

No hay un evento de mención. Un mensaje produce una entrega, y data.message.mentions[] trae cada mención con su persona, más las difusiones a @channel y @here.

El botón Probar manda un evento ping, por la misma cola y con la misma firma que los demás.

El sobre

Todo evento llega con la misma forma:

{
  "id": "6f5b5bd1-3a5e-4a6b-9c2f-2f6d1b0b4a11",
  "type": "task.moved",
  "version": 1,
  "app": "swarm",
  "workspace_id": "364d2a36-934a-42bc-919b-bb7233d2f024",
  "occurred_at": "2026-09-09T14:02:11.412Z",
  "actor": { "user_id": "0a1c…", "display_name": "Sofía", "kind": "USER" },
  "data": {
    "task": {
      "id": "b1c2…",
      "number": 412,
      "title": "Revisar la propuesta",
      "url": "https://swarm.eel.software/w/acme/t/412",
      "project": { "id": "9d0e…", "name": "Ventas" },
      "status": { "id": "3f4a…", "name": "En curso", "is_done": false },
      "assignee": { "user_id": "0a1c…", "display_name": "Sofía" },
      "labels": [{ "id": "77aa…", "name": "urgente", "color": "red" }],
      "priority": "HIGH",
      "due_date": "2026-09-12",
      "completed_at": null
    },
    "from_status": { "id": "1a2b…", "name": "Pendiente", "is_done": false },
    "to_status": { "id": "3f4a…", "name": "En curso", "is_done": false }
  }
}

La tarjeta viene completa dentro del sobre para que no tengas que llamarnos de vuelta por el nombre de un proyecto o de una columna.

version es el contrato de data. Sube solo si cambiamos algo de forma incompatible; agregar un campo no lo mueve, así que tu código debe ignorar los campos que no conozca.

Las cabeceras

POST /tu-endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Eel-Webhooks/1
X-Eel-Event: task.moved
X-Eel-Delivery: 8f1d3b90-6d2e-4a3f-8c11-9b0a2d4e6f77
X-Eel-Event-Id: 6f5b5bd1-3a5e-4a6b-9c2f-2f6d1b0b4a11
X-Eel-Attempt: 1
X-Eel-Timestamp: 1789041731
X-Eel-Signature: v1=8b1a…

X-Eel-Event-Id es el mismo del campo id del sobre: no cambia entre reintentos ni entre dos webhooks que reciban el mismo evento. Ese es tu identificador para no procesar dos veces lo mismo. X-Eel-Delivery cambia en cada intento y en cada endpoint, así que no sirve para eso.

Verificar la firma

La firma es un HMAC-SHA256 sobre el texto <timestamp>.<cuerpo>, con el secreto sin el prefijo whsec_ como clave. Compara en tiempo constante y rechaza lo que llegue con más de cinco minutos de diferencia.

En Node:

import { createHmac, timingSafeEqual } from 'node:crypto'

export function verify(rawBody, headers, secret) {
  const timestamp = headers['x-eel-timestamp']
  const signature = headers['x-eel-signature']
  if (!timestamp || !signature) return false

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp))
  if (!Number.isFinite(age) || age > 300) return false

  const key = secret.startsWith('whsec_') ? secret.slice(6) : secret
  const expected = 'v1=' + createHmac('sha256', key).update(`${timestamp}.${rawBody}`).digest('hex')

  const a = Buffer.from(expected)
  const b = Buffer.from(signature)
  return a.length === b.length && timingSafeEqual(a, b)
}

En Python:

import hashlib
import hmac
import time


def verify(raw_body: bytes, headers, secret: str) -> bool:
    timestamp = headers.get("X-Eel-Timestamp")
    signature = headers.get("X-Eel-Signature")
    if not timestamp or not signature:
        return False

    if abs(int(time.time()) - int(timestamp)) > 300:
        return False

    key = secret[6:] if secret.startswith("whsec_") else secret
    signed = f"{timestamp}.".encode() + raw_body
    expected = "v1=" + hmac.new(key.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Firma el cuerpo tal como llega, byte por byte. Si tu framework lo parsea y lo vuelve a serializar antes de que lo veas, la firma no va a cuadrar: lee el cuerpo crudo.

Reintentos

Respondemos a un 2xx archivando la entrega y ahí termina. Cualquier otra cosa se reintenta hasta seis veces, esperando 30 segundos, 2 minutos, 10 minutos, 1 hora y 6 horas entre intentos. Con eso, un servidor que estuvo caído ocho horas recupera sus eventos.

Una excepción: un 4xx que no sea 429 no se reintenta. Un error de contrato repetido seis veces son seis rechazos iguales, así que lo archivamos como fallido en el primer intento en vez de reintentarlo. Todo lo demás cuenta como un mal minuto y recibe la escalera completa: un 5xx, un 429, una respuesta que tarde más de 5 segundos, un error de conexión y una resolución DNS que falla.

Después de 20 fallos seguidos deshabilitamos el endpoint y lo decimos en la lista. Ese contador solo avanza una vez por evento, en el intento que finalmente lo da por perdido, ya sea agotando la escalera de reintentos o un error de contrato inmediato. Nunca avanza una vez por reintento, así que un evento que pasa por sus seis intentos nunca cuesta más de un solo strike. Reanuda un endpoint deshabilitado desde el menú de la fila cuando tu servidor esté listo: eso pone el contador en cero. Cualquier respuesta correcta también lo reinicia.

Un webhook pausado no acumula nada: mientras está en pausa los eventos no se entregan ni se recuperan al reanudarlo, y los que alcancen a quedar en cola se marcan Omitido en la lista de entregas.

Ver qué pasó

El menú de cada fila abre Ver entregas: los últimos 50 intentos con su evento, su estado, el número de intento, el código que devolvió tu servidor y la hora. Reenviar vuelve a mandar ese mismo evento, con las mismas bytes y por tanto el mismo X-Eel-Event-Id, con una firma nueva.

Guardamos el historial 14 días.

Rotar el secreto

Rotar secreto genera uno nuevo y lo muestra una sola vez. El anterior deja de servir de inmediato, así que hazlo cuando tengas dónde pegar el nuevo.