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
4xxthat is not429is 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.