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