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.