Brua

Webhooks

Signed, retried notifications when versions are ready, change sets move and releases go out.

Your services can hear about a project's changes as they happen: a POST to an https endpoint of yours, signed with a secret only you and Brua know, and retried for about ten hours if your receiver doesn't answer. A project's admins add endpoints under Settings → Webhooks in the back office, pick the events each one takes, and get the secret once.

Apps that read the edge need none of this: the versionless URL follows every merge on its own. Webhooks are for the systems around the apps, such as a cache to purge, a build to start or a channel to tell.

What is sent

Event When data
asset.version.created A version was created and waits for its files asset, version, preset
version.ready Its files are processed and it can be reviewed asset, version, width, height
change_set.submitted A change set was sent for review changeSet, name, changes
change_set.merged A change set was merged into prod changeSet, name, release
release.published Prod changed, by a merge or a restore release, note, changes
asset.deleted An asset was deleted asset, versions
ping Someone pressed Send test endpoint

changes is a list of { asset, version }: for release.published, the assets whose live version the release changed, with version null for one taken out of prod. It is the event most services want, since it says prod changed and which ids are affected, so a cache or a build can react to exactly those.

The envelope

release.published
{
  "id": "0193c2b8-5e1f-7a3e-9c1d-2f8f0b6c4d10",
  "event": "release.published",
  "createdAt": "2026-11-03T07:54:12.481Z",
  "project": "demo",
  "data": {
    "release": 12,
    "note": "Merged change set \"Autumn campaign\"",
    "changes": [
      { "asset": "hero-autumn", "version": 3 },
      { "asset": "promo-summer", "version": null }
    ]
  }
}

id is the delivery's id, the same across retries, so a receiver that keeps the ids it has seen can drop a repeat. project is the project's id, as in its URLs.

Headers

Header
Brua-Event The event name
Brua-Delivery The delivery's id, the same as id in the body
Brua-Signature t=<unix seconds>,v1=<hex HMAC-SHA256>
User-Agent Brua-Webhooks/1
Content-Type application/json; charset=utf-8

Verifying the signature

The signature is an HMAC-SHA256 of {t}.{body} under the endpoint's secret, where body is the raw request body, byte for byte. Read the raw body before anything parses it, compare in constant time, and refuse a t more than five minutes from your clock, so a captured delivery can't be played back later.

Node / TypeScript
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret: string, signature: string, rawBody: string): boolean {
  const parts = Object.fromEntries(signature.split(",").map((part) => part.split("=") as [string, string]));
  const t = Number(parts.t);
  if (!/^[0-9a-f]{64}$/.test(parts.v1 ?? "") || !Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  return timingSafeEqual(expected, Buffer.from(parts.v1, "hex"));
}

In an Express app, keep the raw body with express.raw({ type: "application/json" }) on the webhook route; a body that was parsed and serialized again won't match.

Retries

A delivery is done when your receiver answers any 2xx within 10 seconds. Anything else, a redirect included, is a failed attempt, and the same delivery is posted again after 1, 5, 30, 120 and 480 minutes, six attempts over about ten and a half hours, before it is marked failed. An endpoint that is switched off gets no retries: its pending deliveries are marked failed. Every attempt and its answer is listed under the endpoint in Settings → Webhooks, so a receiver that was down can be checked afterwards. Answer first and do the work after: a receiver that takes longer than the timeout is retried, and sees the same delivery twice.

Secrets

The secret is shown once, when the endpoint is added. Rotate it from Settings when it leaks or changes hands: the new one signs every post from then on and the old one stops at once, so update the receiver right after. An endpoint can be switched off without losing its secret.

Next

  • Lifecycle: the states these events follow.
  • Delivery API: reading what a release contains once you've heard about it.