Realtime and webhooks
Follow the board as it happens — subscribe over SSE or receive signed webhook deliveries.
Two ways to stop polling: keep a Server-Sent Events connection open, or let Batuta push signed deliveries to your URL.
Realtime over SSE
GET /api/projects/:id/stream keeps an text/event-stream connection open and pushes one event per change:
card.created, card.updated, card.moved, card.archived, card.unarchived, card.deleted, comment.added, project.updated.
Each data payload has the same shape; clients re-fetch what they need:
{ "type": "card.moved", "projectId": "…", "cardId": "…", "number": 42, "actor": "claude-code", "actorRef": "key:abc", "at": "…" }
actorRef is the authenticated principal behind the change (user:<id> / key:<id>), or null when the actor came only from the X-Actor header. A : ping comment is sent every 25 seconds to keep the connection healthy.
Webhooks
Webhooks push every board event to your URL as a signed POST — that is how an external agent gets triggered when a card enters a column. They are owner-only and managed through the REST API: POST /api/webhooks (url, events, projectId?), with the secret shown once. Omit projectId to receive events from every project.
Subscribe to any subset of the realtime events above, or ["*"] for all. The payload is the same shape as SSE events.
Delivery headers
Every delivery is a POST with a JSON body and these headers:
X-Batuta-Event: <type>X-Batuta-Delivery: <delivery id>X-Batuta-Timestamp: <unix seconds>X-Batuta-Signature: sha256=<hex>— HMAC-SHA256 of<timestamp>.<body>using your secret
Verify the signature before trusting a delivery (read the raw body, don’t rely on a parsed object):
import crypto from "node:crypto";
const ts = req.headers["x-batuta-timestamp"];
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
const got = req.headers["x-batuta-signature"];
const ok = got.length === expected.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
Retries and testing
Delivery is asynchronous with retries 1 min, 5 min and 30 min after each failure; after the 4th failed attempt the delivery is marked FAILED. Inspect outcomes with GET /api/webhooks/:id/deliveries, and send a webhook.test event immediately with POST /api/webhooks/:id/test. Respond 2xx to acknowledge a delivery.