Webhooks

Eel calls your own server, signed, the moment something happens.

A webhook is a call Eel makes to you. Every time something happens in your workspace, we send that event to the address you give us, within a minute, without you asking for anything.

Creating one

In Nest, under Webhooks. Workspace owners and admins create them, and they are the only ones who see them: a webhook's address names the server receiving a copy of everything that happens inside.

The form asks for three things: the https:// address, a description, and the events you want. Tick nothing and you receive them all, including the ones we add later.

You see the signing secret exactly once, when you create the webhook. Save it before you close the window: it is what proves an event came from us.

A webhook receives events from the whole workspace, direct messages included. Point it at a server of your own and at nothing else.

The address

We accept https:// and nothing else, private networks included. We refuse an address at create time, with the reason, when it:

  • does not start with https
  • carries a username or a password
  • targets a reserved port
  • names a domain that does not resolve
  • names a domain that resolves to a private or loopback address

The same check runs again at send time, so a domain that now resolves to a private address stops receiving: that delivery is marked blocked rather than retried, because no amount of retrying changes what the name resolves to. A lookup that fails outright at send time (your DNS having a bad minute) is treated as transient instead and gets retried like any other delivery failure. A redirect is not followed either: a 3xx counts as a failed attempt.

The events

Every event belongs to an app, and the form groups them by it. These come from Swarm.

Type Arrives when
task.created somebody creates a card, wherever it comes from
task.updated the title, description, priority or due date changes
task.moved the card changes column
task.assigned who owns the card changes
task.comment.created somebody comments on a card
channel.message.created somebody writes in a channel or a direct message

task.moved and task.assigned sit outside task.updated so you never have to read a diff to find out a card moved.

There is no mention event. One message produces one delivery, and data.message.mentions[] carries every mention with its person, plus the @channel and @here broadcasts.

The Test button sends a ping event down the same queue with the same signature as the rest.

The envelope

Every event arrives in the same shape:

{
  "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": "Review the proposal",
      "url": "https://swarm.eel.software/w/acme/t/412",
      "project": { "id": "9d0e…", "name": "Sales" },
      "status": { "id": "3f4a…", "name": "In progress", "is_done": false },
      "assignee": { "user_id": "0a1c…", "display_name": "Sofía" },
      "labels": [{ "id": "77aa…", "name": "urgent", "color": "red" }],
      "priority": "HIGH",
      "due_date": "2026-09-12",
      "completed_at": null
    },
    "from_status": { "id": "1a2b…", "name": "To do", "is_done": false },
    "to_status": { "id": "3f4a…", "name": "In progress", "is_done": false }
  }
}

The card comes in full inside the envelope so you never have to call us back for a project's name or a column's.

version is the contract for data. It moves only if we change something in an incompatible way; adding a field does not move it, so your code should ignore fields it does not know.

The headers

POST /your-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 is the envelope's own id: it does not change between retries, nor between two webhooks receiving the same event. That is your key for not processing the same thing twice. X-Eel-Delivery changes on every attempt and every endpoint, so it does not serve that purpose.

Verifying the signature

The signature is an HMAC-SHA256 over the string <timestamp>.<body>, keyed with the secret minus its whsec_ prefix. Compare in constant time and reject anything more than five minutes out of date.

In 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)
}

In 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)

Sign the body as it arrives, byte for byte. If your framework parses and re-serializes it before you see it, the signature will not match: read the raw body.

Retries

A 2xx closes the delivery and that is the end of it. Anything else is retried up to six times, waiting 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours between attempts. A server that was down for eight hours gets its events back.

One exception: a 4xx that is not 429 is not retried. A contract error repeated six times is six identical rejections, so we archive it as failed on the first attempt instead. Everything else counts as a bad minute and gets the full ladder: a 5xx, a 429, a response slower than 5 seconds, a connection error, and a DNS lookup that fails to resolve your host.

After 20 failures in a row we disable the endpoint and say so in the list. That count only advances once per event, on the attempt that finally gives up on it, whether that's exhausting the retry ladder or an immediate contract error. It never advances once per retry, so one event working through its six attempts never costs more than a single strike. Resume a disabled endpoint from the row menu once your server is ready, which puts the counter back to zero. Any successful response resets it too.

A paused webhook holds nothing back: while it is paused, events are not delivered and are not backfilled when you resume it, and anything already queued is marked Skipped in the deliveries list.

Seeing what happened

Each row's menu opens View deliveries: the last 50 attempts with their event, status, attempt number, the code your server returned, and the time. Resend sends that same event again, with the same bytes and therefore the same X-Eel-Event-Id, under a fresh signature.

We keep the history for 14 days.

Rotating the secret

Rotate secret mints a new one and shows it once. The previous one stops working immediately, so rotate when you have somewhere ready to paste the new value.