Skip to content
Integrations / Any site, by signed webhook

The publishing webhook, in full.

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.

The request

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.

HeaderTypeWhat it means
Content-Typeapplication/jsonAlways. The body is UTF-8 JSON.
X-Hoxigen-Signaturesha256=<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-Eventarticle.published | webhook.test | article.lookup | article.updatedAlways equals the event in the body. Treat the body as the source of truth and the header as a cheap pre-filter.
X-Hoxigen-Timestampunix secondsThe 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-Contract2Only 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.
You have thirty seconds

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.

Redirects are not followed on updates

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.

Your response is read up to 1 MB

On the connection test, a lookup or an update, anything past a million bytes is an error. On a new publish it is ignored: the article counts as delivered, but Hoxigen cannot read its URL. Nothing in the contract needs a large response.

One secret, only yours

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.

A real article.published request

# 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>"
  }
}

# Response, once the article is stored. A Version 3 receiver always sends the
# article's id and public url; Hoxigen links the article to that live page.
# Version 1 and 2 receivers may answer a bare 2xx.
HTTP/1.1 200 OK
Content-Type: application/json

{ "received": true, "id": "post-4182", "url": "https://site.example/blog/kettlebell-swings-for-cyclists" }

The payload

The body is always { event, timestamp, data }, with contract_version: 2 added on the test, lookup and update events (the request format, unchanged in Version 3). Every key below is always present on article.published — no field is ever omitted, and only one of them is ever null.

data fieldTypeWhat it means
idstringHoxigen's id for the article. Never empty, and identical on every retry of the same delivery — this is your deduplication key.
titlestringEmpty string if the article somehow has no title. The WordPress plugin stores '(untitled)' rather than an empty post title.
slugstringA 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_htmlstringThe article body as HTML. This is the field to render.
content_markdownstringThe same body as Markdown, so a Markdown site does not have to parse HTML back out. Empty string when no Markdown version exists.
meta_descriptionstringMay be empty.
focus_keywordstringMay be empty.
featured_image_urlstring | nullThe 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.
tagsstring[]May be an empty array. Never null.
authorstringThe byline from the app profile, falling back to the connector's legacy author field and then the app name.
published_atstringISO 8601. Always identical to the top-level timestamp.
json_ldstringAlready-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.
json_ld is HTML, not JSON

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.

The optional view pixel rides in the body

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.

Every new article also ends with an invisible HTML comment, <!-- hx:item:<id> -->, so Hoxigen can recognise the article on your live site. Readers never see it; keep it in the page.

Verify the signature

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.

  1. 1
    Read the raw body first

    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.

  2. 2
    HMAC-SHA256 the raw body with your secret

    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.

  3. 3
    Compare in constant time

    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.

  4. 4
    401 on a mismatch, and only then parse

    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);
}
The timestamp header is not signed

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.

Hex comes through lowercase

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.

What we expect back, and what we do when it does not come

For a publish, any 2xx means the article is yours. The only thing read from its body is url, the article's public address, which Hoxigen shows as the article's live link: a Version 3 receiver always sends it, older ones may not. The lookup and update events are the exception: their responses are parsed and checked, and an acknowledgement that does not match is treated as a failure.

2xx — accepted

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. Add { "received": true, "url": "<public address>" } so the article links to its live page.

Any other status — retried

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.

409 on an update — not retried

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.

6 attempts, then it stops

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.

What Hoxigen keeps about each delivery

One row per queued article, visible to us when you write in about a delivery.

ColumnTypeWhat it means
urlstringThe 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.
statuspending | sending | success | failedA row stuck in sending for ten minutes — a deploy mid-flight, say — is returned to pending and picked up again.
attemptsintCounts up to 6, then the delivery is failed for good.
lastStatusCodeint?The HTTP status of the last attempt, when there was one.
lastErrorstring?The failure text, including the first 200 characters of your response body. Worth putting something diagnostic there.
nextRetryAtdatetime?When the next attempt becomes due. Null once the delivery has settled.
payloadjsonThe exact object that gets serialized and signed on each attempt, so a retry is byte-for-byte the same article.

