Skip to content
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.

Property → Content → Where articles are published19 min read

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

  1. Open the property's Content page and go to Where articles are published.
  2. Fill in Webhook address on your site. It must be an https:// URL on a public host name: IP addresses and localhost are refused. AutoPSEO does not follow redirects, so give the final address.
  3. 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 /answers lives at https://example.com/answers/<slug>.
  4. 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.
  5. Reveal the Signing secret with Show or Copy, store it as a secret on your site, then choose Send a test.
  6. 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:

HeaderValue
Content-Typeapplication/json
User-AgentAutoPSEO-Webhook/1
X-AutoPSEO-EventThe event name, also in the body as event
X-AutoPSEO-DeliveryThe delivery id, also in the body as id. The same on every retry of one delivery
X-AutoPSEO-TimestampUnix time in seconds when this attempt was signed
X-AutoPSEO-Signaturesha256= followed by the hex HMAC-SHA256 of <timestamp>.<raw body>

To verify a request:

  1. Take the timestamp header, a full stop, and the raw request body exactly as it arrived, before any JSON parsing.
  2. 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.
  3. Hex-encode the result and compare it with the part of X-AutoPSEO-Signature after sha256=, 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

EventKindWhen AutoPSEO sends it
pingCheckWhen you save the webhook, on Send a test, and at the start of every site scan
testCheckRight after a successful ping, one per command
article.publishedDeliveryAn article goes live for the first time; every live article when you stop hosting
article.updatedDeliveryA new revision goes live (refresh, optimisation, rollback, publishing again after a takedown, languages added) or a cover was added
article.unpublishedDeliveryAn article is taken down, or merged into another page
path.addDeliveryA new value of a typed content type was approved
path.removeDeliverySomeone asked for a value's path to be taken down
path.checkCallBefore AutoPSEO plans a page at an address your site has no page at, or proposes a new value
site.exportCallDuring a site scan, in the daily topic sync, and before AutoPSEO changes a live article
articles.adoptedCallAfter a scan imported articles your site exported
site.handoverCallWhen 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.

FieldMeaning
idAutoPSEO's id for the article. It never changes, so it is a safe key
slugThe last part of the address. On typed pages it holds segments joined by /, such as google/software-engineer
pathThe 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, langsThe primary language and every language of this revision
revThe revision number
urlThe full address as AutoPSEO knows it: your site's once hosting is off, or the url your receiver last answered
datePublished, dateModifiedYYYY-MM-DD. The publication date stays fixed through later revisions
contentHashA fingerprint of the revision
writerWho wrote the revision: platform (AutoPSEO), mcp (your own AI), user, import (your site's own text) or aggregate (a list page)
copyThe article in its primary language
translationsThe other languages, keyed by language code
categoryYour 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
partsOn 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
coverA 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
basedOnOn article.updated: your page's version this revision was written from (see conflicts below)
redirectToOn 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

FieldMeaning
title, metaDescription, h1The page title, meta description and heading
directAnswerThe short answer shown first
keyTakeawaysA list of strings
sections[{ heading, bodyHtml }]
faq[{ q, a }]
sources[{ title, url, lastVerified? }]
questionOptional: the searcher's question in their words, when your format asks for it
categoryOptional: one of your site's categories
listsOptional: 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.

FieldEffect
urlThe https:// address where the article now lives. AutoPSEO records it as the article's URL
versionYour page's version, an opaque string of at most 128 characters. It comes back as basedOn with the next update
urlsThe 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 answerAutoPSEO reads it asWhat happens
A 4xx with { "error": "unknown_event" }, or a 501You do not handle this command yetThe delivery waits, without retries
A 409 with { "error": "conflict" }The update was written from a version your page no longer hasThe 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.removeA refusal with your reasonRecorded; see Typed pages and new paths
Anything else: no answer within 10 seconds, a network error, a redirect, 408, 429, 5xx or another 4xxA temporary failureRetried 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 test per command. The body is { id, event: "test", occurredAt, site, test: { event: "<command>", ... } }, where test holds 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 with ok: false and another error code, such as a takedown of an address you do not have, still counts as connected. The sample sits under test, 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_event marks it not connected.
  • The ping answer. Commands your answer lists in autopseo.events show 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"
  }
}
FieldMeaning
version1. The object is ignored without a version of 1 or more
eventsThe commands you handle, shown as declared until tested
coverstrue 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
exportWhat site.export hands over: articles, topics, profile
adoptYou accept articles.adopted, so a full scan can take over your existing articles
handoverYou accept site.handover
typesThe content type keys you accept (up to 50)
writerWho 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 urls your answer listed. Without urls it sends your answer's url, 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-After asks), 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"] }
  }
}
FieldMeaning
pathThe value's own address on your site
typeThe content type, as { key, name, path }
partThe 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
takesOn path.add: the values of the other parts people search for with it
redirectToOn 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 declared export with articles and adopt: true. A 400 answer means "not supported". With paths: ["/answers/x"] and limit: 1 it 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 sends since, the time of the last pull.
  • what: "profile" runs in every scan. Answer { "profile": { types, languages, rules, categories, format } }, each optional: types as [{ key, name, path }], and rules with brandTerms, synonyms, noise, onTopic and offTopic.

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.

Put your SEO content on autopilot

Connect Search Console and your site. AutoPSEO scans it and plans the first topics right away. No credit card needed.