Documentation
Delivery webhook: receiving articles on your own site
The signed webhook that sends AutoPSEO articles to your site: headers, each event and payload, the answers to give, retries, waiting commands and IndexNow.
AutoPSEO sends every article it publishes, updates or takes down to your own site as a signed HTTPS POST. Your site stores the article and renders it with its own templates, so the content lives in your system and keeps working if you ever leave. This page is the reference for the developer who builds the receiver: signing, every event with its payload, the answers AutoPSEO reads, retries, waiting commands and IndexNow.
Set up the webhook
- Open the property's Content page and go to Where articles are published.
- Fill in Webhook address on your site. It must be an
https://URL on a public host name: IP addresses andlocalhostare refused. AutoPSEO does not follow redirects, so give the final address. - Fill in Your site's address, for example
https://example.com. Each content type's path hangs under it, so an article of a type at/answerslives athttps://example.com/answers/<slug>. - Choose Save and send a test. AutoPSEO creates a signing secret (
whsec_followed by 48 hex characters) and pings your address. A receiver that checks signatures fails this first ping, because it does not have the secret yet. - Reveal the Signing secret with Show or Copy, store it as a secret on your site, then choose Send a test.
- Read What your site handles: each command shows Connected, Not connected, Declared, not tested or Not tested.
New properties start with AutoPSEO hosting the articles at its own address (see Hosting), and hosting and the webhook can run side by side. Once your site shows the articles, choose Stop hosting on AutoPSEO: every live article moves to your site's address and is sent to your webhook as article.published. That button needs a webhook that answered its last test and your site's address. While hosting is off, the webhook cannot be removed.
New secret replaces the signing secret at once and the old one stops verifying immediately. Put the new secret on your site straight after you make it.
AutoPSEO queues deliveries only for a webhook whose last ping was answered with a 2xx. A failed ping (on save, on Send a test, or at the start of a site scan) marks the webhook as in error, and articles published while it is in error are not queued for it. Keep ping answering, and choose Send a test after you fix an outage.
Headers and signature
Every request is a POST with a JSON body and these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | AutoPSEO-Webhook/1 |
X-AutoPSEO-Event | The event name, also in the body as event |
X-AutoPSEO-Delivery | The delivery id, also in the body as id. The same on every retry of one delivery |
X-AutoPSEO-Timestamp | Unix time in seconds when this attempt was signed |
X-AutoPSEO-Signature | sha256= followed by the hex HMAC-SHA256 of <timestamp>.<raw body> |
To verify a request:
- Take the timestamp header, a full stop, and the raw request body exactly as it arrived, before any JSON parsing.
- Compute HMAC-SHA256 over that string. The key is the whole signing secret as the app shows it,
whsec_prefix included, as UTF-8 bytes. - Hex-encode the result and compare it with the part of
X-AutoPSEO-Signatureaftersha256=, in constant time. Reject the request when they differ.
Each attempt is signed afresh with its own timestamp, so you can refuse requests whose timestamp is far from your clock (five minutes is a common window) without breaking retries. HMAC itself is defined in RFC 2104.
Every body carries id, event, occurredAt (ISO 8601, UTC) and site: { propertyId, domain }.
Events
| Event | Kind | When AutoPSEO sends it |
|---|---|---|
ping | Check | When you save the webhook, on Send a test, and at the start of every site scan |
test | Check | Right after a successful ping, one per command |
article.published | Delivery | An article goes live for the first time; every live article when you stop hosting |
article.updated | Delivery | A new revision goes live (refresh, optimisation, rollback, publishing again after a takedown, languages added) or a cover was added |
article.unpublished | Delivery | An article is taken down, or merged into another page |
path.add | Delivery | A new value of a typed content type was approved |
path.remove | Delivery | Someone asked for a value's path to be taken down |
path.check | Call | Before AutoPSEO plans a page at an address your site has no page at, or proposes a new value |
site.export | Call | During a site scan, in the daily topic sync, and before AutoPSEO changes a live article |
articles.adopted | Call | After a scan imported articles your site exported |
site.handover | Call | When someone hands the writing of content types to AutoPSEO, or back |
Deliveries go through an outbox: they are recorded in the same database batch as the change, sent at once, retried, and can wait for your site. Calls are plain request and response: AutoPSEO reads the answer on the spot. When the path commands are sent is explained in Typed pages and new paths.
The article payload
article.published and article.updated carry the whole article, every language included:
{
"id": "0f6d2c1e-7a51-4c1b-9d0e-5b8f3a2e9c41",
"event": "article.published",
"occurredAt": "2026-10-12T08:30:00.000Z",
"site": { "propertyId": "prop_123", "domain": "example.com" },
"article": {
"id": "a4c09e2b-6f1d-4e57-8b3a-1d2e3f4a5b6c",
"slug": "plan-a-content-calendar",
"path": "/answers/plan-a-content-calendar",
"type": { "key": "answers", "name": "Answers", "path": "/answers" },
"lang": "en",
"langs": ["en", "de"],
"rev": 1,
"url": "https://example.com/answers/plan-a-content-calendar",
"datePublished": "2026-10-12",
"dateModified": "2026-10-12",
"contentHash": "9f2c4e1a7b3d5f6081a2c3d4",
"writer": "platform",
"category": "Planning",
"copy": {
"title": "How to plan a content calendar",
"metaDescription": "A content calendar in five steps, with a template.",
"h1": "How to plan a content calendar",
"directAnswer": "List your topics, give each a date and an owner, then review the plan weekly.",
"keyTakeaways": ["Plan a quarter at a time.", "Give every piece one owner."],
"sections": [{ "heading": "Start from your topics", "bodyHtml": "<p>Begin with the searches you want to win.</p>" }],
"faq": [{ "q": "How far ahead should I plan?", "a": "One quarter is enough for most teams." }],
"sources": [{ "title": "Creating helpful content", "url": "https://developers.google.com/search/docs/fundamentals/creating-helpful-content" }]
},
"translations": { "de": { "title": "Einen Redaktionsplan erstellen", "h1": "Einen Redaktionsplan erstellen" } },
"cover": {
"url": "https://app.autopseo.com/media/covers/prop_123/<sha256>.jpg",
"sha256": "<sha256>",
"contentType": "image/jpeg",
"width": 1536,
"height": 1024
}
}
}
The German entry is shortened here; each translation has the same shape as copy.
| Field | Meaning |
|---|---|
id | AutoPSEO's id for the article. It never changes, so it is a safe key |
slug | The last part of the address. On typed pages it holds segments joined by /, such as google/software-engineer |
path | The address relative to your site: the type's path plus the slug. Fixed once the page exists |
type | { key, name, path }. key is stable; path is where the type's articles live (empty for the site root) |
lang, langs | The primary language and every language of this revision |
rev | The revision number |
url | The full address as AutoPSEO knows it: your site's once hosting is off, or the url your receiver last answered |
datePublished, dateModified | YYYY-MM-DD. The publication date stays fixed through later revisions |
contentHash | A fingerprint of the revision |
writer | Who wrote the revision: platform (AutoPSEO), mcp (your own AI), user, import (your site's own text) or aggregate (a list page) |
copy | The article in its primary language |
translations | The other languages, keyed by language code |
category | Your site's category for it, when your format keeps categories |
topic | { externalId, keyword } when the article was written for a topic your site handed over, so you can close that topic |
parts | On typed pages, the value of each part: { "company": "google", "role": "software-engineer" } |
kind | "list" on a list page. Its copy is { title, h1, items: [{ from, text }] }, where from is the path of the page each item comes from, and parts names the value it gathers |
cover | A cover AutoPSEO made for the article. Fetch it once and keep your own copy; the address never changes because the file name is the image's SHA-256 |
basedOn | On article.updated: your page's version this revision was written from (see conflicts below) |
redirectTo | On article.unpublished after a merge: where the old address now leads. Answer it with a 301 and take nothing new there |
article.unpublished carries the same article fields except copy, translations, category and cover.
Inside copy
| Field | Meaning |
|---|---|
title, metaDescription, h1 | The page title, meta description and heading |
directAnswer | The short answer shown first |
keyTakeaways | A list of strings |
sections | [{ heading, bodyHtml }] |
faq | [{ q, a }] |
sources | [{ title, url, lastVerified? }] |
question | Optional: the searcher's question in their words, when your format asks for it |
category | Optional: one of your site's categories |
lists | Optional: plain-text lists by key, for content types that ask for them (an interview guide's sample questions) |
In articles AutoPSEO writes, bodyHtml uses only p, ul, li, strong, em, a and code, and links keep only their href. Text taken over from your own site keeps your HTML. Run it through your own sanitiser too if your templates demand one.
What your receiver answers
Any 2xx completes a delivery. AutoPSEO also reads an optional JSON object from the answer body; keep it under 64 KB, because a larger answer counts as a failure.
| Field | Effect |
|---|---|
url | The https:// address where the article now lives. AutoPSEO records it as the article's URL |
version | Your page's version, an opaque string of at most 128 characters. It comes back as basedOn with the next update |
urls | The https:// addresses this delivery changed, for IndexNow (every language, or the addresses you stopped serving). [] means nothing changed |
cover | "wanted" when you have no cover for the article and the delivery carried none. AutoPSEO makes one and sends it in an article.updated |
Error answers mean different things:
| Your answer | AutoPSEO reads it as | What happens |
|---|---|---|
A 4xx with { "error": "unknown_event" }, or a 501 | You do not handle this command yet | The delivery waits, without retries |
A 409 with { "error": "conflict" } | The update was written from a version your page no longer has | The delivery fails without retries, and the article is read again from your site before its next change |
A 4xx other than 408 or 429 with { "error": "<code>", "details": [] } to path.add or path.remove | A refusal with your reason | Recorded; see Typed pages and new paths |
| Anything else: no answer within 10 seconds, a network error, a redirect, 408, 429, 5xx or another 4xx | A temporary failure | Retried with backoff |
Retries and superseded deliveries
A delivery is sent as soon as the change is saved, and a five-minute schedule sends whatever is due, oldest first. After a temporary failure AutoPSEO waits 1 minute, then 5 minutes, 15 minutes, 1 hour, 3 hours, 6 hours and 12 hours between attempts. The eighth failed attempt marks the delivery as failed, and the Content page counts it in a red badge next to the webhook.
When a newer delivery for the same article exists, an older one still pending is marked superseded and never sent. Every delivery carries the whole article, so the newest one is all your site needs. Path commands about the same value work the same way.
Your receiver can see one delivery twice, for instance when it stored the article but answered after the 10-second timeout. Deduplicate on X-AutoPSEO-Delivery, or make every write idempotent by storing on article.id. article.updated can also reach an article you deleted after an article.unpublished, because publishing a taken-down article again sends an update: treat updates as "create or replace".
Commands your site does not handle yet
A site can implement the protocol one command at a time. AutoPSEO keeps the state of each command and learns it three ways:
- Tests. After every successful ping, AutoPSEO sends one
testper command. The body is{ id, event: "test", occurredAt, site, test: { event: "<command>", ... } }, wheretestholds a sample shaped like the real command and about<type path>/autopseo-connection-test, an address no site has. Check it as you would the real thing, change nothing, and answer with a 2xx:{ "test": "<command>", "ok": true }for Connected,{ "test": "<command>", "ok": false, "error": "unknown_event" }for Not connected. An answer withok: falseand another error code, such as a takedown of an address you do not have, still counts as connected. The sample sits undertest, never at the top level, so a receiver that knows nothing of tests finds no article in it. - Real deliveries and calls. A 2xx marks the command connected;
unknown_eventmarks it not connected. - The ping answer. Commands your answer lists in
autopseo.eventsshow as Declared, not tested until a test or a delivery says more.
A delivery for a command that is not connected waits. The Content page lists what waits, for example "Your site does not take articles down yet", with a Done on the site button for anything you carried out by hand; AutoPSEO then never sends that one. When a later test or delivery finds the command connected, everything waiting on it is sent.
The ping answer
Answer ping with a 2xx. A site that only receives articles needs nothing more. To tell AutoPSEO what else your site does, include an autopseo object:
{
"ok": true,
"autopseo": {
"version": 1,
"events": ["article.published", "article.updated", "article.unpublished"],
"covers": true,
"indexNow": { "key": "3f1c9a7e5b2d4f60", "keyLocation": "https://example.com/3f1c9a7e5b2d4f60.txt" },
"export": ["articles", "topics", "profile"],
"adopt": true,
"handover": true,
"types": ["answers"],
"writer": "site"
}
}
| Field | Meaning |
|---|---|
version | 1. The object is ignored without a version of 1 or more |
events | The commands you handle, shown as declared until tested |
covers | true when your pages show a cover with each article. AutoPSEO's writer then makes the cover while it writes, so the first delivery carries it, and your own AI is asked to supply one. See Covers |
indexNow | { key, keyLocation? }: the IndexNow key you serve (8 to 128 letters, digits and dashes) and, when the key file is not at /<key>.txt, its https:// location |
export | What site.export hands over: articles, topics, profile |
adopt | You accept articles.adopted, so a full scan can take over your existing articles |
handover | You accept site.handover |
types | The content type keys you accept (up to 50) |
writer | Who writes your new articles today: site or autopseo |
IndexNow for the addresses you serve
When your ping answer declares indexNow, AutoPSEO pings IndexNow at api.indexnow.org after the deliveries you accept, and your site pings nothing itself.
- It sends the
urlsyour answer listed. Withouturlsit sends your answer'surl, else the article's URL.urls: []sends nothing. - Only addresses on your own host go in, as the IndexNow protocol requires: the host of
keyLocation, else the host of your site's address. - Each delivery run sends one ping per site, with up to 10,000 addresses.
- 200 and 202 are accepted. A 429, a 5xx or no answer keeps the addresses and tries again after an hour, doubling each time up to a day (longer when
Retry-Afterasks), with whatever later deliveries added. A 403 (key not valid) or 422 (addresses not on the host) is recorded and dropped. - The Content page shows the last ping: when, how many addresses, the HTTP status and the next try.
- While AutoPSEO also hosts the articles, the hosted pages are pinged with AutoPSEO's own key, and yours is not used.
The IndexNow tool under Indexing is a separate, manual submitter; see IndexNow.
Conflicts: your site's text is the original
Your answer's version (or the version of an article in your export) is what AutoPSEO believes your page is at. Every article.updated carries it as basedOn. If the page changed on your site since, for example after an editor's correction, answer 409 with { "error": "conflict" } and store nothing. The delivery fails without retries.
Before a queued change to a live article is written, AutoPSEO reads that article again from your site with site.export and paths, when your site handles site.export. If your version moved on, your text becomes the article's next revision (writer import), and the update that follows carries your current version as basedOn.
path.add and path.remove
These two deliveries concern one value of a typed content type, for example a company in /interviews/{company}/{role}. They carry a path object instead of article:
{
"id": "7c1e9d2a-3b4f-4e8a-9c0d-1e2f3a4b5c6d",
"event": "path.add",
"occurredAt": "2026-10-12T08:30:00.000Z",
"site": { "propertyId": "prop_123", "domain": "example.com" },
"path": {
"path": "/interviews/jane-street",
"type": { "key": "interviews", "name": "Interviews", "path": "/interviews" },
"part": "company",
"value": {
"value": "jane-street",
"name": "Jane Street",
"aliases": [],
"url": "https://<its own site>/",
"sameAs": ["https://<its careers page>/"],
"description": "<what it is, in a few words>",
"facts": [
{ "text": "<one sentence, as the source states it>", "source": "https://<the page that says it>", "lastVerified": "2026-10-01", "tier": "official" }
]
},
"takes": { "role": ["software-engineer"] }
}
}
| Field | Meaning |
|---|---|
path | The value's own address on your site |
type | The content type, as { key, name, path } |
part | The part of the address the value fills |
value | { value, name }, plus on path.add the aliases, url, sameAs, description and facts AutoPSEO's writer gathered. Each fact has text, source, lastVerified and a tier of official or reported |
takes | On path.add: the values of the other parts people search for with it |
redirectTo | On path.remove: where the value's addresses should lead from now on, with a 301 |
Answer a 2xx when you took the command; on path.add you may add the url where the value now lives, and on path.remove the urls you stopped serving, which go to IndexNow. To refuse, answer a 4xx with { "error": "<code>", "details": ["<why>"] }. On path.add, invalid_value sends the value back to the writer with your reason (the third refusal is final) and any other code refuses it for good. On path.remove, not_found counts as done, and any other code, such as 409 in_use, leaves everything as it was. Marking a waiting path command Done on the site counts as your site's yes.
Calls: requests AutoPSEO reads the answer to
Calls are signed like deliveries. A call waits up to 60 seconds by default and 30 seconds for re-reads and path checks, and reads answers of up to 24 MB. Any answer other than a 2xx fails the call.
site.export
The request carries export: { what, cursor, limit }, plus since (an ISO time) for topics or paths for a re-read. Answer { "items": [], "next": "<cursor>" }; AutoPSEO asks again with cursor set to your next until it is empty or null.
what: "articles"runs in full site scans, 10 per page, when you declaredexportwitharticlesandadopt: true. A 400 answer means "not supported". Withpaths: ["/answers/x"]andlimit: 1it re-reads one article; answer with that article only.what: "topics"runs in every scan and in the daily sync, 50 per page; the daily sync sendssince, the time of the last pull.what: "profile"runs in every scan. Answer{ "profile": { types, languages, rules, categories, format } }, each optional:typesas[{ key, name, path }], andruleswithbrandTerms,synonyms,noise,onTopicandoffTopic.
An exported article needs externalId, a slug (letters and digits of any script and hyphens, at most 200 characters, segments joined by /), a path ending in that slug, and copy with at least title, h1 and one section. Optional fields: lang (defaults to en), type (a content type key), url, keyword, category, datePublished, dateModified, version, translations, owner (AutoPSEO's article id, once adopted) and maintenance: { kind: "refresh" or "optimize", reason }, which queues that task. Each exported article becomes a managed article with your text as its first revision, and nothing is sent back. Exporting again is safe, and an article AutoPSEO took down never comes back through an export.
A topic item is { keyword, status?, volume?, source?, externalId?, type?, reason? }, where status is queued, planned or rejected.
articles.adopted
After importing, AutoPSEO asks you to let it manage the new articles: { "articles": [{ "externalId", "articleId", "slug", "path" }] }. Answer { "adopted": ["<externalId>"], "refused": [{ "externalId": "<id>", "reason": "<why>" }] }. An article you do not confirm is removed from AutoPSEO again, unless AutoPSEO has already written a revision of it.
site.handover
{ "handover": { "types": ["answers"], "writer": "autopseo" } }, sent when someone chooses Let AutoPSEO write them or Hand writing back to the site on the property's Your site page. Answer { "ok": true, "writer": "autopseo" }. After a hand-over to AutoPSEO, your site passes the topics it vets to AutoPSEO instead of writing them.
path.check
{ "paths": [{ "path": "/interviews/google/data-engineer" }, { "path": "/interviews/jane-street", "new": { "part": "company", "value": "jane-street", "name": "Jane Street" } }] }. Answer one result per path: { "results": [{ "path": "/interviews/google/data-engineer", "ok": true }, { "path": "/interviews/jane-street", "ok": false, "error": "not_allowed", "details": ["We only list companies with a careers page."] }] }. A refusal is kept with your reason and not asked again; a path you leave out is asked again later.
A minimal receiver
A Cloudflare Worker that verifies the signature with the Web Crypto API, handles the three article commands, and refuses the rest as unknown_event. It keeps articles in a KV namespace bound as ARTICLES, and the signing secret in a Worker secret named AUTOPSEO_SECRET.
const HANDLED = ["article.published", "article.updated", "article.unpublished"];
const MAX_SKEW_SECONDS = 300;
const SITE = "https://example.com";
const json = (data, status = 200) =>
new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json" } });
function hexToBytes(hex) {
if (!/^[0-9a-f]{64}$/.test(hex)) return null;
const out = new Uint8Array(32);
for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
return out;
}
async function verified(request, body, secret) {
const timestamp = request.headers.get("X-AutoPSEO-Timestamp") || "";
const signature = (request.headers.get("X-AutoPSEO-Signature") || "").replace(/^sha256=/, "");
if (!/^\d+$/.test(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > MAX_SKEW_SECONDS) return false;
const bytes = hexToBytes(signature);
if (!bytes) return false;
const enc = new TextEncoder();
const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
// verify() compares in constant time.
return crypto.subtle.verify("HMAC", key, bytes, enc.encode(`${timestamp}.${body}`));
}
export default {
async fetch(request, env) {
if (request.method !== "POST") return new Response("Method not allowed", { status: 405 });
const body = await request.text();
if (!(await verified(request, body, env.AUTOPSEO_SECRET))) return json({ error: "bad_signature" }, 401);
let msg;
try {
msg = JSON.parse(body);
} catch {
return json({ error: "bad_json" }, 400);
}
if (msg.event === "ping") return json({ ok: true, autopseo: { version: 1, events: HANDLED } });
if (msg.event === "test") {
const command = msg.test && msg.test.event;
// Check the sample as you would the real command, and store nothing.
return HANDLED.includes(command)
? json({ test: command, ok: true })
: json({ test: command, ok: false, error: "unknown_event" });
}
if (msg.event === "article.published" || msg.event === "article.updated") {
const a = msg.article;
const stored = JSON.parse((await env.ARTICLES.get(a.id)) || "null");
if (a.basedOn && stored && stored.version && stored.version !== a.basedOn) {
return json({ error: "conflict", details: ["The page changed on the site."] }, 409);
}
// Any change to the page, here or in your own editor, gets a new version.
const version = crypto.randomUUID();
await env.ARTICLES.put(a.id, JSON.stringify({ ...a, version }));
return json({ url: SITE + a.path, version });
}
if (msg.event === "article.unpublished") {
const a = msg.article;
if (a.redirectTo) await env.ARTICLES.put(a.id, JSON.stringify({ path: a.path, redirectTo: a.redirectTo }));
else await env.ARTICLES.delete(a.id);
return json({ ok: true });
}
return json({ error: "unknown_event" }, 400);
},
};
On Node.js, verify the same way with createHmac and timingSafeEqual, over the raw body your framework received:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyAutopseo(rawBody, headers, secret) {
const timestamp = String(headers["x-autopseo-timestamp"] || "");
const given = String(headers["x-autopseo-signature"] || "");
if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = "sha256=" + createHmac("sha256", secret).update(timestamp + ".").update(rawBody).digest("hex");
return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
Next: what reaches the webhook is decided by writing and review and maintenance; the whole flow is in the content engine overview.