Versions 1, 2 and 3

Version 2 adds 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. Version 3 adds the link: your answer to each published article names its public address, so Hoxigen can open the live page from the article.

Version 1

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.

What it misses

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.

Version 2

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.

What it misses

The article's address. Hoxigen cannot link an article to its live page unless the article.published answer names it. Refresh needs it to be earned: Hoxigen sends a signed webhook.test before every refresh and only proceeds when the response reports contract_version 2 or 3 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.

Version 3

Everything in Version 2, and every article.published answer carries the stored article's id and absolute public url, so each article links to its live page in Hoxigen. Requests do not change: lookup and update still arrive with contract_version 2. Only the answers say 3.

What it misses

Nothing. The Blog connector shows a Version 2 endpoint an upgrade with the agent prompt, and Version 3 as up to date.

webhook.test — how Hoxigen learns which one you are

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 3 receiver (contract_version 2 here reads
// as Version 2). Anything else — including a bare 200, or 200
// {"received":true} — is read as Version 1.
HTTP 200
{
  "received": true,
  "contract_version": 3,
  "capabilities": ["article.published", "article.lookup", "article.updated"]
}

article.lookup

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": 3,
  "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" }

article.updated

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": 3, "revision_id": "cmf0hoxigenrefresh7" }

// The article changed since the draft was written. Change nothing.
HTTP 409
{ "error": "Article revision conflict" }
Extra data fieldTypeWhat it means
target_idstringThe id your own article.lookup response returned. The article to overwrite, by your identifier.
target_urlstringThe canonical public URL of that article. Both this and target_id must point at the same stored article, or refuse the write.
base_revision_idstringThe revision your lookup reported. If the article has changed since, this no longer matches and you must answer 409.
revision_idstringThe revision to store after a successful write. Always equal to data.id, and unique to this refresh draft.
Every CMS edit must move the revision

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.

A receiver you can paste

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. The url is the
    // article's public address: Hoxigen links the article to it.
    const { url } = await saveArticle(data);
    return Response.json({ received: true, url });
  }

  // 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 same endpoint, at Version 3

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 3 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: 3,
      capabilities: ["article.published", "article.lookup", "article.updated"] });
  }
  if (event === "article.published") {
    // preserve existing ID, slug, URL; answer with the article's public address
    const stored = await articles.publishIdempotently(data);
    return Response.json({ received: true, id: stored.id, url: stored.url });
  }
  // requests keep the Version 2 format: only the answers say 3
  if (payload.contract_version !== 2) {
    return Response.json({ error: "Version 2 request format 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: 3, 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: 3,
      revision_id: data.revision_id });
  }
  return Response.json({ error: "Unsupported event" }, { status: 400 });
}

Or do not write one at all

On WordPress, install the plugin

Hoxigen ships a WordPress plugin that is a complete Version 3 receiver for this exact contract — signature verification, lookup by permalink, revision conflicts and all. Nothing on this page needs implementing. The WordPress integration

Have an agent build it

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 full Version 3 receiver spec

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 3 receiver spec

Versions: Version 2 added article.lookup and article.updated (refresh in place).
Version 3 adds the article's public URL to every article.published answer.
Requests are the same in both: lookup and update carry contract_version: 2.
A receiver states its version in its answers, above all to webhook.test.

Transport and authentication:
  HTTP POST to the existing endpoint; Content-Type: application/json.
  X-Hoxigen-Event equals the payload event. Lookup, update and test requests
  include X-Hoxigen-Contract: 2 and top-level contract_version: 2 (the request
  format, unchanged in Version 3). Accept exactly that value.
  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": 3,
    "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, then return HTTP 200
    { "received": true, "id": "<stored article ID>", "url": "https://site.example/blog/post" }
  url is the article's absolute public address (HTTP(S), no credentials); Hoxigen
  shows it as the article's live link. A redelivery answers with the same ID and URL.
  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": 3, "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": 3, "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.

Your endpoint, your database, your URLs.

Connect the webhook when the receiver is ready — nothing publishes before that.