Developer webhooks
On this page
Register an endpoint #
Open Developer › Webhooks and choose New endpoint. You need to be an Editor or the Owner. Give the address, choose one site or, if you are the Owner, the whole workspace, and tick the events you want. Copy the signing secret. It starts whsec_and is shown once.Press Send a test to send a testevent. It is sent at once, once, and the result is shown. It is not retried, and is limited to one every 10 seconds per endpoint and 10 an hour per person.
The address must be https, on port 443, at most 2,048 characters, and resolve to a public address. Private and loopback addresses are refused, both when you save it and again on every delivery.Redirects are not followed. A 3xx answer counts as a failure. An endpoint receives only the events it ticked. A site-bound endpoint receives that site’s events only; a whole-workspace endpoint receives all. The person who made it must still be the Owner, or an Editor with access to the site.
What arrives #
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Brenzuri-Webhooks/1.0 |
X-Brenzuri-Event | job.done. |
X-Brenzuri-Delivery | |
X-Brenzuri-Timestamp | |
X-Brenzuri-Signature |
data.
{
"createdAt": "2026-10-06T12:31:41Z",
"data": {
"articleId": "9f3b1c2a-6d4e-4b7a-8c15-2e0a7d91b3f4",
"credits": 1,
"jobId": "c8a41e07-52b9-4d36-8f1a-0b7e9d3c6a25"
},
"id": "evt_5f7b6a64-1c1e-4a0b-9d2a-3c8a8e0f1a11",
"siteId": "3b6f0c52-6a41-4d0e-9a53-71c8f2d5a1e0",
"type": "job.done",
"workspaceId": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"
}| Field | Meaning |
|---|---|
id | evt_ and a UUID. The same for every attempt, so it is the key to deduplicate on. |
type | |
createdAt | |
workspaceId | |
siteId | |
data |
Payload explorer
The exact body a developer webhook receives, with keys in the order the engine writes them (sorted). Hover or focus a field to read what it means. Long strings are shortened on screen. The values are made up; the shape is checked against the engine data types.
- {
- },
- }
Hover or focus a field to see what it is.
Verify the signature #
<timestamp>.<raw body>, where <timestamp> is the value of X-Brenzuri-Timestamp and the key is the signing secret, the whole string including `whsec_`, as its UTF-8 bytes.
Refuse to run without a secret. If the setting is missing or empty, an empty key would let anyone forge a valid signature, so stop at startup or answer 500. Read the raw body. Do not parse it and serialise it again: the bytes you hash must be the bytes that were sent. Require the signature header to be exactly 64 lowercase hex characters before you compare anything. Reject a timestamp that is not a number or is more than 5 minutes from your clock. Brenzuri does not enforce a window; the 300 seconds here is a choice we recommend, and it stops an old request from being replayed. Compute the HMAC and compare it to the header in constant time. Only then parse the JSON.
Signature checker
Paste a secret, the X-Brenzuri-Timestamp header, the raw request body and the X-Brenzuri-Signature header. The HMAC-SHA256 is computed in this browser with WebCrypto. Nothing leaves this page: there is no request, no logging and no storage.
Fill in the secret, the timestamp and the body.
Receivers #
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.BRENZURI_WEBHOOK_SECRET;
if (!SECRET) {
console.error('BRENZURI_WEBHOOK_SECRET is not set; refusing to start');
process.exit(1);
}
const TOLERANCE_SECONDS = 300;
const HEX64 = /^[0-9a-f]{64}$/;
const seen = new Set();
const app = express();
app.post('/hooks/brenzuri', express.raw({ type: 'application/json', limit: '2mb' }), (req, res) => {
const body = req.body.toString('utf8');
const timestamp = req.get('X-Brenzuri-Timestamp') ?? '';
const given = req.get('X-Brenzuri-Signature') ?? '';
if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
return res.sendStatus(400);
}
if (!HEX64.test(given)) return res.sendStatus(401);
const expected = createHmac('sha256', SECRET).update(`${timestamp}.${body}`).digest('hex');
if (!timingSafeEqual(Buffer.from(expected), Buffer.from(given))) {
return res.sendStatus(401);
}
const event = JSON.parse(body);
if (seen.has(event.id)) return res.sendStatus(204);
seen.add(event.id);
switch (event.type) {
case 'job.done':
console.log('article ready', event.data.articleId);
break;
case 'article.approved':
console.log('approved by', event.data.approvedBy, 'version', event.data.version);
break;
default:
console.log('event', event.type);
}
res.sendStatus(204);
});
app.listen(3000);SECRET="whsec_local_test"
TS=$(date +%s)
BODY='{"createdAt":"2026-10-06T12:00:00Z","data":{"test":true},"id":"evt_0b6c1d2e-3f4a-4b5c-8d6e-7f8091a2b3c4","type":"test","workspaceId":"0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"}'
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -i -X POST "http://localhost:3000/hooks/brenzuri" \
-H "Content-Type: application/json" \
-H "X-Brenzuri-Timestamp: $TS" \
-H "X-Brenzuri-Signature: $SIG" \
-H "X-Brenzuri-Event: test" \
-d "$BODY"Answer #
Any 2xx is success. Answer quickly: Brenzuri waits 20 seconds and reads at most 4,096 bytes of your answer. Anything else is a failure and is retried, including 4xx. If your endpoint is wrong, a 4xx will be retried too, so fix the endpoint rather than waiting. Do the work after you answer, or hand it to a queue. A slow handler looks like a failure.
Retries #
| Retry | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
The body and idare the same on every attempt; the timestamp and signature are new.There is no manual redeliver in the interface, apart from pressing republish on an approved article, which raises article.approvedagain.The last three deliveries of each endpoint are shown in Developer › Webhooks. The log is kept for 30 days and the payload is blanked after 7. Deliveries are made in batches: at most 20 at a time and 5 per workspace per cycle, so a burst is spread out.
Rotate the secret #
Tolerating duplicates #
id for each endpoint. Order is not guaranteed across events either: a job.done can arrive after an article.approved if the first was retried.