Skip to content

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

bash
# 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> is HMAC-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:

js
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:

bash
# $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.

Released under the AGPL-3.0 license.