Webhooks

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

A webhook is a call Eel makes to you. Every time somebody moves a card or writes a message, 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 changes address stops receiving. A redirect is not followed either: a 3xx counts as a failed attempt.

The events

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.

Two exceptions:

  • a 4xx that is not 429 is not retried. A contract error repeated six times is six identical rejections.
  • a response that takes longer than 5 seconds counts as a failure.

After 20 failures in a row we disable the endpoint and say so in the list. Resume it from the row menu once your server is ready, which puts the counter back to zero. Any successful response resets it too.

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.