Docs
Webhook delivery
Every published article is POSTed as JSON to each enabled webhook endpoint of the brand. Nothing is delivered until you approve and publish the post. Content API guide.
Events
article.published— first time the post enters published.article.updated— an already-published post is edited and saved as published again.
Payload
Markdown is canonical (body_markdown). body_html is included pre-rendered.
{
"id": "c9e4f6a2-…",
"type": "article.published",
"created_at": "2026-08-23T10:15:00.000Z",
"workspace_id": "…",
"brand": { "id": "…", "name": "Acme" },
"article": {
"id": "…",
"title": "How Acme onboards a workspace",
"slug": "acme-onboarding-workspace",
"meta_description": "…",
"excerpt": "…",
"tags": ["onboarding"],
"group": { "slug": "product-guides", "label": "Product Guides" },
"version": 3,
"published_at": "2026-08-23T10:14:58.000Z",
"updated_at": "2026-08-23T10:15:00.000Z",
"body_markdown": "# …",
"body_html": "<h1>…</h1>"
}
}Signatures
x-seogeoaeo-signature: t=1755939300, v1=5f8a…v1 is HMAC-SHA256 of the unix timestamp, a dot, then the raw request body, using the signing secret. Reject timestamps older than five minutes.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyWebhook(secret, header, rawBody) {
const parts = Object.fromEntries(
header.split(",").map((pair) => pair.trim().split("=")),
);
const age = Math.floor(Date.now() / 1000) - Number(parts.t);
if (Math.abs(age) > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1, "hex"));
}Retries
Attempts happen immediately after publish, then back off 1m → 5m → 30m → 2h (five attempts total). Success is a 2xx within ten seconds. After twenty consecutive failures the endpoint is paused. Resume from Settings → Delivery; resuming issues a new signing secret. Failed rows can be sent again with Redeliver. Delivery log rows older than 30 days are removed.