Skip to content

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.

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.

{
"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.

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

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-test
timestamp 1774000000
body {"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=861a870d23c4a8089c8af6c498dd30e1bafb67b299080feb2ec792de5088f70d

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.

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.

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}
  1. Check the signature. Refuse what fails, with 401.
  2. Answer fast — 200 and nothing else — then do the work. Five seconds is all the sender waits.
  3. Remember the delivery ids you have seen and skip a repeat.
  4. 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, with afterId beside after, as the activity feed says.