Guide
Delivery API
Everything a receiving system needs in order to accept content from BrandKind: what arrives, how it is authenticated, what BrandKind does when your endpoint is unhappy, and what you must implement to be a well-behaved receiver.
Which way the traffic goes
BrandKind calls you. There is no API you call — no key to issue, no endpoint to hit, no rate limit to plan around on your side. You stand up an HTTP endpoint, register it as a destination, and BrandKind POSTs finished pieces to it.
A delivery is triggered either by a person choosing Send to… on a piece, or by the automatic retry sweep re-attempting an earlier failure. Both look identical from your side.
The request
Always a POST to the exact URL you registered, with a single piece in the body. There is no batching — one request carries one piece.
POST /webhooks/brandkind HTTP/1.1
Host: cms.example.org
Content-Type: application/json; charset=utf-8
Authorization: Bearer <your secret>
{ …the JSON payload above… }| Property | Value |
|---|---|
| Method | POST |
| Scheme | http or https. https is required whenever a secret is configured. |
| Ports | Standard web ports only. Non-standard ports are rejected before sending. |
| Content-Type | Set from the destination’s wire format — see the next section. |
| Body | One piece. Never an array, never multiple pieces. |
| Timeout | BrandKind waits 10 seconds per attempt, per redirect hop. |
Wire formats
A destination is configured with one of three wire formats, chosen when you create it. The format decides both the Content-Type header and the entire body.
| Format | Content-Type | Body |
|---|---|---|
html | text/html; charset=utf-8 | The piece’s rendered HTML, and nothing else. No wrapper, no metadata. |
markdown | text/markdown; charset=utf-8 | The piece as Markdown. Falls back to the plain-text extract if the document produces no Markdown, so the body is never silently empty. |
json | application/json; charset=utf-8 | A structured envelope carrying all three renderings plus metadata. Documented below. |
Choosing one
json unless you have a reason not to. It is the only format that carries the piece id, the delivery id, the scores and the image — and the only one you can extend against later without reconfiguring the destination.The JSON payload
{
"delivery_id": "9f1c2e64-3a55-4d1b-9d0e-7c2a1b8f4e33",
"piece_id": "1b7d90aa-42c8-4a2f-8f61-0c5e9d3a77b1",
"title": "Winter gritting routes explained",
"format": "blog",
"content_html": "<h1>Winter gritting routes explained</h1><p>…</p>",
"content_markdown": "# Winter gritting routes explained\n\n…",
"content_text": "Winter gritting routes explained\n\n…",
"content_meta": {},
"humanise": {
"reading_age_target": 12,
"ai_score_pre": 91,
"ai_score_post_claude": 44,
"ai_score_post_gpthuman": 8,
"humanised_at": "2026-08-03T09:41:22.318Z"
},
"image": {
"url": "https://…/piece-images/…?token=…",
"layout": "banner"
}
}| Field | Type | Meaning |
|---|---|---|
delivery_id | UUID | This delivery attempt. Stable across every retry of the same delivery — use it as your idempotency key. |
piece_id | UUID | The piece. Stable for the life of the piece, across every delivery of it, to every destination. |
title | string | The piece’s title as it stands now. |
format | string | One of the five piece formats. Not to be confused with the destination’s wire format. |
content_html | string | Rendered HTML. Empty string if the piece has none — never null. |
content_markdown | string | Markdown rendering, with the same plain-text fallback as the markdown wire format. |
content_text | string | Plain-text extract, useful for search indexing and previews. |
content_meta | object | Per-piece metadata. Always an object; empty when there is none. Treat unknown keys as additive — new ones may appear. |
humanise | object | The scoring record for this piece. Detailed below. |
image | object or null | The feature image, or null when the piece has none. |
The humanise object
These are the raw detection numbers, on a 0–100 scale where higher means more likely to be flagged as machine-written. Note that this is the opposite direction to the Human score shown in the interface, which is inverted so that higher is better. If you surface these numbers to your own users, invert them or you will show people a score that reads backwards.
| Field | Meaning |
|---|---|
reading_age_target | The reading age the piece was aimed at, or null. |
ai_score_pre | Detection score before any humanising pass ran. |
ai_score_post_claude | Detection score after the first rewriting pass. |
ai_score_post_gpthuman | Detection score after the final pass. This is the number the interface inverts into the Human score. |
humanised_at | When the humanising pass completed, or null if it never ran. |
The image object
Present only on json destinations, and only when the piece has a feature image selected. It carries a url and a layout describing how the image was meant to sit against the text.
The image URL expires
Authentication
A destination carries one of three authentication modes. BrandKind holds the secret; you set it once and can rotate or clear it, but nobody — including workspace admins — can read it back afterwards.
| Mode | What BrandKind sends |
|---|---|
none | No authentication header at all. |
bearer | Authorization: Bearer <secret> |
custom_header | A header of your choosing carrying the bare secret as its value — for example X-Webhook-Token: <secret>. The header name must be a valid HTTP token. |
Two rules worth knowing before you debug
A destination set to bearer or custom_header with no secret saved sends no authentication header at all — the request arrives unauthenticated rather than carrying an empty credential. If your endpoint is rejecting BrandKind with a 401, check that the secret is actually set.
Storing a secret on a destination whose URL is plain http is refused outright. A credential never travels unencrypted, and the delivery fails permanently rather than falling back.
Redirects
BrandKind follows redirects, but not blindly. Up to three hops are allowed, and every hop is re-checked against the same address rules as the original URL before it is followed.
Your credential is dropped across origins
What counts as success
Any 2xx response marks the delivery sent. The response body is not read and not stored — return whatever you like, or nothing.
Anything else — a non-2xx code, a connection failure, a timeout — marks the delivery failed and schedules a retry. The HTTP code you returned is recorded against the delivery so whoever sent it can see what happened.
| Status | Meaning |
|---|---|
queued | Accepted but not yet attempted, or deferred because a quota was reached. No attempt has been spent. |
sent | Your endpoint returned 2xx. Terminal — nothing further happens. |
failed | The attempt did not succeed. Retried on the schedule below unless retries are exhausted or the failure is permanent. |
Retries
A failed delivery is retried automatically on a widening schedule. After the fifth attempt BrandKind stops and the delivery stays failed — it is never retried again without someone sending it manually.
| Attempts so far | Next attempt in |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 15 minutes |
| 4 | 60 minutes |
| 5 | Never — retries exhausted |
That gives your endpoint roughly 81 minutes of grace across five attempts. An outage shorter than that resolves itself with nothing lost.
Duplicates are possible, so be idempotent
delivery_id and ignore one you have already handled. This is the one piece of work a receiver genuinely must do.Failures that are not retried
Some failures are configuration problems that retrying cannot fix. These are recorded once with a plain explanation and no retry is scheduled.
- The destination is paused. Nothing is sent — deliberately, and visibly, rather than silently dropped.
- A secret is configured against a plain
httpURL. - The custom header name is not a valid HTTP header name.
Rate limits and quotas
Each workspace has a per-minute and a per-day delivery budget. They protect both your endpoint and the platform from a runaway loop. They behave differently, and the difference matters if you are reading delivery records:
| Limit reached | What happens |
|---|---|
| Per-day budget | The delivery is deferred to the start of the next day window and stays queued. No attempt is spent, so it does not eat into the five retries. |
| Per-minute budget | The delivery is marked failed and retried on the normal schedule. An attempt is spent. |
| Budget cannot be checked | The delivery is deferred for a few minutes and retried. BrandKind will not send what it cannot account for. |
Which addresses are allowed
Destination URLs are validated before every send, not just when you save them. Addresses that resolve to private, loopback, link-local or otherwise reserved ranges are refused, as are non-standard ports and schemes other than http and https.
This is why a destination pointing at something inside your own network — a machine onlocalhost, or a private address — will never work, no matter how the firewall is set up. Expose a public endpoint, or use a tunnel that gives you a public hostname.
Implementing a receiver
A minimal correct receiver is short. In order of what actually matters:
Accept POST and reply fast.
Return 2xx as soon as you have safely stored the payload. Do the slow work — image download, indexing, publishing — afterwards. You have 10 seconds; use one of them.Check the credential.
Compare the incoming secret against the one you configured, and reject anything else with a 401. Compare in constant time if your language makes that easy.Deduplicate on delivery_id.
If you have seen it, return 2xx and do nothing else. This is what makes retries safe.Fetch the image now, if there is one.
The signed URL is good for 24 hours and no longer. Store your own copy.Tolerate new fields.
Ignore keys you do not recognise rather than failing on them. The payload gains fields over time and never removes them without notice.Return a real status code when you fail.
A 500 tells BrandKind to retry, which is usually what you want. Returning 2xx on failure loses the content permanently.
Testing your endpoint
Create a destination pointing at your endpoint, set it to json, then use Send to… on any piece. The delivery record shows the status, the HTTP code you returned, the attempt count and the reason for any failure — which is enough to debug most integrations without instrumenting your own side.
To test the failure path, return a 500 deliberately and watch the retry schedule play out. To test the permanent-failure path, pause the destination and send again.