BrenzuriStart free
Developer webhooks
Guides

Developer webhooks

Brenzuri calls your https endpoint. You verify the signature, answer 2xx, and keep the delivery id.

On this page

A developer webhook calls your server when something happens in your workspace: a job finished, an article was approved, an agent ran out of budget. It is the alternative to polling.

Register an endpoint#

  1. Open Developer › Webhooks and choose New endpoint. You need to be an Editor or the Owner.
  2. Give the address, choose one site or, if you are the Owner, the whole workspace, and tick the events you want.
  3. Copy the signing secret. It starts whsec_ and is shown once.
  4. Press Send a test to send a test event. 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#

HeaderValue
Content-Typeapplication/json
User-AgentBrenzuri-Webhooks/1.0
X-Brenzuri-EventThe event name, for example job.done.
X-Brenzuri-DeliveryThe id of this delivery. It stays the same across retries of the same delivery.
X-Brenzuri-TimestampUnix seconds when this attempt was sent. It changes on every attempt.
X-Brenzuri-SignatureLower-case hex HMAC-SHA256, described below.

The body is JSON with sorted keys. Every event has the same envelope and an event-specific data.

JSON
{
  "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"
}
FieldMeaning
idevt_ and a UUID. The same for every attempt, so it is the key to deduplicate on.
typeThe event name.
createdAtWhen the event was raised.
workspaceIdYour workspace.
siteIdThe site the event is about, when it has one.
dataThe fields in Webhook events.

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.

POSTX-Brenzuri-Event: job.done
  1. {
  2. },
  3. }

Hover or focus a field to see what it is.

Verify the signature#

Anyone can send a request to your address, so verify every one. The signature is a lower-case hexadecimal HMAC-SHA256 of the string <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.

  1. 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.
  2. Read the raw body. Do not parse it and serialise it again: the bytes you hash must be the bytes that were sent.
  3. Require the signature header to be exactly 64 lowercase hex characters before you compare anything.
  4. 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.
  5. Compute the HMAC and compare it to the header in constant time.
  6. 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#

Each of these verifies the signature in constant time, checks the timestamp, ignores an event it has already seen, and answers 204. The set of seen ids is held in memory or in a file to keep the example short; use your database.

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);

Test it without Brenzuri by signing a request yourself. The test secret is the one in the first line.

Shell
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#

A failed delivery is attempted again after the intervals below, 12 retries in all, counted from the previous attempt. After the last one it is marked failed, “gave up after 24 h”.

Retry123456789101112
After (minutes)1410153060120180240240270270
  • The body and id are 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.approved again.
  • 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#

Rotate secret, on an endpoint in Developer › Webhooks, returns a new secret once, and the old one stops working immediately. There is no overlap window.

Because failed deliveries are retried, rotation does not lose events if you are quick: deploy the new secret and the next retry, which comes within minutes, verifies. To avoid even that, make your receiver accept a list of secrets, rotate, deploy the new list, then drop the old secret.

Tolerating duplicates#

Delivery is at least once. A retry after a timeout can arrive after your first attempt succeeded. Deduplicate on 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.