Hoxigen writes an article and hands it to you as one signed HTTP POST. You verify a header, store what you were sent, and return 200. This page is the whole contract: every header, every field, the exact signature algorithm, and what happens when your endpoint is down.
One POST to the URL you registered on the Blog connector, with a JSON body. There is no polling, no callback URL to hand back, and nothing to install on your side.
| Header | Type | What it means |
|---|---|---|
| Content-Type | application/json | Always. The body is UTF-8 JSON. |
| X-Hoxigen-Signature | sha256=<hex> | HMAC-SHA256 of the raw request body, keyed with your signing secret, hex-encoded in lowercase. Verify this before you parse anything. |
| X-Hoxigen-Event | article.published | webhook.test | article.lookup | article.updated | Always equals the event in the body. Treat the body as the source of truth and the header as a cheap pre-filter. |
| X-Hoxigen-Timestamp | unix seconds | The moment the request was sent. It is not covered by the signature, so it cannot stand alone as replay protection — the signed timestamp inside the body can. |
| X-Hoxigen-Contract | 2 | Only on requests whose body carries contract_version: 2 — webhook.test, article.lookup and article.updated. It is absent from article.published, which is unchanged since Version 1. |
Article deliveries time out at 30 seconds, the connection test and the lookup at 15. Commit, then answer; push cache rebuilds and image fetches into a background job rather than making Hoxigen wait for them.
The connection test, the lookup and an update refuse redirects outright — a 301 is a failed request, not a hop. A plain publish still follows one, which is the one asymmetry in the contract. Register the final URL either way, with the scheme and the trailing slash it really has.
Anything past a million bytes is an error rather than a truncation. Nothing in the contract needs a large response.
Hoxigen issues a signing secret per webhook and shows it once. It is not shared with any other customer, and reconnecting issues a new one.
# The body is pretty-printed here to be read. What arrives is compact JSON,
# and the signature covers those exact bytes, not this rendering.
POST /your/endpoint HTTP/1.1
Content-Type: application/json
X-Hoxigen-Event: article.published
X-Hoxigen-Signature: sha256=<64 lowercase hex characters>
X-Hoxigen-Timestamp: 1789378200
{
"event": "article.published",
"timestamp": "2026-09-21T09:30:00.000Z",
"data": {
"id": "cmf0hoxigenexample01",
"title": "Kettlebell swings for cyclists",
"slug": "kettlebell-swings-for-cyclists",
"content_html": "<h2>Why</h2><p>Because hills.</p>",
"content_markdown": "## Why\n\nBecause hills.",
"meta_description": "Two lifts that carry over to climbing.",
"focus_keyword": "kettlebell swings",
"featured_image_url": "https://cdn.example/hero.png",
"tags": ["training", "cycling"],
"author": "Dana Levi",
"published_at": "2026-09-21T09:30:00.000Z",
"json_ld": "<script type=\"application/ld+json\">{ ... }</script>"
}
}The body is always { event, timestamp, data }, with contract_version: 2 added on the Version 2 events. Every key below is always present on article.published — no field is ever omitted, and only one of them is ever null.
| data field | Type | What it means |
|---|---|---|
| id | string | Hoxigen's id for the article. Never empty, and identical on every retry of the same delivery — this is your deduplication key. |
| title | string | Empty string if the article somehow has no title. The WordPress plugin stores '(untitled)' rather than an empty post title. |
| slug | string | A suggested slug. Falls back to the id when the article has none. Yours to honour or ignore — but keep it stable once you have chosen it. |
| content_html | string | The article body as HTML. This is the field to render. |
| content_markdown | string | The same body as Markdown, so a Markdown site does not have to parse HTML back out. Empty string when no Markdown version exists. |
| meta_description | string | May be empty. |
| focus_keyword | string | May be empty. |
| featured_image_url | string | null | The only field that is ever null. Null means Hoxigen has no image for this article — on an update it means leave the image already on the page alone, never clear it. |
| tags | string[] | May be an empty array. Never null. |
| author | string | The byline from the app profile, falling back to the connector's legacy author field and then the app name. |
| published_at | string | ISO 8601. Always identical to the top-level timestamp. |
| json_ld | string | Already-wrapped HTML, not raw JSON: one or more <script type="application/ld+json"> blocks, newline-separated. An Article block plus any FAQPage the article generated. Put it in the page head verbatim. Empty string if none could be built. |
It arrives as complete <script type="application/ld+json"> tags, newline-separated: a deterministic Article and Organization block, plus any FAQPage the article generated. Do not parse it — put it in the page head as it is.
Off by default. When it is on, small tracking markup is appended to content_html and content_markdown so Hoxigen can estimate article views. It is part of the body; nothing separate to install.
This is the part that is worth getting exactly right, because a mistake here is a hole in your site rather than in ours. The algorithm is short and there are no options in it.
The signature covers the exact bytes that arrived. Read them before any framework parses, re-serializes or pretty-prints the JSON — a re-encoded object will not match, even when it is semantically identical.
Key is the signing secret, message is the raw body, digest is lowercase hex. The header value is that hex with a literal sha256= in front.
Use timingSafeEqual, hash_equals, hmac.compare_digest or your language's equivalent — never == or ===. Check the lengths first: the constant-time comparators throw or error when the two sides differ in length.
Reject before you touch the JSON. An unsigned request should not be able to cost you a parse, a database lookup or a log line containing its contents.
import crypto from "node:crypto";
/**
* True only for a body that was signed with your Hoxigen secret.
* rawBody must be the exact bytes you received — never a re-serialized object.
*/
export function verifyHoxigenSignature(rawBody, header, secret) {
const expected =
"sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const got = Buffer.from(header ?? "", "utf8");
const want = Buffer.from(expected, "utf8");
// Compare in constant time. A plain === leaks the signature one byte at a
// time to anyone willing to measure; timingSafeEqual throws when the two
// buffers differ in length, so the length check has to come first.
return got.length === want.length && crypto.timingSafeEqual(got, want);
}X-Hoxigen-Timestamp sits outside the signed bytes, so anyone replaying a captured request can set it to whatever they like. If you want a replay window, use the timestamp inside the body, which is covered by the signature — or lean on data.id, which makes a replay a no-op anyway.
Hoxigen always sends lowercase hex. The snippet above compares the header exactly as sent; the WordPress plugin lowercases it first. Either is fine as long as you do not reach for a case-insensitive comparison that is no longer constant time.
For a publish, any 2xx means the article is yours and the body is not read. The Version 2 events are the exception: their responses are parsed and checked, and an acknowledgement that does not match is treated as a failure.
Return it only once the article is durably stored. A 200 sent before the write commits is how an article goes missing with the delivery marked successful.
4xx and 5xx are treated the same way, as are timeouts and connection errors. The first 200 characters of your response body are kept as the error, so a useful message there is worth writing.
A stale base revision never becomes fresh, so retrying would only lock the draft up for hours. The refresh fails immediately and the article is left exactly as it was.
The first try is immediate; the retries wait 1m, 5m, 30m, 2h, 6h. The queue is drained by a worker that ticks every minute, so each wait is a floor rather than a stopwatch. After the last one the item is marked failed, with the error visible in the app.
One row per queued article, visible to us when you write in about a delivery.
| Column | Type | What it means |
|---|---|---|
| url | string | The endpoint the payload was queued for. A publish keeps going to that address even if the connector is edited afterwards; a refresh is refused outright when the connector no longer points there. |
| status | pending | sending | success | failed | A row stuck in sending for ten minutes — a deploy mid-flight, say — is returned to pending and picked up again. |
| attempts | int | Counts up to 6, then the delivery is failed for good. |
| lastStatusCode | int? | The HTTP status of the last attempt, when there was one. |
| lastError | string? | The failure text, including the first 200 characters of your response body. Worth putting something diagnostic there. |
| nextRetryAt | datetime? | When the next attempt becomes due. Null once the delivery has settled. |
| payload | json | The exact object that gets serialized and signed on each attempt, so a retry is byte-for-byte the same article. |
There is only one thing between the two versions, and it is refresh. Hoxigen rewrites an article that has started to slip so it keeps the position it earned — which only works if the rewrite replaces the original at its own URL instead of publishing a second article beside it.
Answers article.published: store the article, return 200. That is the whole of it, and it has not changed since the first release — a Version 1 receiver keeps working untouched.
Refresh. When Hoxigen rewrites an article to keep it ranking, a Version 1 endpoint has no way to be told which existing article to replace, so the refresh cannot be delivered at all. It is not published as a duplicate; it stops, and the item reports that a Version 2 receiver is required.
Adds two events. article.lookup resolves one article from its public URL and reports its current revision. article.updated overwrites that exact article, at the same URL, keeping your stored id and slug, and stores the new revision atomically.
Nothing, but it has to be earned. Hoxigen sends a signed webhook.test before every refresh and only proceeds when the response reports contract_version 2 and all three capabilities. Advertise a capability you have not finished and the first thing that happens is a refresh writing to an article you were not ready to replace.
A signed test goes out when you connect, and again immediately before every refresh. Nothing is cached and nothing is taken on trust: only the exact response below unlocks lookup and update.
// Request — sent when you connect, and again before every refresh.
{
"event": "webhook.test",
"contract_version": 2,
"timestamp": "2026-09-21T09:29:58.000Z",
"data": {
"id": "test-m1k4p9",
"message": "Hoxigen webhook connectivity test — safe to ignore.",
"capabilities": ["article.published", "article.lookup", "article.updated"]
}
}
// Response that makes you a Version 2 receiver. Anything else — including a
// bare 200, or 200 {"received":true} — is read as Version 1.
HTTP 200
{
"received": true,
"contract_version": 2,
"capabilities": ["article.published", "article.lookup", "article.updated"]
}Resolve exactly one article from its public URL. Canonicalization drops the fragment and one trailing slash; origin, path and query all have to match. Never guess by title or by a similar slug — a wrong answer here is an article overwritten by another article's rewrite.
// Request
{
"event": "article.lookup",
"contract_version": 2,
"timestamp": "2026-09-21T09:29:59.000Z",
"data": { "target_url": "https://site.example/blog/kettlebell-swings-for-cyclists" }
}
// Response. Every field must be a nonempty string, and target.url must
// canonicalize to the URL that was asked for — Hoxigen rejects a mismatch.
HTTP 200
{
"received": true,
"contract_version": 2,
"target": {
"id": "412",
"url": "https://site.example/blog/kettlebell-swings-for-cyclists",
"revision_id": "b7f1c0…",
"title": "Kettlebell swings for cyclists",
"content_html": "<h2>Why</h2><p>Because hills.</p>"
}
}
// No article at that exact URL: any non-2xx. Never guess by title or slug.
HTTP 404
{ "error": "Article not found" }Replace that exact article, never insert. Keep your stored id, slug and public URL even when the incoming draft carries different ones. Compare base_revision_id against the current revision and write the content and the new revision in one transaction. If revision_id is already the current revision, it is a retry: answer 200 without reapplying anything.
// Request — every article.published field, plus the four target fields.
{
"event": "article.updated",
"contract_version": 2,
"timestamp": "2026-09-21T09:30:00.000Z",
"data": {
"id": "cmf0hoxigenrefresh7",
"title": "Kettlebell swings for cyclists (2026)",
"content_html": "<h2>Why</h2><p>Because hills, still.</p>",
"…": "all other article.published fields",
"target_id": "412",
"target_url": "https://site.example/blog/kettlebell-swings-for-cyclists",
"base_revision_id": "b7f1c0…",
"revision_id": "cmf0hoxigenrefresh7"
}
}
// Written, and the new revision stored, in one transaction.
HTTP 200
{ "received": true, "contract_version": 2, "revision_id": "cmf0hoxigenrefresh7" }
// The article changed since the draft was written. Change nothing.
HTTP 409
{ "error": "Article revision conflict" }| Extra data field | Type | What it means |
|---|---|---|
| target_id | string | The id your own article.lookup response returned. The article to overwrite, by your identifier. |
| target_url | string | The canonical public URL of that article. Both this and target_id must point at the same stored article, or refuse the write. |
| base_revision_id | string | The revision your lookup reported. If the article has changed since, this no longer matches and you must answer 409. |
| revision_id | string | The revision to store after a successful write. Always equal to data.id, and unique to this refresh draft. |
The revision is your compare-and-set token. If someone edits the article in your admin and the revision stays put, Hoxigen's next update will overwrite that edit believing nothing changed. A content hash is the easy way to get this right.
Start here. It is a complete Version 1 receiver — signature check, size cap, dedup key, honest failure on anything it does not implement — with one function left for you to supply. Publish new articles today; add lookup and update when you want refreshes.
// app/api/hoxigen/route.js — a complete Version 1 receiver.
// Supply saveArticle(); everything else here is the whole contract.
import crypto from "node:crypto";
import { saveArticle } from "@/lib/your-article-store";
import { verifyHoxigenSignature } from "@/lib/hoxigen-signature";
const MAX_BODY = 1_000_000;
export async function POST(req) {
// 1. Raw bytes first. The signature covers what was sent, not what JSON.parse
// gives back, and an unsigned request must not be able to cost you a parse.
const raw = await req.text();
if (raw.length > MAX_BODY) {
return Response.json({ error: "Payload too large" }, { status: 413 });
}
const secret = process.env.HOXIGEN_WEBHOOK_SECRET;
if (!secret || !verifyHoxigenSignature(raw, req.headers.get("x-hoxigen-signature"), secret)) {
return Response.json({ error: "Invalid signature" }, { status: 401 });
}
// 2. Only now is it safe to parse.
let payload;
try {
payload = JSON.parse(raw);
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const data = payload?.data ?? {};
if (payload?.event === "webhook.test") {
// Version 1: acknowledge, advertise nothing. Claiming a capability you have
// not implemented is how a refresh overwrites the wrong article.
return Response.json({ received: true });
}
if (payload?.event === "article.published") {
if (typeof data.id !== "string" || !data.id.trim()) {
return Response.json({ error: "Missing data.id" }, { status: 400 });
}
// Deduplicate on data.id: retries redeliver the same article, and the stored
// id, slug and public URL must not move when they do. Commit before you
// answer — a 200 tells Hoxigen the article is durably yours.
await saveArticle(data);
return Response.json({ received: true });
}
// Anything you do not implement fails loudly. A 200 here would tell Hoxigen a
// refresh landed when nothing happened.
return Response.json({ error: "Unsupported event" }, { status: 400 });
}The routing for all four events. The transactional parts are left as calls into your own store, because the compare-and-set has to happen where your data lives.
// app/api/hoxigen-webhook/route.js (Next.js App Router)
// Implement this adapter with your existing DB, validation and transactions.
// Do not advertise Version 2 until all three methods satisfy the receiver spec.
import crypto from "node:crypto";
import { articles, validateWebhookPayload } from "@/lib/your-article-store";
export async function POST(req) {
const raw = await req.text();
const expected = "sha256=" + crypto
.createHmac("sha256", process.env.HOXIGEN_WEBHOOK_SECRET)
.update(raw).digest("hex");
const a = Buffer.from(req.headers.get("x-hoxigen-signature") || "");
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return Response.json({ error: "Invalid signature" }, { status: 401 });
}
let payload;
try {
payload = validateWebhookPayload(JSON.parse(raw));
} catch {
return Response.json({ error: "Invalid payload" }, { status: 400 });
}
const { event, data } = payload;
if (event === "webhook.test") {
return Response.json({ received: true, contract_version: 2,
capabilities: ["article.published", "article.lookup", "article.updated"] });
}
if (event === "article.published") {
await articles.publishIdempotently(data); // preserve existing ID, slug, URL
return Response.json({ received: true });
}
if (payload.contract_version !== 2) {
return Response.json({ error: "Version 2 required" }, { status: 400 });
}
if (event === "article.lookup") {
const target = await articles.findExactlyByPublicUrl(data.target_url);
if (!target) return Response.json({ error: "Not found" }, { status: 404 });
return Response.json({ received: true, contract_version: 2, target });
}
if (event === "article.updated") {
// Adapter atomically checks target ID + URL, already-applied revision,
// then base revision; writes content + revision without changing the URL.
const result = await articles.updateIfRevisionMatches(data);
if (result === "conflict") {
return Response.json({ error: "Article revision conflict" }, { status: 409 });
}
if (result !== "committed" && result !== "identical-retry") {
return Response.json({ error: "Not found" }, { status: 404 });
}
return Response.json({ received: true, contract_version: 2,
revision_id: data.revision_id });
}
return Response.json({ error: "Unsupported event" }, { status: 400 });
}Hoxigen ships a WordPress plugin that is a complete Version 2 receiver for this exact contract — signature verification, lookup by permalink, revision conflicts and all. Nothing on this page needs implementing. The WordPress integration
The connector holds a prompt that implements this whole contract against your own codebase, already carrying your endpoint's signing secret. Paste it into Claude Code, Cursor or Copilot. The receiver spec below is what it is working from.
The same text the product hands to an agent, reproduced here so you can read it before signing up for anything.
Hoxigen blog webhook — Version 2 receiver spec
Transport and authentication:
HTTP POST to the existing endpoint; Content-Type: application/json.
X-Hoxigen-Event equals the payload event. Version 2 requests include
X-Hoxigen-Contract: 2 and top-level contract_version: 2.
X-Hoxigen-Timestamp: <unix seconds>; payload timestamp: ISO 8601 string.
X-Hoxigen-Signature: sha256=<hex>, HMAC-SHA256 of the raw request body
with HOXIGEN_WEBHOOK_SECRET. Read raw bytes before JSON parsing, compare
signatures in constant time, and return 401 on mismatch. Never expose secrets.
Validate event, field types and nonempty IDs/URLs/revisions before writing.
Return 400 for invalid requests; never acknowledge a write before it commits.
webhook.test (no writes):
Request: { "event": "webhook.test", "contract_version": 2, "timestamp": "<ISO>",
"data": { "id": "<test ID>", "message": "<test message>",
"capabilities": ["article.published", "article.lookup", "article.updated"] } }
HTTP 200: { "received": true, "contract_version": 2,
"capabilities": ["article.published", "article.lookup", "article.updated"] }
Advertise only capabilities that are fully implemented and tested.
article.published (Version 1 compatible):
Accept the existing payload without contract_version or X-Hoxigen-Contract.
data contains id, title, slug, content_html, content_markdown, meta_description,
focus_keyword, featured_image_url, tags, author, published_at and json_ld.
Deduplicate by data.id. Persist before returning HTTP 200 { "received": true }.
Keep the stored article ID and public URL stable on redelivery. Store an opaque,
nonempty revision_id that changes whenever content is edited, including CMS edits.
Inject json_ld script blocks into the page head. Rebuild/cache work may run async.
article.lookup (read only):
Request: { "event": "article.lookup", "contract_version": 2, "timestamp": "<ISO>",
"data": { "target_url": "https://site.example/blog/post" } }
Resolve exactly one article by its public URL. Canonicalization removes the
fragment and one trailing path slash only; origin, path and query must match.
Use HTTP(S) URLs without credentials. Never guess by title, slug similarity,
or a URL on another origin. Missing or ambiguous targets fail with non-2xx.
HTTP 200: { "received": true, "contract_version": 2, "target": {
"id": "<stored article ID>", "url": "https://site.example/blog/post",
"revision_id": "<current revision>", "title": "<title>",
"content_html": "<article HTML>" } }
All target fields must be nonempty strings. Keep responses below 1,000,000 bytes.
article.updated (replace the exact existing article, never insert):
Request: { "event": "article.updated", "contract_version": 2, "timestamp": "<ISO>",
"data": { <all article.published fields>,
"id": "<refresh draft ID>", "target_id": "<stored article ID>",
"target_url": "https://site.example/blog/post",
"base_revision_id": "<revision returned by lookup>",
"revision_id": "<refresh draft ID>" } }
data.id and data.revision_id must match. target_id AND target_url must identify
the same existing article. Preserve its stored ID, slug and public URL even
when the incoming draft's id or slug differs. Replace supported content fields.
Atomically compare base_revision_id with the current revision and write the
content plus revision_id in one transaction/conditional write. Every CMS edit
must change that revision too. If the base no longer matches, return HTTP 409
{ "error": "Article revision conflict" } without changing anything.
Check revision_id before comparing the base: if it is already the current
revision, return success without reapplying content. Otherwise a stale base
returns 409, including an old retry after a newer edit. This makes retries
idempotent without rolling back later edits. Never reuse a revision_id for
different content; each refresh draft has its own immutable revision ID.
Only after durable commit, HTTP 200:
{ "received": true, "contract_version": 2, "revision_id": "<refresh draft ID>" }
Never return a generic Version 1 acknowledgement for lookup or update.
Failed deliveries retry after 1m, 5m, 30m, 2h and 6h; unsupported events fail
with non-2xx instead of pretending an update succeeded.Everything on this page is read out of the code that runs today. Found something that does not match what your endpoint actually receives? support@hoxigen.app — we will correct the page.
Connect the webhook when the receiver is ready — nothing publishes before that.