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/json
  • Idempotency-Key: the result identifier
  • X-SH-Delivery: the identifier of a series of attempts; a retry of the same content carries the same identifier
  • X-SH-Timestamp: send time in Unix seconds
  • Authorization: Bearer … and X-SH-Signature: sha256=… when a secret is configured
  • any 2xx response 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-Signature header.

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: article when the result carries generated content, or cluster when we send the cluster together with its source articles. In cluster mode llm_response is null, there is no locales, and steps is an empty object.
  • result_id and revision: result_id stays the same across versions of one result, and revision grows 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 in sections.
  • locales: a ready version of the result in the source language, with a slug and 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. lead is the standfirst from the editor. Put it in the excerpt or meta description instead of cutting the first paragraph of the body. clusters[0].description describes 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, or publish). 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" }]
    }
  ]
}
  • source must 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.
  • key uses lowercase letters, digits, and _, up to 40 characters. type is enum, text, or bool. An enum field needs an options list, and multiple is allowed only for enum.
  • 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 fields key with a key: value map. 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:

  1. pick the HTTP address tile in the Action step;
  2. 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;
  3. prepare an idempotent receiver;
  4. enter the real Recipient URL and the optional Secret;
  5. switch Delivery mode to Production;
  6. dispatch one approved result;
  7. 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.

All pages