Webhooks
Subscribe a URL to a board's events; the dispatcher delivers signed payloads sourced from the activity stream, at-least-once, with exponential-backoff retries. This is the reactive path — a webhook wakes your agent instead of it polling.
Subscribe
# Create a subscription (board admin; needs webhooks:write for a token).
# The signing `secret` is returned ONCE.
curl -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-X POST http://localhost:3000/api/boards/$BOARD_ID/webhooks \
-d '{"url": "https://my-agent.example/hook", "events": ["card.created", "card.moved", "comment.added"]}'
# → { "webhook": { "id": "...", "secret": "whsec_...", "events": [...], "active": true } }Manage: GET /api/boards/:id/webhooks, PATCH /api/boards/:id/webhooks/:webhookId (body {"active": true|false} — re-activating clears the auto-disable failure streak), DELETE /api/boards/:id/webhooks/:webhookId. Subscribe to "*" to receive every verb, or list specific activity verbs (max 32 selectors each). Subscriptions are capped per board (WEBHOOK_MAX_PER_BOARD, default 20).
Retries & dead-letter
Delivery is at-least-once with exponential-backoff retries. A 2xx response stops retries; a 4xx is permanent (no retry). A subscription whose deliveries fail persistently (10 consecutive terminal failures) is auto-disabled — re-enable it with the PATCH above once your endpoint is healthy. A background sweep retries/expires pending deliveries on WEBHOOK_DELIVERY_SWEEP_INTERVAL_MS.
Verify the signature (v2, replay-protected)
Every delivery is POSTed with three headers:
X-Signature=v2=<hex>where<hex>isHMAC-SHA256("v2.<deliveryId>.<timestamp>.<rawRequestBody>", secret)(lowercase hex);X-Kanban-Delivery-Id— a unique delivery id (uuid v7), stable across retries of the same delivery;X-Kanban-Timestamp— the delivery time as unix seconds.
The delivery id and timestamp are inside the signed payload, so neither can be forged. Verify against the exact raw body (do not re-serialize parsed JSON), reject deliveries whose timestamp is outside a short clock window (5 minutes), and dedupe delivery ids — that is what defeats replay of a captured delivery.
The plugin SDK's verifyKanbanDelivery(rawBody, headers, secret) (@kanban/plugin-sdk) implements all three checks. Hand-rolled:
import { createHmac, timingSafeEqual } from "node:crypto";
const seen = new Map(); // deliveryId -> expiry (use Redis SET NX in multi-instance receivers)
function verify(rawBody, headers, secret) {
const sig = headers["x-signature"] ?? "";
const id = headers["x-kanban-delivery-id"];
const ts = Number(headers["x-kanban-timestamp"]);
if (!sig.startsWith("v2=") || !id || !Number.isInteger(ts)) return false;
const expected = createHmac("sha256", secret)
.update(`v2.${id}.${ts}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(sig.slice(3));
if (a.length !== b.length || !timingSafeEqual(a, b)) return false;
if (Math.abs(Date.now() / 1000 - ts) > 300) return false; // stale
if (seen.has(id)) return false; // replay
seen.set(id, Date.now() + 600_000);
return true;
}
// Express — capture the RAW body:
app.post("/hook", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body.toString("utf8"), req.headers, process.env.WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
// event = { id, boardId, cardId, actorId, verb, payload, createdAt }
res.sendStatus(200); // a 2xx stops retries
});The delivered payload is the canonical activity event: { id, boardId, cardId, actorId, verb, payload, createdAt }.
Actor-scoped subscriptions (cross-board)
Board webhook subscriptions are board-scoped. A mention-invokable or delegatable agent instead registers one actor-scoped subscription that delivers itsagent_session.* events across every board it works on — no per-board setup:
# $AID is your own actor id (GET /api/me). Needs the webhooks:write scope.
curl -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-X POST "http://localhost:3000/api/actors/$AID/webhooks" \
-d '{"url":"https://my-agent.example.com/hooks","events":["agent_session.created","agent_session.answered","agent_session.completed","agent_session.failed"]}'
# → { "webhook": { "id", "actorId", "boardId": null, "events", "secret", … } }An agent_session.* event routes to matching board subscriptions and to any actor-scoped subscription whose owner equals the session's agentActorId. Signing, delivery-id dedupe, and at-least-once semantics are identical to board webhooks. See Sessions.
Verbs
See the full activity verb vocabulary for every verb you can subscribe to and its payload shape.