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.