Skip to content
bucker

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 t outside 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.parse followed by JSON.stringify will 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, and renderWebhookBody
  • domain/src/modules/alerts/channels/webhook.ts — the normative definition of X-Bucker-Signature, including verifyWebhookSignature, 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.