Alert webhooks
A webhook channel is a URL Bucker POSTs an alert to, signed with a shared secret you hold. This page is the receiver's side of that contract: what arrives, what every field means, and how to prove the bytes came from us.
The header grammar has been documented for a while; the body had not been, which is
why this page exists. A receiver written against the header alone still has to read our
source to learn what lifecycle means or whether issue can be null — and a payload
people reverse-engineer is a payload nobody can change safely.
The request
POST https://your-endpoint.example.com/bucker
content-type: application/json
x-bucker-signature: t=1757876651,v1=9f0c…
x-bucker-delivery: clx0delivery00001
Any extra headers configured on the channel are sent too, except that a configured
header can never overwrite the two above. content-type, host, content-length and
transfer-encoding are reserved the same way — configuring one of them (any case) is
refused when the channel is saved — because each either names this request's own
framing or is computed for you. authorization is not reserved; use it freely.
Answer 2xx and we are done. A 4xx other than 408 and 429 is read as "you
rejected this payload on purpose" and the delivery is marked permanently failed — it is
not retried. Anything else, including a timeout, is retried on a backoff from 30 seconds
to 30 minutes; after that the delivery is marked DEAD and kept, so
GET …/alert-events can still answer "did the page actually go out?".
x-bucker-delivery is the id of the AlertEvent row behind the attempt, and a retry
re-attempts that same row — so the delivery id is stable across retries and is the
idempotency key you want. The same value is in the body as deliveryId, so a receiver
that queues the parsed body does not have to carry the headers along beside it.
The body
The envelope is two fields: the delivery id, and the alert itself.
| Field | Type | Meaning |
|---|---|---|
deliveryId |
string | Same value as x-bucker-delivery. Stable across retries |
alert |
object | The notification, described below |
Everything else in this section is inside alert.
The alert object
| Field | Type | Meaning |
|---|---|---|
version |
"1" |
Payload version. See below — it is not a "reject if unknown" field |
ruleId |
string | The alert rule that fired |
ruleName |
string | Its name, as the customer typed it |
ruleType |
"issue" | "metric" |
Which of issue / metric below is populated |
orgSlug |
string | Organization the rule belongs to |
projectSlug |
string | Project the rule belongs to |
reason |
string | Why it fired, in words. We computed this, so it is safe to render |
occurredAt |
string | ISO 8601 UTC instant the rule fired |
deepLink |
string | Absolute URL to the issue or the rule in the dashboard |
suppressedCount |
integer | Occurrences the cooldown swallowed since the previous alert for this rule |
transition |
boolean | True when this alert punched through an active cooldown |
lifecycle |
string | Optional, often absent. The lifecycle transition that fired the alert. See below |
issue |
object | null | Present for ruleType: "issue", null otherwise |
metric |
object | null | Present for ruleType: "metric", null otherwise |
alert.issue
Null on a metric alert. On an issue alert:
| Field | Type | Meaning |
|---|---|---|
id |
string | Internal issue id — what the REST API and the MCP tools take |
shortId |
string | The human reference, e.g. STOREFRONT-4T |
status |
string | NEW, ONGOING, RESOLVED, ARCHIVED or REGRESSED |
level |
string | debug, info, warning, error or fatal |
title |
object | Labelled text — the exception message. Untrusted; see below |
culprit |
object | Labelled text — where it came from. Untrusted; may be an empty string |
blastRadius |
object | How far it has spread; fields below |
actionability |
number | 0..1, our transparent heuristic for "is this worth acting on" |
firstSeen |
string | ISO 8601 UTC |
lastSeen |
string | ISO 8601 UTC |
essenceTokens |
integer | null | Size of the published error essence, or null if none is cached yet |
alert.issue.blastRadius
| Field | Type | Meaning |
|---|---|---|
events |
integer | Events in this issue |
users |
integer | Distinct users affected |
environments |
string[] | Environment names. Reported by the SDK, so untrusted |
alert.metric
Null on an issue alert. On a metric alert:
| Field | Type | Meaning |
|---|---|---|
aggregate |
string | event_count, user_count or issue_count |
windowMinutes |
integer | Length of the window the aggregate was measured over |
comparator |
string | gt, gte, lt, lte or eq |
threshold |
number | The configured threshold |
value |
number | What was actually observed |
Three fields that decide how you write the receiver
version — widen, do not reject
version is "1" and exists so that a payload stored today stays readable when this
document grows. Treat an unknown version as "parse what you recognise and carry on",
not as a reason to answer 4xx: a 4xx is permanent here, so a receiver that refuses
version "2" throws away the one alert it most wanted, and it does so at exactly the
moment nobody is watching your endpoint.
What we promise in exchange is that a bump is reserved for a change that would break a reader who ignored it. Adding a field does not bump the version — so ignore fields you do not know rather than validating the object closed.
lifecycle — branch on its presence, not on issue.status
lifecycle is present only when a state change fired the alert, and it is omitted
rather than set to null on every other alert. That is deliberate: a stored payload
written before the field existed is byte-identical to one written after it, so version
could stay "1".
Its values are resolved, unresolved, archived, unarchived, assigned and
unassigned. Branch on whether the key is there:
if ('lifecycle' in alert) {
// someone (or something) changed the issue's state
}
issue.status cannot stand in for it. assigned and unassigned do not change the
status at all, so a receiver reading the status sees an identical issue twice and
concludes nothing happened.
title, culprit and environments are attacker-controlled
An exception message is written by whatever threw it, and anyone who can make your application throw can choose those characters. So the two text fields arrive as an object rather than a string, with the label attached:
{ "value": "TypeError: Cannot read properties of undefined", "trust": "untrusted" }
trust is untrusted for text derived from telemetry. If our injection scanner matched
the text, trust is quarantined instead, value is
[redacted: flagged as possible prompt injection], and a third field flagged names the
rule that fired.
We have already flattened line breaks and dropped control characters, so no value can
forge a second field in whatever you render it into, and title and culprit are capped
at 300 characters (environments entries at 60). What we cannot do from here is decide
what your side does with it. The rule that matters: if this payload reaches an LLM,
untrusted content is data and never instructions. Same rule as the error essence, for
the same reason.
Verifying the signature
Every delivery carries:
x-bucker-signature: t=<unix seconds>,v1=<hex hmac-sha256 of `${t}.${rawBody}`>
The timestamp is inside the MAC, so a captured delivery cannot be re-dated and
replayed at your endpoint later. That only holds if you actually check t:
- Reject a
toutside a tolerance window. 300 seconds is what our own reference verifier uses and what we suggest; it is the width of the replay window you are accepting, so the number is a trade against clock skew rather than a formality. Much tighter and an ordinary NTP correction starts rejecting real alerts; much wider and a captured request stays replayable for as long as you allow. - Take the FIRST
t=if the value somehow carries two. Last-wins would let anyone who can append to the header shadow the timestamp we signed. - Sign over the raw request bytes, not a re-serialized parse.
JSON.parsefollowed byJSON.stringifywill not reproduce them. - Compare in constant time.
===on a hex digest returns as soon as it finds a differing byte, which tells an attacker how many leading bytes they have right and turns forging the digest into a few thousand requests rather than a search.
A future algorithm arrives as an additional v2= beside v1= in the same header, so a
verifier that keeps reading v1 keeps working. Ignore keys you do not recognise.
import { createHmac, timingSafeEqual } from 'node:crypto'
const TOLERANCE_SECONDS = 300
export function verifyBuckerSignature(rawBody, header, secret) {
const parts = new Map()
for (const part of String(header ?? '').split(',')) {
const separator = part.indexOf('=')
if (separator <= 0) continue
const key = part.slice(0, separator).trim()
const value = part.slice(separator + 1).trim()
// First occurrence wins.
if (key === '' || value === '' || parts.has(key)) continue
parts.set(key, value)
}
const t = parts.get('t')
const v1 = parts.get('v1')
if (!t || !v1 || !/^\d{1,15}$/.test(t)) return false
const nowSeconds = Math.floor(Date.now() / 1000)
if (Math.abs(nowSeconds - Number(t)) > TOLERANCE_SECONDS) return false
const expected = Buffer.from(
createHmac('sha256', secret).update(`${t}.${rawBody}`, 'utf8').digest('hex'),
'utf8',
)
const actual = Buffer.from(v1, 'utf8')
// timingSafeEqual throws on a length mismatch, so the length check comes first.
return expected.length === actual.length && timingSafeEqual(expected, actual)
}
In Express, rawBody is what express.raw({ type: 'application/json' }) gives you —
verify first, parse second. The secret is shown once, when the channel is created, and is
never returned again.
Examples
Real bodies, pretty-printed. On the wire they are a single line with no whitespace, and that line is what the signature covers.
ruleType: "issue"
{
"deliveryId": "clx0delivery00001",
"alert": {
"version": "1",
"ruleId": "clx0rule0issue01",
"ruleName": "Checkout errors",
"ruleType": "issue",
"orgSlug": "acme",
"projectSlug": "storefront",
"reason": "A new issue was seen 14 times in 5 minutes",
"occurredAt": "2026-09-14T19:04:11.000Z",
"deepLink": "https://app.bucker.io/acme/storefront/issues/STOREFRONT-4T",
"suppressedCount": 3,
"transition": true,
"lifecycle": "unresolved",
"issue": {
"id": "clx0issue000001",
"shortId": "STOREFRONT-4T",
"status": "REGRESSED",
"level": "error",
"title": {
"value": "TypeError: Cannot read properties of undefined (reading 'total')",
"trust": "untrusted"
},
"culprit": {
"value": "src/checkout/summary.tsx in renderTotal",
"trust": "untrusted"
},
"blastRadius": {
"events": 142,
"users": 37,
"environments": [
"production",
"staging"
]
},
"actionability": 0.82,
"firstSeen": "2026-09-14T18:41:02.000Z",
"lastSeen": "2026-09-14T19:03:58.000Z",
"essenceTokens": 412
},
"metric": null
}
}
ruleType: "metric"
{
"deliveryId": "clx0delivery00002",
"alert": {
"version": "1",
"ruleId": "clx0rule0metric1",
"ruleName": "Event volume spike",
"ruleType": "metric",
"orgSlug": "acme",
"projectSlug": "storefront",
"reason": "event_count over 15m is 5120, above the threshold of 2000",
"occurredAt": "2026-09-14T19:10:00.000Z",
"deepLink": "https://app.bucker.io/acme/storefront/alerts/clx0rule0metric1",
"suppressedCount": 0,
"transition": false,
"issue": null,
"metric": {
"aggregate": "event_count",
"windowMinutes": 15,
"comparator": "gt",
"threshold": 2000,
"value": 5120
}
}
}
Both were produced by calling the builder that produces the real thing, not written by
hand — a hand-written example is the one that drifts. The four field tables above are
checked against that builder's output on every run by
api/test/alerts/webhook-payload-doc.test.ts: a field added to the payload without a row
here, or a row here naming a field that no longer exists, fails the suite.
Where this is defined
domain/src/modules/alerts/notification.ts—AlertNotification, the builder, andrenderWebhookBodydomain/src/modules/alerts/channels/webhook.ts— the normative definition ofX-Bucker-Signature, includingverifyWebhookSignature, the reference verifier the snippet above mirrors
Other Bucker webhooks — status-page subscribers, rollout providers, the audit export sink, proof-ledger witnesses — carry the same signature grammar under the same header name, with their own bodies. witness-onboarding.md §3 covers the witness case.