Deliver to a custom site
On this page
Set it up #
Build the receiving endpoint below, on https, reachable from the internet. In Brenzuri open Connections, choose New connection, pick Webhook, name it, and enter the endpoint address. Leave the secret empty and Brenzuri makes one for you, shown once after saving; or enter your own. Press Test connection. It sends a signed ping; any 2xx answer is a pass. Attach the connection to a site from the site’s Setup. A connection serves one site and a site has one connection.
The address must be https, resolve to a public address, and is checked again on every delivery. Private and loopback addresses are refused. A secret Brenzuri makes starts whsec_. A secret you type is used exactly as typed, apart from whitespace and line breaks at either end, which are removed. Either way the whole secret, anywhsec_included, is the signing key.The two policies, Draft and Awaiting approval, differ in one thing for you, and only for a delivery a person starts by hand: statussaysdrafton Draft andawaiting_approvalon Awaiting approval. Every delivery that follows an approval in Brenzuri, every delivery of a Beat, and everything a key or an agent sends hasstatusdraft, whichever policy the connection has. A retry uses the connection’s policy at the time of the retry (site deliveries are always drafts).
When you receive one #
A person approves an article of the site in Brenzuri. The approved version is delivered at once, as a draft, once per version. A Beat writes an article for the site. It is delivered unapproved, as a draft. Someone delivers by hand, from the article page or with POST /articles/{id}/deliver. A person getsdraft, orawaiting_approvalon a connection set to Awaiting approval. A key or an agent can do this too and always getsdraft.A workspace rule can stop a delivery before it is made, for example when it requires approval and the version is not approved. The delivery log in Brenzuri then says so.
The request #
| Header | Value |
|---|---|
Content-Type | application/json. |
X-Brenzuri-Event | article.delivered, on every delivery. |
X-Brenzuri-Delivery | deliveryId; the header is not signed, so read the body. |
X-Brenzuri-Timestamp | |
X-Brenzuri-Signature | <timestamp>.<raw body>, keyed with the whole connection secret, as UTF-8 bytes. A secret Brenzuri makes starts whsec_, and that prefix is part of the key. It covers the whole body, illustration included. |
User-Agent. The body is JSON with sorted keys. Use the tabs to see a delivery of an approved article with its picture, one not yet approved, a rewrite, and a language version.
Payload explorer
The body a custom site receives when an article is delivered, 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 keys are checked against the engine.
- {
- },
- {
- },
- {
- },
- {
- }
- ],
- {
- },
- {
- },
- {
- }
- ],
- },
- {
- },
- {
- }
- ],
- }
Hover or focus a field to see what it is.
Body #
| Field | Meaning |
|---|---|
event | article.exported. It is the name this body had before the header X-Brenzuri-Event existed and it has not changed. Do not branch on it; the header says article.delivered. |
title | <h1> at the top of html. |
status | draft or awaiting_approval. Brenzuri never sends publish. A delivery that follows an approval in Brenzuri, a delivery made by a key, an agent or a Beat, and one from a connection set to Draft are always draft, and so is a language version of an article a Beat wrote. awaiting_approval is sent only when a person delivers by hand through a connection set to Awaiting approval, or retries such a delivery. Your site decides what to publish. |
html | <h1>, the text, and an <aside class="bz-sources"> with the sources and the line “Generated with sources · reviewed by a human: yes or no”. Every claim a person kept a flag on has <sup>†</sup> after it. Text is escaped. |
deliveryId | X-Brenzuri-Delivery. Unlike the header, it is inside the signed body, so it cannot be changed without breaking the signature. Deduplicate on this one. |
articleId | |
version | articleId with version identifies what you received. |
deck | |
words | |
humanApproved | approval then still names the approval. |
blocks | kind: paragraph (text), heading (level, text), list (ordered, items) or table (headers, rows). Text may contain **bold** markers. |
claims | text, state, the outlets that carry it and block, the index into blocks where it sits. |
sources | outlet, title, url, score and official. |
illustration | |
supersedes | |
language | es; bhs for Bosnian/Croatian/Serbian. Only present when the article has language versions or is one. See Language versions. |
primaryArticleId | |
approval | { by, version, at }, present when the version has an approval. |
blog |
claims #
| Field | Meaning |
|---|---|
text | |
state | corroborated, singleSource, circular, disputed or parallel. |
outlets | |
block | blocks. |
sources #
| Field | Meaning |
|---|---|
outlet | |
title | |
url | |
score | |
official |
approval #
| Field | Meaning |
|---|---|
by | |
version | |
at |
illustration #
| Field | Meaning |
|---|---|
mime | image/png, image/jpeg or image/webp. |
base64 | tooLarge is true. |
sha256 | |
width | |
height | |
altText | |
caption | |
label | AI-generated. Show it with the picture. |
styleLabel | House style or Abstract editorial. |
tooLarge | base64, when the encoded image would be over 6,000,000 characters. Pictures are at most 4 MB, so this does not happen today. Handle it anyway: keep the article and ask for nothing else. |
Decode base64, check the SHA-256 againstsha256, store the file, and showlabelwith it. The picture is always an AI-generated illustration and is labelled so.A body with a picture is large: a 4 MB image is about 5.4 MB of base64. Accept bodies of at least 8 MB on the endpoint and in the web server in front of it (nginx client_max_body_size, PHPpost_max_sizeandmemory_limit, Express and Flask limits).An article with no picture has no illustrationkey at all, and so does a delivery whose picture could not be read.
Check the signature #
<timestamp>.<raw body> with the whole secret as the key, compare it to X-Brenzuri-Signature in constant time, and refuse a timestamp more than 300 seconds from your clock. Hash the bytes you received, not a JSON string you parsed and wrote again. Refuse to start, or answer 500, when the secret is missing or empty: an empty key would let anyone forge a signature. Require the header to be exactly 64 lowercase hex characters and the timestamp to be all digits. The signature widget is the same one used for developer webhooks.
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.
Answer #
Any 2xx is delivered. Answer within 20 seconds; Brenzuri reads at most 64 KB of the answer. Optionally answer JSON {"url": "https://…"}: the address of what you made. Brenzuri stores it, checks that it is https without credentials, and links to it from the article page. Anything else in the answer is ignored.Any other status is a failure. 429 and 5xx are retried; other 4xx and 3xx are held for a person, who retries from the delivery log once you have fixed the cause. Do slow work after you answer, or answer only once the article is stored.
Retries #
deliveryId.
| Attempt | 1→2 | 2→3 | 3→4 | 4→5 | 5→6 | 6→7 | 7→8 | 8→9 | 9→10 | 10→11 | 11→12 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| What happened | Reason in the delivery log | Retried by Brenzuri |
|---|---|---|
A request that timed out while waiting for your answer may have been received. Your endpoint, not Brenzuri, decides whether it was: that is why the delivery waits for a person, and why deliveryIdmatters when they retry.After the attempts or the 24 hours the delivery stays held, with the last reason. A person can retry it by hand from Connections › Delivery log. A held delivery for an older version is not retried once a newer version exists; it says “A newer version exists; approve it to deliver.”
Receive the same article twice #
Deduplicate on `deliveryId`, from the signed body. It is the same on every retry of one delivery and is the id shown in the delivery log. The header X-Brenzuri-Deliverycarries the same value but is not under the signature, so use it only to find the delivery in the log. If you have already stored the id, answer 2xx again, with the sameurl, and store nothing.Upsert by `articleId`. A later version of the same article arrives with the same articleIdand a higherversion. Replace your record. If a lower version arrives after a higher one, because a retry was late, keep the higher one and still answer 2xx.Different deliveries are different ids: delivering the same version again by hand is a new delivery with the same articleIdandversion.
Language versions #
articleId, its own version and its own deliveryId. Two fields tie them together:
languageis the language code of that article. It is present on the article that was written from the brief only once it has language versions, and on every version.primaryArticleIdis the lower-casearticleIdof the article the version belongs to. It is present on a version only. Group by it on your side; Brenzuri does not link the records.A language version has no illustrationkey: it has no picture of its own.When a rewrite started with supersedeswrites language versions, each one hassupersedesset to the version of the same language that the earlier article had, if it had one.
Rewrites #
supersedes set to the earlier article. When it is delivered, supersedes holds the earlier article’s id. Replace your record of that article with the new one, or redirect the old address to it; Brenzuri cannot change anything on your site, so it cannot do it for you. Later versions of the new article arrive with the new articleId.
The test ping #
{"event":"connection.test"}, signed the same way, with a timestamp and a signature but without X-Brenzuri-Event or X-Brenzuri-Delivery, and with no deliveryId. Verify it and answer 2xx. It has no articleId, so handle it before you look for one.
A complete receiver #
deliveryId it has seen, keeps the highest version of an article, stores the HTML and the decoded picture, and answers 200 with {"url"}. They keep their data in files to stay short; use your database. Set BRENZURI_CONNECTION_SECRET to the connection secret.
import express from 'express';
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const SECRET = process.env.BRENZURI_CONNECTION_SECRET;
if (!SECRET) {
console.error('BRENZURI_CONNECTION_SECRET is not set; refusing to start');
process.exit(1);
}
const PUBLIC_BASE = process.env.PUBLIC_BASE_URL ?? 'https://example.com';
const DATA = process.env.BRENZURI_DATA_DIR ?? './brenzuri-data';
const TOLERANCE_SECONDS = 300;
const EXTENSIONS = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' };
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const HEX64 = /^[0-9a-f]{64}$/;
for (const dir of ['deliveries', 'articles', 'images']) mkdirSync(join(DATA, dir), { recursive: true });
const app = express();
app.post('/brenzuri/articles', express.raw({ type: 'application/json', limit: '16mb' }), (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);
}
let article;
try {
article = JSON.parse(body);
} catch {
return res.sendStatus(400);
}
if (article.event === 'connection.test') return res.sendStatus(204);
const deliveryId = article.deliveryId ?? '';
if (!UUID.test(deliveryId) || !UUID.test(article.articleId ?? '')) return res.sendStatus(400);
const done = join(DATA, 'deliveries', deliveryId + '.json');
if (existsSync(done)) return res.status(200).type('json').send(readFileSync(done));
const recordPath = join(DATA, 'articles', article.articleId + '.json');
const stored = existsSync(recordPath) ? JSON.parse(readFileSync(recordPath, 'utf8')) : null;
if (!stored || stored.version <= article.version) {
const record = {
articleId: article.articleId,
version: article.version,
title: article.title,
status: article.status,
html: article.html,
deck: article.deck ?? null,
supersedes: article.supersedes ?? null,
humanApproved: article.humanApproved,
image: null,
};
const picture = article.illustration;
if (picture && picture.base64 && EXTENSIONS[picture.mime]) {
const bytes = Buffer.from(picture.base64, 'base64');
if (createHash('sha256').update(bytes).digest('hex') === picture.sha256) {
const file = article.articleId + '-v' + article.version + '.' + EXTENSIONS[picture.mime];
writeFileSync(join(DATA, 'images', file), bytes);
record.image = { file, altText: picture.altText, caption: picture.caption ?? null, label: picture.label };
}
}
writeFileSync(recordPath, JSON.stringify(record));
}
const answer = JSON.stringify({ url: PUBLIC_BASE + '/articles/' + article.articleId });
writeFileSync(done, answer);
res.status(200).type('json').send(answer);
});
app.listen(3000);Test your endpoint #
Run the receiver locally and send the signed request below. It carries a complete delivery with a picture. You should get 200 and {"url": …}.Send the same request again. You should get 200 and the same url, and nothing stored twice.Change one character of the body and send it with the old signature. You should get 401. Send it with a timestamp from ten minutes ago. You should get 400. Send the test ping. You should get a 2xx. Put the endpoint on https, press Test connection in Brenzuri, then approve an article of the site and watch the delivery log.
SECRET="whsec_local_test"
TS=$(date +%s)
cat > body.json <<'JSON'
{"approval":{"at":"2026-10-06T12:31:40Z","by":"Maya Chen","version":1},"articleId":"9f3b1c2a-6d4e-4b7a-8c15-2e0a7d91b3f4","blocks":[{"kind":"paragraph","text":"Three ferry operators will publish one shared timetable for the northern route from 3 November, the harbour authority said."},{"kind":"heading","level":2,"text":"What changes for commuters"},{"kind":"paragraph","text":"Commuters will see 14 daily crossings instead of 9, and a single fare table applies on all of them."}],"claims":[{"block":0,"outlets":["harbour-authority.example","coast-gazette.example"],"state":"corroborated","text":"The shared timetable starts on 3 November."},{"block":2,"outlets":["harbour-authority.example"],"state":"singleSource","text":"The route will have 14 daily crossings instead of 9."},{"block":2,"outlets":["harbour-authority.example","coast-gazette.example"],"state":"corroborated","text":"A single fare table applies on all crossings."}],"deck":"The harbour authority says commuters get 14 daily crossings.","deliveryId":"0d4f6a18-93be-4c27-8a50-e1c2b7d9f346","event":"article.exported","html":"<h1>Ferry operators agree on a single timetable for the northern route</h1>\n<p>Three ferry operators will publish one shared timetable for the northern route from 3 November, the harbour authority said.</p>\n<h2>What changes for commuters</h2>\n<p>Commuters will see 14 daily crossings instead of 9, and a single fare table applies on all of them.</p>\n<aside class=\"bz-sources\">\n<h2>Sources</h2>\n<p>This article was written from the reporting below.</p>\n<ul>\n<li><a href=\"https://harbour-authority.example/news/northern-route-timetable\">harbour-authority.example — Northern route: one timetable from 3 November</a></li>\n<li><a href=\"https://coast-gazette.example/transport/ferries-share-timetable\">coast-gazette.example — Ferries to share a timetable</a></li>\n</ul>\n<p><small>Generated with sources · reviewed by a human: yes · Approved by Maya Chen, 6 Oct 2026 · <a href=\"https://brenzuri.com/source-policy\">Sourcing policy</a></small></p>\n</aside>\n","humanApproved":true,"illustration":{"altText":"Flat shapes of a ferry crossing a calm blue strait under a pale sky.","base64":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==","caption":"Plate 4 - Ferry timetables","height":1,"label":"AI-generated","mime":"image/png","sha256":"6b7fa434f92a8b80aab02d9bf1a12e49ffcae424e4013a1c4f68b67e3d2bbcd0","styleLabel":"House style","width":1},"sources":[{"official":false,"outlet":"harbour-authority.example","score":75,"title":"Northern route: one timetable from 3 November","url":"https://harbour-authority.example/news/northern-route-timetable"},{"official":false,"outlet":"coast-gazette.example","score":75,"title":"Ferries to share a timetable","url":"https://coast-gazette.example/transport/ferries-share-timetable"}],"status":"draft","title":"Ferry operators agree on a single timetable for the northern route","version":1,"words":412}
JSON
SIG=$({ printf '%s.' "$TS"; cat body.json; } | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -i -X POST "http://localhost:3000/brenzuri/articles" \
-H "Content-Type: application/json" \
-H "X-Brenzuri-Event: article.delivered" \
-H "X-Brenzuri-Delivery: 0d4f6a18-93be-4c27-8a50-e1c2b7d9f346" \
-H "X-Brenzuri-Timestamp: $TS" \
-H "X-Brenzuri-Signature: $SIG" \
--data-binary @body.jsonSECRET="whsec_local_test"
TS=$(date +%s)
BODY='{"event":"connection.test"}'
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -i -X POST "http://localhost:3000/brenzuri/articles" \
-H "Content-Type: application/json" \
-H "X-Brenzuri-Timestamp: $TS" \
-H "X-Brenzuri-Signature: $SIG" \
-d "$BODY"What you see in Brenzuri #
| Delivery status | Means |
|---|---|
delivered | |
retrying | |
held | |
cancelled |
POST /articles/{id}/deliver as a Delivery.
Limits #
One connection per site, and a connection serves one site. The body is signed but not encrypted beyond https. The signature has no replay protection beyond the timestamp; your deduplication provides the rest.