BrenzuriStart free
Deliver to a custom site
Guides

Deliver to a custom site

A signed POST to your https endpoint, once per approved version. You verify it, store it and answer 2xx.

On this page

A custom site receives each article as a signed JSON POST to an https address you give Brenzuri. This page is the whole contract: when it is sent, what is in it, how to check it, what to answer, and what happens when you do not.

Set it up#

  1. Build the receiving endpoint below, on https, reachable from the internet.
  2. 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.
  3. Press Test connection. It sends a signed ping; any 2xx answer is a pass.
  4. 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, any whsec_ 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: status says draft on Draft and awaiting_approval on Awaiting approval. Every delivery that follows an approval in Brenzuri, every delivery of a Beat, and everything a key or an agent sends has status draft, 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 gets draft, or awaiting_approval on a connection set to Awaiting approval. A key or an agent can do this too and always gets draft.
  • 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#

HeaderValue
Content-Typeapplication/json.
X-Brenzuri-Eventarticle.delivered, on every delivery.
X-Brenzuri-DeliveryThe id of this delivery, a lower-case UUID. It is the same on every retry of the delivery and equals the id in the delivery log in Brenzuri. The same value is in the signed body as deliveryId; the header is not signed, so read the body.
X-Brenzuri-TimestampUnix seconds when this attempt was sent. It changes on every attempt.
X-Brenzuri-SignatureLower-case hex HMAC-SHA256 of <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.

There is no custom 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.

POSTX-Brenzuri-Event: article.delivered
  1. {
  2. },
  3. {
  4. },
  5. {
  6. },
  7. {
  8. }
  9. ],
  10. {
  11. },
  12. {
  13. },
  14. {
  15. }
  16. ],
  17. },
  18. {
  19. },
  20. {
  21. }
  22. ],
  23. }

Hover or focus a field to see what it is.

Body#

FieldMeaning
eventAlways 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.
titleThe article title. The same title is the <h1> at the top of html.
statusdraft 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.
htmlThe article as HTML: the <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.
deliveryIdThe id of this delivery, the same lower-case UUID as the header 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.
articleIdLower-case UUID of the article.
versionThe version of the article that was delivered. articleId with version identifies what you received.
deckThe standfirst. Only present when this version is approved and the article has one.
wordsWord count.
humanApprovedTrue when this exact version was approved by a person and the delivery was made by a person, as approval in Brenzuri does. A delivery a key or an agent starts reports false even for an approved version; approval then still names the approval.
blocksThe text as structured blocks, each with a kind: paragraph (text), heading (level, text), list (ordered, items) or table (headers, rows). Text may contain **bold** markers.
claimsEvery claim: text, state, the outlets that carry it and block, the index into blocks where it sits.
sourcesEvery source: outlet, title, url, score and official.
illustrationThe picture, only when the delivered version has a ready one. See the table below. Absent otherwise.
supersedesLower-case UUID of the article this one replaces, when it is a rewrite. See Rewrites.
languageThe language code of this article, such as es; bhs for Bosnian/Croatian/Serbian. Only present when the article has language versions or is one. See Language versions.
primaryArticleIdLower-case UUID of the article this one is a language version of. Only present on a language version.
approval{ by, version, at }, present when the version has an approval.
blogOnly ever present for Brenzuri’s own blog. It is never sent to a customer. Ignore it.

claims#

FieldMeaning
textThe claim.
statecorroborated, singleSource, circular, disputed or parallel.
outletsOutlets that carry it.
blockIndex into blocks.

sources#

FieldMeaning
outletThe outlet or origin.
titlePage title.
urlAddress of the page.
scoreThe score the sourcing policy gives the outlet.
officialWhether the origin is official for the subject.

approval#

FieldMeaning
byThe person who approved.
versionThe approved version.
atISO 8601 timestamp.

illustration#

Present only when the delivered version has a ready picture. The picture is carried inside the body, so the signature covers it and no second request is needed.

