Webhooks
An agent listens on the stream. A serverless function, a CI job or a chat bot has nowhere to listen from, so it gives Ushabti a URL and Ushabti posts to it.
A webhook rings the same doorbell the stream rings: that something changed and where, never what. No title, no value, no before and after. The receiver reads the board for the rest.
Make one
Section titled “Make one”Settings → Webhooks, at /p/{projectId}/settings/webhooks. Only the owner and admins see
the page, and every route below is adminOnly — the read one included. A URL and a secret are
another road onto the board.
The secret is shown once, when the webhook is made. Ushabti keeps it whole, because it signs every body with it, but nothing shows it again: the page then shows a prefix and Roll the secret.
The payload
Section titled “The payload”{ "delivery": "9d2d9d6e-9a6f-4f63-9b1e-6b2b0b6a4c11", "projectId": "2f1a7c2e-5f0c-4d4a-9a2e-2b9a3c4d5e6f", "projectKey": "USH", "kind": "comment", "taskId": "7c1b9a2d-3e4f-4a5b-8c9d-0e1f2a3b4c5d", "taskKey": "USH-31", "at": "2026-03-17T09:46:40.000Z"}kind is one of the words the activity feed writes: created, title, description, value,
checklist, comment, run, archive, link, deleted, reset, import, attachment, rules.
A webhook that names
none of them rings for all of them. A change that writes no feed line — a view, a property, the card
view — rings nothing.
import rings once for a whole import, not once per card: the owner brought a board in from
Trello, and two thousand deliveries for one press of a button is a flood and not an event. The ring
carries the line about the project, so taskId and taskKey are null; the feed holds one import
line per task behind it.
link rings both ways, as deleted does: a task was made to wait on another, or stopped waiting.
taskKey is the task that gained or lost the blocker; the blocker’s own key is on the feed line, in
data.blockerKey, beside data.action, which is linked or unlinked. A blocker becoming over
rings nothing, because nothing happened to that task. link also rings when a task is made part of
another or taken out of it: taskKey is the part, and the feed line carries data.action
parented or unparented with the parent’s key in data.parentKey.
attachment rings when a file is added to a task or taken off it: the feed line carries
data.action, added or removed, with data.attachmentId and data.name. An upload nobody
confirmed rings nothing. rules rings when somebody changes the project’s agent rules; the line is
about the project, and carries data.hash and data.text. reset is about the project too: a reset
link was made, for the member in data.forUserId and data.forName.
comment rings for a new comment and for an edit of one. The feed line tells them apart: an edit
carries data.action: "edited" beside data.commentId.
taskId and taskKey are null on a line about the project rather than about a task. Send a
test carries kind: "test" and no task, because there is no feed line behind it.
The headers
Section titled “The headers”| Header | What it is |
|---|---|
X-Ushabti-Delivery |
The delivery id, also in the body |
X-Ushabti-Timestamp |
When the body was signed, in seconds |
X-Ushabti-Signature |
sha256=<hex> over timestamp + "." + body |
Verify it
Section titled “Verify it”HMAC-SHA256 with the secret, over the raw body, before any JSON parsing. Compare in constant time and refuse a timestamp older than five minutes.
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verify(secret, headers, rawBody) { const time = headers['x-ushabti-timestamp'] const given = Buffer.from(headers['x-ushabti-signature'] ?? '') if (!time || Math.abs(Date.now() / 1000 - Number(time)) > 300) return false const want = Buffer.from( 'sha256=' + createHmac('sha256', secret).update(`${time}.${rawBody}`).digest('hex'), ) return want.length === given.length && timingSafeEqual(want, given)}A known answer, so you can check your own code before you trust a delivery:
secret ushs_a-known-secret-for-the-testtimestamp 1774000000body {"delivery":"9d2d9d6e-9a6f-4f63-9b1e-6b2b0b6a4c11","projectId":"2f1a7c2e-5f0c-4d4a-9a2e-2b9a3c4d5e6f","projectKey":"USH","kind":"comment","taskId":"7c1b9a2d-3e4f-4a5b-8c9d-0e1f2a3b4c5d","taskKey":"USH-31","at":"2026-03-17T09:46:40.000Z"}signature sha256=861a870d23c4a8089c8af6c498dd30e1bafb67b299080feb2ec792de5088f70dWhich addresses are allowed
Section titled “Which addresses are allowed”A webhook is the one request a person tells the server to make for them, so the address has to be
one a stranger could reach too. Ushabti refuses a loopback (127.0.0.0/8, 0.0.0.0/8, ::1,
localhost), private (10/8, 172.16/12, 192.168/16, fc00::/7) or link-local
(169.254.0.0/16 — the cloud metadata address — and fe80::/10) host, and a URL that carries a
name and password. It checks when the URL is saved and again before every try, resolving the name
each time.
Delivery and retries
Section titled “Delivery and retries”The first try goes at once. A failed try is made again after 1 minute, then 5 minutes, then
30 minutes — four in all. Each one waits 5 seconds for an answer. Anything outside 2xx
fails, and so does a redirect.
After four failures the delivery is dropped. Catching up is the feed’s job, which is the record:
GET /api/projects/{projectId}/activity?after=….
The last 20 deliveries of each webhook are kept; the page reads the newest of them for its delivered 2m ago line.
The routes
Section titled “The routes”| What | Call |
|---|---|
| The list, with each one’s last delivery | GET /api/projects/{projectId}/webhooks |
| Make one, and read its secret once | POST /api/projects/{projectId}/webhooks |
URL, kinds, on or off, or {"roll": true} |
PATCH /api/projects/{projectId}/webhooks/{id} |
| Send a test | POST /api/projects/{projectId}/webhooks/{id} |
| Delete it | DELETE /api/projects/{projectId}/webhooks/{id} |
What a receiver should do
Section titled “What a receiver should do”- Check the signature. Refuse what fails, with
401. - Answer fast —
200and nothing else — then do the work. Five seconds is all the sender waits. - Remember the delivery ids you have seen and skip a repeat.
- Read
/activity?after=…for what actually happened, from a few seconds before your cursor, and skip the entries you have handled: two writes can commit out of the order of their clocks. A full page goes on from its last line, withafterIdbesideafter, as the activity feed says.