# Webhooks

> Eleven events, a signed envelope, and how to verify a delivery in constant time.

An organisation can register endpoints that receive a POST when something
happens. Managing them needs `webhook.manage`.

## Events

Subscribe to any subset. An empty selection means every event.

| Event | Fires when |
| --- | --- |
| `blueprint.published` | A blueprint goes draft → published, or a draft sibling is merged into its published parent |
| `blueprint.unpublished` | A blueprint is deprecated, set back to draft, or hard-deleted |
| `blueprint.visibility.changed` | Visibility changes via the Share dialog or the wizard |
| `folium.published` | A folium version is published: a frozen snapshot of the whole tree |
| `folium.page.updated` | A page's content or metadata is saved, in the live working set |
| `folium.page.created` | A page is added to a folium |
| `workflow.run.submitted` | An author hands a draft into an approval workflow |
| `workflow.run.approved` | The last step of a run is approved |
| `workflow.run.rejected` | An approver rejects at any step |
| `member.added` | Someone joins the org, by invite or direct add |
| `member.removed` | Someone is removed, or leaves |

## The request

```http
POST https://your-endpoint.example.com/blueprintr
Content-Type: application/json
User-Agent: Blueprintr-Webhooks/1
X-Blueprintr-Event: folium.page.updated
X-Blueprintr-Delivery: cmtp0q7uv0002mohxnqod3vkb
X-Blueprintr-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015…
```

```json
{
  "id": "cmtp0q7uv0002mohxnqod3vkb",
  "event": "folium.page.updated",
  "orgId": "cmrdzv7ts0001fnywvijnxgaz",
  "occurredAt": "2026-09-05T23:04:11.882Z",
  "payload": { }
}
```

`id` is the delivery id and doubles as your dedupe key. It is also in the
`X-Blueprintr-Delivery` header, so you can dedupe before parsing the body.

## Verifying

`X-Blueprintr-Signature` is `sha256=` followed by the hex HMAC-SHA256 of the
**exact bytes of the request body**, keyed with the endpoint's secret. Read
the raw body. A framework that parses and re-serialises JSON for you will
change key order or whitespace, and every signature will fail.

```js
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(rawBody, header, secret) {
  const expected =
    "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(header ?? "");
  return a.length === b.length && timingSafeEqual(a, b);
}
```

Compare in constant time. A plain `===` leaks the correct signature one byte at
a time to anyone who can measure your response latency.

> [!IMPORTANT]
> An unverified endpoint is an unauthenticated write path into whatever it
> triggers. Verify on every request and reject what fails. Do not log and
> continue.

## Provider shapes

An endpoint has a provider mode, and it changes the body on the wire:

| Provider | Body |
| --- | --- |
| `generic` | The envelope above |
| `slack` | A Slack incoming-webhook payload: `{ text, blocks }` |
| `discord` | A Discord webhook payload: `{ content, embeds }` |
| `msteams` | A Teams MessageCard |

The signature is computed over what is sent, so a Slack-shaped delivery is
signed over the Slack JSON, not over the envelope. Point a `slack`
endpoint at a Slack incoming-webhook URL and it works with no receiver of your
own.

## Endpoint requirements

The URL must be HTTPS on a public host. Private, link-local and metadata
addresses are refused, and the connection is pinned to the address that was
validated, so a hostname that resolves to something public during the check and
to something internal a moment later still cannot be reached.

| | |
| --- | ---: |
| Request timeout | 5 seconds |
| Response body read | first 1 KB, then discarded |

Return a 2xx quickly and do the work afterwards. A slow endpoint is a failed
delivery.

## There are no retries

> [!IMPORTANT]
> A failed delivery is recorded and **not** retried. There is no backoff and no
> redelivery queue, so if your endpoint was down, that event is gone.

Treat webhooks as a low-latency hint that something changed, and reconcile
against the [REST API](/foliums/blueprintr-user-guide/developers/rest-api) for
anything you cannot afford to miss. Ordering is not guaranteed either, so make
handlers idempotent on `id`.

Every attempt, succeeded or failed, is recorded with its response status and
error.

## Testing

**Send test** on the webhook posts a real signed delivery to the endpoint and
records the result in the same log. Use it to prove the signature check before
subscribing.

## Rotating a secret

Rotation is a **hard cutover**. The new secret signs the next delivery, and
every receiver still verifying with the old one starts failing immediately.

> [!STEPS]
>
> === Teach the receiver both secrets
>
> Deploy it accepting either the current secret or an as-yet-unset second one,
> before you rotate.
>
> === Rotate
>
> From the webhook's settings. You will be asked to reauthenticate: rotation
> hands out a live signing secret and breaks every receiver, so a stolen
> session cannot do it alone. Limited to five rotations an hour.
>
> === Drop the old secret
>
> Once a delivery has verified against the new one.