FieldMeaning
mimeimage/png, image/jpeg or image/webp.
base64The image bytes, standard base64. Absent when tooLarge is true.
sha256Lower-case hex SHA-256 of the raw image bytes, so you can check what you decoded.
widthPixels. Absent if the size could not be read.
heightPixels. Absent if the size could not be read.
altTextAlternative text for the picture.
captionThe caption, with its plate number when the site’s style has one. Absent when empty.
labelAlways AI-generated. Show it with the picture.
styleLabelHouse style or Abstract editorial.
tooLargeTrue, with no 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 against sha256, store the file, and show label with 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, PHP post_max_size and memory_limit, Express and Flask limits).
  • An article with no picture has no illustration key at all, and so does a delivery whose picture could not be read.

Check the signature#

Anyone can post to your address. Compute the HMAC-SHA256 of <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#

A failed attempt that can succeed later is tried again after 30 seconds, doubling each time and never more than 4 hours apart, up to 12 attempts within 24 hours. Each attempt has a new timestamp and signature, and the same body, so the same deliveryId.

Attempt1→22→33→44→55→66→77→88→99→1010→1111→12
Wait before the next30 s1 min2 min4 min8 min16 min32 min64 min2 h 8 min4 h4 h

Brenzuri retries on its own only when it can be sure your endpoint received nothing, or answered that it is busy. A retry after a lost answer could create a second article, so those wait for a person.

What happenedReason in the delivery logRetried by Brenzuri
You answered 429 or 5xxdestination returned 503; queued for retryYes
The address did not resolvecould not resolve the addressYes
The connection was refusedcould not connectYes
No connection within 10 secondscould not connect: timed out after 10 sYes
No answer within 20 seconds, after the connection was madetimed out after 20 sNo, a person retries
The answer was larger than 64 KBthe response was too largeNo, a person retries
The address is private, or not allowedaddress not allowedNo, a person retries
TLS failed, the connection closed, or anything elsethe request failedNo, a person retries
You answered another 4xx or a 3xxdestination returned 404No, a person retries
  • 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 deliveryId matters 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#

Delivery is at least once. Make receiving idempotent:

  • 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-Delivery carries 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 same url, and store nothing.
  • Upsert by `articleId`. A later version of the same article arrives with the same articleId and a higher version. 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 articleId and version.

Language versions#

An article written in several languages arrives as several articles, one delivery for each, because each language version is approved on its own and is delivered when its own approval is made. A language version has its own articleId, its own version and its own deliveryId. Two fields tie them together:

  • language is 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.
  • primaryArticleId is the lower-case articleId of 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 illustration key: it has no picture of its own.
  • When a rewrite started with supersedes writes language versions, each one has supersedes set to the version of the same language that the earlier article had, if it had one.

Rewrites#

A rewrite is a new article written to replace an earlier one, started with 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#

Test connection sends {"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#

Each of these refuses to run without a secret, verifies the signature in constant time with a 300 second window, ignores a 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#

  1. Run the receiver locally and send the signed request below. It carries a complete delivery with a picture. You should get 200 and {"url": …}.
  2. Send the same request again. You should get 200 and the same url, and nothing stored twice.
  3. Change one character of the body and send it with the old signature. You should get 401.
  4. Send it with a timestamp from ten minutes ago. You should get 400.
  5. Send the test ping. You should get a 2xx.
  6. Put the endpoint on https, press Test connection in Brenzuri, then approve an article of the site and watch the delivery log.
Signed delivery
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.json
Replay a delivery. Run the receiver on port 3000 first, with the secret whsec_local_test.
Test ping
SECRET="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 statusMeans
deliveredYou answered 2xx.
retryingBrenzuri is holding it while delivery is paused for the workspace and sends it when delivery resumes.
heldIt did not arrive, or Brenzuri cannot tell. Brenzuri retries a failure by itself only when nothing was sent, or you answered 429 or 5xx; for the others, or after the attempts run out, a person retries it. The reason is shown in plain words.
cancelledThe connection was handed to another site since, so the article is not sent through it.

Each row in Connections › Delivery log shows the status, the HTTP status you answered with, the reason, the number of attempts and the link you answered with. The same fields come back from 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.