Webhooks
The HTTP address channel, the payload v5 contract, retries, idempotency, and safe rollout.
A webhook sends a result as an HTTP POST request with JSON to a publicly reachable address. You set it up on the HTTP address channel. The receiver should acknowledge acceptance quickly with a 2xx response and perform heavier work asynchronously.
Minimal receiver
import express from 'express'
const app = express()
app.use(express.json())
app.post('/semantic-hub', async (req, res) => {
const deliveryId = req.header('X-SH-Delivery')
await enqueueOnce(deliveryId, req.body)
res.status(202).end()
})Headers and response
Content-Type: application/jsonIdempotency-Key: the result identifierX-SH-Delivery: the identifier of a series of attempts; a retry of the same content carries the same identifierX-SH-Timestamp: send time in Unix secondsAuthorization: Bearer …andX-SH-Signature: sha256=…when a secret is configured- any
2xxresponse completes delivery successfully
Secret
The secret is optional. Once saved, SemanticHub never shows it again: you see Secret set with a date and the Change and Remove buttons.
Every delivery uses the same secret twice:
- in the
Authorization: Bearer …header; - as the HMAC key for the
X-SH-Signatureheader.
The signature is HMAC-SHA256(secret, X-SH-Timestamp + "." + raw body) in hex. Compute it over the request bytes before you parse them as JSON. The receiver checks whichever method it supports. Without a secret, the request carries neither header.
Retries
A delivery gets at most three attempts: the first one, and after a failure two more, roughly 1 minute and 5 minutes later. Each request has a 30-second timeout.
SemanticHub retries timeouts, connection failures, 429, and 5xx responses. Other 4xx responses are treated as permanent failures. After the third failed attempt the delivery is marked failed and you can retry it by hand from the result panel.
Main payload fields
Every receiver gets the same shape, payload_version: 5. You do not pick a version and you do not set a custom JSON template.
{
"payload_version": 5,
"mode": "article",
"event": "workflow.action",
"result_id": "result-id",
"revision": 1,
"workflow_execution": "result-id",
"workflow_name": "Goal name",
"title": "Result title",
"lead": "Standfirst for the excerpt and meta description.",
"executed_at": "2026-09-04T10:00:00+00:00",
"published_at": "2026-09-04T10:00:00+00:00",
"source_lang": "en",
"articles_count": 3,
"articles": [],
"clusters": [],
"llm_response": "## Heading\n\nMarkdown content…",
"locales": {
"en": {
"title": "Result title",
"slug": "result-title",
"lead": "Standfirst…",
"body": "## Heading\n\nMarkdown content…",
"seo_description": "Standfirst…",
"body_html": "<h2>Heading</h2><p>Markdown content…</p>"
}
},
"steps": {
"Editor": { "text": "…", "model": "model-id", "tokens": 900, "cost": 0.0004 }
},
"ai_model": "model-id",
"tokens_used": 1234,
"cost": 0.01
}- mode:
articlewhen the result carries generated content, orclusterwhen we send the cluster together with its source articles. In cluster modellm_responseisnull, there is nolocales, andstepsis an empty object. - result_id and revision:
result_idstays the same across versions of one result, andrevisiongrows with every content change. The receiver uses this pair to decide which post to update. - executed_at and published_at: the time the result was created. A retry sends the same values.
- llm_response: content in markdown (headings
##, GFM tables, blockquotes). The canonical structure travels insections. - locales: a ready version of the result in the source language, with a
slugand the body already rendered to HTML. A second language appears only when you turn on Second language version. - steps: the output of every workflow step under its name. The receiver takes what it needs, for example only the brief or the text before editing.
- title and lead: always present, though either may be
null.leadis the standfirst from the editor. Put it in the excerpt or meta description instead of cutting the first paragraph of the body.clusters[0].descriptiondescribes the cluster and is not the same text.
Some keys appear only when there is something to send. A missing key is not an error and does not mean "empty value":
{
"sections": [{ "key": "body", "blocks": [] }],
"image": { "url": "https://…", "attribution": "…" },
"tags": ["keyword"],
"publish_mode": "draft",
"fields": { "category": "health" }
}sections: omitted when the result has no sections (we never send"sections": []);image: only when someone actually chose a featured image; the key also carries attribution;tags: only when the cluster has keywords;publish_mode: only when the goal set a publishing policy (moderation,draft, orpublish). It is a request, not an order: the receiver still checks its own permissions.fields: only when the receiver reported fields it requires (see Receiver fields).
Ignore unknown keys for forward compatibility instead of rejecting the request.
Next version of a result
When new articles join an already published cluster, or you change the text of a delivered result and send it again, the new version goes out as event: "cluster.updated" with a higher revision. Such a delivery adds two keys that a first delivery does not have:
{
"event": "cluster.updated",
"revision": 2,
"parent_execution": "previous-result-id",
"new_articles": ["article-id"]
}After a text-only change to a result with no previous version, parent_execution is null and new_articles is empty. A new cluster version with new articles carries parent_execution. If your system already knows a higher version, answer 409 with a revision field in the body. We then send the result again with the next number.
Receiver fields
Your system can require values with every entry that the result content does not carry, such as a category or a section. It reports them in the same capability report as blocks and sections: PUT /api/goals/{id}/target-manifest with an API token. Here is a fragment of such a report:
{
"source": "receiver",
"fields": [
{
"key": "category",
"label": "Category",
"type": "enum",
"required": true,
"multiple": false,
"options": [{ "value": "health", "label": "Health" }]
}
]
}sourcemust be"receiver". A report without it is treated as a WordPress plugin report, and its fields do not apply to a goal on the HTTP address channel.keyuses lowercase letters, digits, and_, up to 40 characters.typeisenum,text, orbool. Anenumfield needs anoptionslist, andmultipleis allowed only forenum.- You set the values in SemanticHub: defaults in the Action step, in the Receiver fields section, and per result on the Delivery tab. A value outside the reported list is rejected on save.
- The payload carries a
fieldskey with akey: valuemap. Without reported fields, the key is absent. - A result with a required field left empty is not sent. In SemanticHub it goes back to Needs approval and waits for the value.
A different payload shape
You cannot change the JSON shape on the SemanticHub side. If your system expects other fields, use a URL from Zapier, Make, or n8n as the recipient URL and reshape the data there.
Receiver capability negotiation
A receiver can report to SemanticHub what it is able to render: which section types and content elements it supports. The WordPress plugin does this, and your own application can introduce itself the same way.
- The first report on a goal that has no content contract yet establishes that contract from the receiver's capabilities, so you do not have to configure it by hand.
- Later reports only record the new capabilities. A report never holds delivery back.
- A section the receiver cannot render natively is sent in a fallback form, as plain content blocks.
- Once a week you get an email if a manually set contract no longer matches the receiver's capabilities or the plugin stopped reporting.
Test mode
A goal created in the web wizard starts with Delivery mode set to Test. This protects the configured real destination. In the hosted production environment, the internal development sink is deliberately unavailable, so a test delivery may appear as failed rather than as an inspectable request.
Safe rollout sequence:
- pick the HTTP address tile in the Action step;
- hit Send test: one sample event travels the same route as a real delivery and shows the recipient's response; it also accepts an unsaved URL, so a typo surfaces before you save;
- prepare an idempotent receiver;
- enter the real Recipient URL and the optional Secret;
- switch Delivery mode to Production;
- dispatch one approved result;
- choose Send immediately only after verification.
Address restrictions
The webhook must target a publicly routable host. Loopback, private, link-local, reserved, and cloud-metadata addresses are blocked.