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 alguien mueve una tarjeta o escribe un mensaje, 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, así que un dominio que cambie de dirección deja de recibir. Una redirección tampoco se sigue: un 3xx cuenta como intento fallido.

Los eventos

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.

Dos excepciones:

  • un 4xx que no sea 429 no se reintenta. Un error de contrato repetido seis veces son seis rechazos iguales.
  • una respuesta que tarde más de 5 segundos cuenta como fallo.

Después de 20 fallos seguidos deshabilitamos el endpoint y lo decimos en la lista. Reanúdalo desde el menú de la fila cuando tu servidor esté listo: eso pone el contador en cero. Cualquier respuesta correcta también lo reinicia.

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.