# 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](alert-webhooks.md) 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

```json
{
  "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.

```ts
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.
