Skip to main content

Outbound webhooks

Jentrix can POST board events to your HTTPS endpoint — the push half of the agent platform (the pull half is the MCP server). The bundled bug-triage reference agent is a complete working receiver.

Creating a webhook

Workspace Settings → Connections → Webhooks (workspace ADMIN role), or via admin-scoped MCP tools (create_webhook, list_webhooks, delete_webhook, list_webhook_deliveries). A webhook subscribes to one or more events on the workspace's boards; creating one returns a secret used to sign every delivery — store it, it's not shown again.

Events

A webhook is workspace-scoped, so it can subscribe to two families: board events and workspace events. An empty events: [] (or omitting it) delivers all events of both families; otherwise only the named events are delivered.

Board events:

task.created      task.updated      task.moved        task.archived
column.created    column.updated    column.moved      column.archived
board.renamed     board.kind_changed
label.created     label.updated     label.deleted
comment.created   comment.updated   comment.deleted
attachment.created                  attachment.removed
deal.updated      bug.updated       ticket.updated    initiative.updated
task.due_soon     ticket.sla_breached        # system-generated (cron sweep)
automation.fired                             # from an automation's "fire webhook" action

Workspace events — the harness (coding-harness lifecycle, see agent workflows) family is the headline:

harness.created          harness.updated
harness.spec_validated   harness.plan_validated
harness.run_started      harness.stage_ready      harness.stage_started
harness.stage_validated  harness.stage_committed
harness.finding_created  harness.frozen           harness.unfrozen
harness.blocked          harness.completed        harness.stuck

Other workspace-scoped events (the Agentic OS / delivery layer — e.g. project.*, agent_job.*, release.*, control_tower.item_added) are also subscribable; they fan out through the same workspace channel. Subscribe to the exact names you need, or [] for everything.

Harness deliveries carry compact identifiers plus the workspace id (e.g. harness.stage_committed{ workspaceId, harnessId, stageId }, harness.finding_created{ workspaceId, harnessId, findingId, severity }), plus a fetch hint naming the MCP read tool — never large prompt bodies, diffs, or review text. Fetch the full context over MCP (get_harness, list_harness_findings, …). A receiver that only cares about, say, critical findings can filter on the delivered severity without an extra read.

Delivery format

POST /your/endpoint HTTP/1.1
Content-Type: application/json
X-Stacks-Event: task.created
X-Stacks-Delivery-Id: <unique per delivery>
X-Stacks-Timestamp: <unix epoch seconds>
X-Stacks-Signature: sha256=<hex hmac>

{ "id": "...", "event": "task.created", "createdAt": "...", "data": { ... } }

data carries identifiers (e.g. task.created{ boardId, workspaceId, taskId }; harness events carry workspaceId + harness ids, no boardId), not full entities — fetch what you need through MCP tools. This keeps payloads stable and means a leaked delivery exposes ids, not content.

Verifying signatures

X-Stacks-Timestamp is a Unix timestamp in whole seconds (e.g. 1752192000), not an ISO 8601 string. The signature is an HMAC-SHA256 of the exact string "{timestamp}.{rawBody}" — that same epoch-seconds value, a literal dot, then the raw body — keyed with your webhook secret. Always verify before trusting a delivery, and reject stale timestamps to block replays:

import { createHmac, timingSafeEqual } from "node:crypto";

// Reject deliveries whose timestamp is more than 5 minutes from now.
const MAX_SKEW_SECONDS = 5 * 60;

function verify(secret, headers, rawBody) {
  const timestamp = headers["x-stacks-timestamp"] ?? "";
  // timestamp is epoch seconds — compare against the current epoch seconds.
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(timestamp)) > MAX_SKEW_SECONDS) return false;

  const expected =
    "sha256=" +
    createHmac("sha256", secret)
      .update(`${timestamp}.${rawBody}`)
      .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(headers["x-stacks-signature"] ?? "");
  return a.length === b.length && timingSafeEqual(a, b);
}

Use the raw request body bytes — re-serializing parsed JSON breaks the signature.

Retries and auto-disable

  • The first delivery attempt happens right after the triggering request finishes.
  • Failures retry with exponential backoff, up to 8 attempts (drained by a cron sweep every 5 minutes).
  • Respond fast. Deliveries time out after 5 seconds — ack with a 2xx immediately and process async. Use X-Stacks-Delivery-Id for idempotency on your side: retries redeliver the same id.
  • After 3 consecutive deliveries exhaust all retries, the webhook is automatically disabled. Re-enable by deleting and recreating it. Delivery history (pending / success / failed) is visible via list_webhook_deliveries.

Security rules (SSRF guard)

Destination URLs are validated at creation and before every delivery:

  • https:// only, no credentials in the URL
  • No localhost, private-range, or link-local destinations — checked after DNS resolution
  • Redirects are not followed

For local development against a receiver on your machine, set STACKS_WEBHOOK_ALLOW_PRIVATE=1 in dev only — never in production. To reach a local receiver from a deployed Jentrix, use a tunnel (e.g. cloudflared) that gives you a public HTTPS URL.