Skip to content
bucker

Outbound webhooks

An outbound webhook is a URL Bucker POSTs to when something happens in your organization: an issue is created, resolved, regressed or assigned; someone comments; an autofix run or proposal changes stage. You do not need an alert rule for any of it.

It is the receiver-side companion to alert webhooks and uses the same signature, the same delivery-id convention and the same retry ladder. A verifier written for one verifies the other.

Subscribing

Admin access to the organization is required for every call below.

In the dashboard, an owner manages them under Settings › Webhooks: add one (picking its events, and optionally one project), copy the signing secret from the one-time reveal, send a test ping, rotate the secret, and open a webhook's delivery log to replay any delivery. Edit changes the name, URL, events and the Send events switch (PATCH, sending only what changed; the project scope is fixed at creation). Delete asks for confirmation and cannot be undone: the secret and the delivery log go with it, so to pause deliveries turn Send events off instead.

POST /orgs/:orgSlug/webhooks
{
  "name": "pipeline",
  "url": "https://example.com/bucker",
  "events": ["issue.created", "issue.resolved", "comment.created"],
  "projectSlug": "storefront"        // optional; omit for every project
}

The response carries secret, shown once. Store it. POST …/webhooks/:id/rotate-secret replaces it and shows the new one once; the old one stops signing immediately.

Call Use
GET /orgs/:orgSlug/webhook-events The event names you can subscribe to
GET /orgs/:orgSlug/webhooks List subscriptions (no secrets)
PATCH /orgs/:orgSlug/webhooks/:id Rename, re-point, change events, enabled: false
DELETE /orgs/:orgSlug/webhooks/:id Remove it and its delivery log
POST /orgs/:orgSlug/webhooks/:id/test Send a signed ping now and see what your server answered
GET /orgs/:orgSlug/webhooks/:id/deliveries?status=DEAD The delivery log
POST /orgs/:orgSlug/webhooks/:id/deliveries/:deliveryId/replay Send a delivery again, same id and body

URLs must be public http(s) addresses; private, loopback and link-local targets are refused. An organization can hold 25 webhooks.

Events

Event Fires when
issue.created A new issue is opened by ingest
issue.resolved An issue is resolved, by a person, an agent or a linked tracker
issue.regressed A resolved issue receives a new event
issue.unresolved An issue is reopened by hand
issue.assigned, issue.unassigned The assignee changed
issue.archived An issue is ignored or archived
comment.created A comment is posted on an issue (deleted comments are not sent)
remediation.run.queued .running .succeeded .failed .cancelled An autofix run reached that stage
remediation.proposal.awaiting_approval .pr_open .approved .rejected .merged .superseded A fix proposal reached that stage

Events are derived from the records Bucker already keeps, within about fifteen seconds. Two consequences worth knowing:

  • Stage events report what was observed. A run that moves from queued to running between two checks is reported as running; each observed stage is sent once.
  • Nothing is backfilled. A new subscription receives events from the moment it was created.

The request

POST https://example.com/bucker
content-type: application/json
x-bucker-signature: t=1757876651,v1=9f0c…
x-bucker-delivery: clx0delivery00001
x-bucker-event: issue.resolved

The body

{
  "version": "1",
  "id": "clx0delivery00001",
  "event": "issue.resolved",
  "eventId": "activity:clx0activity0007",
  "occurredAt": "2026-10-17T14:03:11.402Z",
  "org": { "slug": "acme" },
  "project": { "slug": "storefront" },
  "data": {
    "issue": {
      "id": "clx0issue000001",
      "shortId": "STOREFRONT-4T",
      "title": "TypeError: x is undefined",
      "status": "RESOLVED",
      "level": "error",
      "priority": "HIGH",
      "url": "https://app.bucker.io/en/org/acme/storefront/issues/STOREFRONT-4T"
    },
    "actor": { "principalId": "clx0principal01" }
  }
}
Field Meaning
version Body version, "1". Ignore fields you do not know; new ones are additive
id Delivery id. Equals x-bucker-delivery. Stable across retries and replays: use it as your idempotency key
event One of the events above, or ping from the test endpoint
eventId The record the event came from. Stable for the event
occurredAt When it happened, ISO 8601 UTC
org, project Slugs. project is null on a ping
data Per event, below

data always carries issue for issue, comment and remediation events. issue.title and every comment body are untrusted text: telemetry or a person wrote them. Escape them before rendering and never execute them.

Event Extra data
issue.* actor (principalId, or null when the system acted)
issue.assigned, issue.unassigned assignee: { principalId, teamId }
comment.created comment: { id, authorPrincipalId, body, truncated }. body is cut at 2000 characters
remediation.run.* run: { id, status, verificationTier, failedStage }
remediation.proposal.* proposal: { id, status, verificationTier, prUrl, prNumber }

Verifying

The header is t=<unix seconds>,v1=<hex>, where the hex is HMAC-SHA256 of "<t>.<raw body>" with your secret. Verify against the raw bytes, compare in constant time, and reject a t more than five minutes from your clock.

import { createHmac, timingSafeEqual } from 'node:crypto'

export function verify(secret: string, rawBody: string, header: string): boolean {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=') as [string, string]))
  const t = Number(parts.t)
  if (!parts.v1 || !Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest()
  const actual = Buffer.from(parts.v1, 'hex')
  return actual.length === expected.length && timingSafeEqual(actual, expected)
}

Retries

Answer 2xx and the delivery is done. A 4xx other than 408 and 429 means you refused the payload on purpose: the delivery is marked FAILED and not retried. Anything else, including a timeout, is retried on a backoff that doubles from 30 seconds to 30 minutes, for eight attempts in all; then the delivery is DEAD. FAILED and DEAD deliveries stay in the log so you can see what your server said (its status code, never its response body) and replay them once it is fixed. Disabling a subscription stops all further attempts, including queued retries.