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.