Documentatie
Aflever-webhook: artikelen ontvangen op je eigen site
De ondertekende webhook die AutoPSEO-artikelen naar je site stuurt: headers, elk event en elke payload, de antwoorden die je moet geven, nieuwe pogingen, wachtende commando's en IndexNow.
AutoPSEO stuurt elk artikel dat het publiceert, bijwerkt of offline haalt als ondertekende HTTPS POST naar je eigen site. Je site slaat het artikel op en rendert het met eigen templates, zodat de content in jouw systeem staat en blijft werken als je ooit weggaat. Deze pagina is de referentie voor de ontwikkelaar die de ontvanger bouwt: ondertekening, elk event met zijn payload, de antwoorden die AutoPSEO uitleest, nieuwe pogingen, wachtende commando's en IndexNow.
De webhook instellen
- Open de pagina Content van de property en ga naar Waar artikelen worden gepubliceerd.
- Vul Webhook-adres op je site in. Dit moet een
https://-URL op een openbare hostnaam zijn: IP-adressen enlocalhostworden geweigerd. AutoPSEO volgt geen redirects, dus geef het definitieve adres op. - Vul Adres van je site in, bijvoorbeeld
https://example.com. Het pad van elk contenttype hangt hieronder, dus een artikel van een type op/answersstaat ophttps://example.com/answers/<slug>. - Kies Opslaan en test sturen. AutoPSEO maakt een ondertekeningsgeheim aan (
whsec_gevolgd door 48 hexadecimale tekens) en pingt je adres. Een ontvanger die handtekeningen controleert, laat deze eerste ping mislukken, omdat hij het geheim nog niet heeft. - Toon het Ondertekeningsgeheim met Tonen of Kopiëren, bewaar het als geheim op je site en kies dan Test sturen.
- Lees Wat je site afhandelt: elk commando toont Verbonden, Niet verbonden, Gedeclareerd, niet getest of Niet getest.
Nieuwe properties beginnen met artikelen die AutoPSEO op zijn eigen adres host (zie Hosting), en hosting en webhook kunnen naast elkaar draaien. Zodra je site de artikelen toont, kies je Stoppen met hosten bij AutoPSEO: elk live artikel verhuist naar het adres van je site en wordt als article.published naar je webhook gestuurd. Die knop vereist een webhook die op zijn laatste test heeft geantwoord en het adres van je site. Zolang hosting uit staat, kan de webhook niet worden verwijderd.
Nieuw geheim vervangt het ondertekeningsgeheim meteen en het oude geheim verifieert direct niet meer. Zet het nieuwe geheim op je site zodra je het hebt gemaakt.
AutoPSEO zet alleen afleveringen in de wachtrij voor een webhook waarvan de laatste ping met een 2xx is beantwoord. Een mislukte ping (bij opslaan, bij Test sturen of aan het begin van een sitescan) markeert de webhook als foutief, en artikelen die tijdens die fout worden gepubliceerd, komen niet in de wachtrij voor deze webhook. Laat ping dus beantwoorden en kies Test sturen nadat je een storing hebt verholpen.
Headers en handtekening
Elk verzoek is een POST met een JSON-body en deze headers:
| Header | Waarde |
|---|---|
Content-Type | application/json |
User-Agent | AutoPSEO-Webhook/1 |
X-AutoPSEO-Event | De naam van het event, ook in de body als event |
X-AutoPSEO-Delivery | De aflevering-id, ook in de body als id. Gelijk bij elke nieuwe poging van één aflevering |
X-AutoPSEO-Timestamp | Unix-tijd in seconden waarop deze poging is ondertekend |
X-AutoPSEO-Signature | sha256= gevolgd door de hex-HMAC-SHA256 van <timestamp>.<raw body> |
Zo verifieer je een verzoek:
- Neem de timestamp-header, een punt en de ruwe request-body precies zoals die binnenkwam, vóór enige JSON-parsing.
- Bereken HMAC-SHA256 over die tekenreeks. De sleutel is het volledige ondertekeningsgeheim zoals de app het toont, inclusief het voorvoegsel
whsec_, als UTF-8-bytes. - Codeer het resultaat als hex en vergelijk het in constante tijd met het deel van
X-AutoPSEO-Signaturenasha256=. Weiger het verzoek als ze verschillen.
Elke poging wordt opnieuw ondertekend met een eigen timestamp, dus je kunt verzoeken weigeren waarvan de timestamp ver van je klok afligt (vijf minuten is een gangbaar venster) zonder nieuwe pogingen te breken. HMAC zelf is gedefinieerd in RFC 2104.
Elke body bevat id, event, occurredAt (ISO 8601, UTC) en site: { propertyId, domain }.
Events
| Event | Soort | Wanneer AutoPSEO het stuurt |
|---|---|---|
ping | Controle | Wanneer je de webhook opslaat, bij Test sturen en aan het begin van elke sitescan |
test | Controle | Direct na een geslaagde ping, één per commando |
article.published | Aflevering | Een artikel gaat voor het eerst live; elk live artikel wanneer je stopt met hosten |
article.updated | Aflevering | Een nieuwe revisie gaat live (verversen, optimaliseren, terugdraaien, opnieuw publiceren na een takedown, talen toegevoegd) of er is een cover toegevoegd |
article.unpublished | Aflevering | Een artikel wordt offline gehaald of samengevoegd met een andere pagina |
path.add | Aflevering | Een nieuwe waarde van een getypeerd contenttype is goedgekeurd |
path.remove | Aflevering | Iemand heeft gevraagd het pad van een waarde offline te halen |
path.check | Aanroep | Voordat AutoPSEO een pagina plant op een adres waar je site geen pagina heeft, of een nieuwe waarde voorstelt |
site.export | Aanroep | Tijdens een sitescan, in de dagelijkse onderwerpensync en voordat AutoPSEO een live artikel wijzigt |
articles.adopted | Aanroep | Nadat een scan artikelen heeft geïmporteerd die je site heeft geëxporteerd |
site.handover | Aanroep | Wanneer iemand het schrijven van contenttypes aan AutoPSEO overdraagt, of terugneemt |
Afleveringen lopen via een outbox: ze worden in dezelfde databasebatch als de wijziging vastgelegd, direct verstuurd, opnieuw geprobeerd en kunnen op je site wachten. Aanroepen zijn gewone verzoeken en antwoorden: AutoPSEO leest het antwoord ter plekke uit. Wanneer de padcommando's worden verstuurd, staat uitgelegd in Getypeerde pagina's en nieuwe paden.
De artikelpayload
article.published en article.updated bevatten het hele artikel, alle talen inbegrepen:
{
"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
}
}
}
Het Duitse onderdeel is hier ingekort; elke vertaling heeft dezelfde vorm als copy.
| Veld | Betekenis |
|---|---|
id | De id van het artikel bij AutoPSEO. Die verandert nooit, dus je kunt hem veilig als sleutel gebruiken |
slug | Het laatste deel van het adres. Bij getypeerde pagina's bevat het segmenten die met / zijn samengevoegd, zoals google/software-engineer |
path | Het adres relatief aan je site: het pad van het type plus de slug. Vast zodra de pagina bestaat |
type | { key, name, path }. key is stabiel; path is waar de artikelen van het type staan (leeg voor de siteroot) |
lang, langs | De primaire taal en alle talen van deze revisie |
rev | Het revisienummer |
url | Het volledige adres zoals AutoPSEO het kent: dat van je site zodra hosting uit staat, of de url die je ontvanger het laatst heeft teruggegeven |
datePublished, dateModified | YYYY-MM-DD. De publicatiedatum blijft gelijk bij latere revisies |
contentHash | Een vingerafdruk van de revisie |
writer | Wie de revisie heeft geschreven: platform (AutoPSEO), mcp (je eigen AI), user, import (de eigen tekst van je site) of aggregate (een lijstpagina) |
copy | Het artikel in de primaire taal |
translations | De andere talen, met de taalcode als sleutel |
category | De categorie van je site voor het artikel, als je formaat categorieën bijhoudt |
topic | { externalId, keyword } wanneer het artikel is geschreven voor een onderwerp dat je site heeft aangeleverd, zodat je dat onderwerp kunt afsluiten |
parts | Bij getypeerde pagina's de waarde van elk onderdeel: { "company": "google", "role": "software-engineer" } |
kind | "list" bij een lijstpagina. De copy is dan { title, h1, items: [{ from, text }] }, waarbij from het pad is van de pagina waar elk item vandaan komt, en parts de waarde noemt die wordt verzameld |
cover | Een cover die AutoPSEO voor het artikel heeft gemaakt. Haal hem één keer op en bewaar een eigen kopie; het adres verandert nooit, omdat de bestandsnaam de SHA-256 van de afbeelding is |
basedOn | Bij article.updated: de versie van je pagina waarop deze revisie is gebaseerd (zie conflicten hieronder) |
redirectTo | Bij article.unpublished na een samenvoeging: waar het oude adres nu naartoe leidt. Beantwoord dit met een 301 en publiceer daar niets nieuws |
article.unpublished bevat dezelfde article-velden, behalve copy, translations, category en cover.
Binnen copy
| Veld | Betekenis |
|---|---|
title, metaDescription, h1 | De paginatitel, de metabeschrijving en de kop |
directAnswer | Het korte antwoord dat als eerste wordt getoond |
keyTakeaways | Een lijst met strings |
sections | [{ heading, bodyHtml }] |
faq | [{ q, a }] |
sources | [{ title, url, lastVerified? }] |
question | Optioneel: de vraag van de zoeker in diens eigen woorden, wanneer je formaat daarom vraagt |
category | Optioneel: een van de categorieën van je site |
lists | Optioneel: lijsten met platte tekst per sleutel, voor contenttypes die erom vragen (voorbeeldvragen van een sollicitatiegids) |
In artikelen die AutoPSEO schrijft, gebruikt bodyHtml alleen p, ul, li, strong, em, a en code, en links behouden alleen hun href. Tekst die van je eigen site is overgenomen, behoudt jouw HTML. Haal die ook door je eigen sanitiser als je templates daarom vragen.
Wat je ontvanger antwoordt
Elke 2xx rondt een aflevering af. AutoPSEO leest ook een optioneel JSON-object uit de antwoordbody; houd het onder 64 KB, want een groter antwoord telt als een mislukking.
| Veld | Effect |
|---|---|
url | Het https://-adres waar het artikel nu staat. AutoPSEO legt het vast als de URL van het artikel |
version | De versie van je pagina, een ondoorzichtige string van maximaal 128 tekens. Komt bij de volgende update terug als basedOn |
urls | De https://-adressen die deze aflevering heeft gewijzigd, voor IndexNow (elke taal, of de adressen die je niet meer aanbiedt). [] betekent dat er niets is veranderd |
cover | "wanted" wanneer je geen cover voor het artikel hebt en de aflevering er geen bevatte. AutoPSEO maakt er een en stuurt die in een article.updated |
Foutantwoorden betekenen verschillende dingen:
| Je antwoord | Hoe AutoPSEO het leest | Wat er gebeurt |
|---|---|---|
Een 4xx met { "error": "unknown_event" }, of een 501 | Je handelt dit commando nog niet af | De aflevering wacht, zonder nieuwe pogingen |
Een 409 met { "error": "conflict" } | De update is geschreven vanuit een versie die je pagina niet meer heeft | De aflevering mislukt zonder nieuwe pogingen, en het artikel wordt opnieuw van je site gelezen vóór de volgende wijziging |
Een andere 4xx dan 408 of 429 met { "error": "<code>", "details": [] } op path.add of path.remove | Een weigering met jouw reden | Vastgelegd; zie Getypeerde pagina's en nieuwe paden |
| Al het andere: geen antwoord binnen 10 seconden, een netwerkfout, een redirect, 408, 429, 5xx of een andere 4xx | Een tijdelijke storing | Nieuwe poging met backoff |
Nieuwe pogingen en achterhaalde afleveringen
Een aflevering wordt verstuurd zodra de wijziging is opgeslagen, en een schema van vijf minuten verstuurt alles wat aan de beurt is, oudste eerst. Na een tijdelijke storing wacht AutoPSEO 1 minuut, daarna 5 minuten, 15 minuten, 1 uur, 3 uur, 6 uur en 12 uur tussen de pogingen. De achtste mislukte poging markeert de aflevering als mislukt, en de pagina Content telt die in een rode badge naast de webhook.
Als er een nieuwere aflevering voor hetzelfde artikel bestaat, wordt een oudere die nog openstaat als achterhaald gemarkeerd en nooit verstuurd. Elke aflevering bevat het hele artikel, dus de nieuwste is alles wat je site nodig heeft. Padcommando's over dezelfde waarde werken op dezelfde manier.
Je ontvanger kan één aflevering twee keer zien, bijvoorbeeld wanneer hij het artikel heeft opgeslagen maar pas na de time-out van 10 seconden antwoordde. Ontdubbel op X-AutoPSEO-Delivery, of maak elke schrijfactie idempotent door op article.id op te slaan. article.updated kan ook een artikel bereiken dat je na een article.unpublished hebt verwijderd, omdat het opnieuw publiceren van een offline gehaald artikel een update stuurt: behandel updates als "aanmaken of vervangen".
Commando's die je site nog niet afhandelt
Een site kan het protocol commando voor commando implementeren. AutoPSEO houdt de status van elk commando bij en leert die op drie manieren kennen:
- Tests. Na elke geslaagde ping stuurt AutoPSEO één
testper commando. De body is{ id, event: "test", occurredAt, site, test: { event: "<command>", ... } }, waarbijtesteen voorbeeld bevat dat de vorm van het echte commando heeft en gaat over<type path>/autopseo-connection-test, een adres dat geen enkele site heeft. Controleer het zoals je het echte commando zou controleren, wijzig niets en antwoord met een 2xx:{ "test": "<command>", "ok": true }voor Verbonden,{ "test": "<command>", "ok": false, "error": "unknown_event" }voor Niet verbonden. Een antwoord metok: falseen een andere foutcode, zoals het offline halen van een adres dat je niet hebt, telt nog steeds als verbonden. Het voorbeeld staat ondertest, nooit op het hoogste niveau, zodat een ontvanger die niets van tests weet er geen artikel in vindt. - Echte afleveringen en aanroepen. Een 2xx markeert het commando als verbonden;
unknown_eventmarkeert het als niet verbonden. - Het ping-antwoord. Commando's die je antwoord opsomt in
autopseo.eventsverschijnen als Gedeclareerd, niet getest totdat een test of een aflevering meer zegt.
Een aflevering voor een commando dat niet verbonden is, wacht. De pagina Content toont wat er wacht, bijvoorbeeld "Je site haalt nog geen artikelen offline", met een knop Gedaan op de site voor alles wat je handmatig hebt uitgevoerd; AutoPSEO stuurt dat ene dan nooit meer. Wanneer een latere test of aflevering het commando als verbonden vindt, wordt alles wat erop wachtte verstuurd.
Het ping-antwoord
Beantwoord ping met een 2xx. Een site die alleen artikelen ontvangt, heeft verder niets nodig. Om AutoPSEO te vertellen wat je site nog meer doet, neem je een autopseo-object op:
{
"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"
}
}
| Veld | Betekenis |
|---|---|
version | 1. Het object wordt genegeerd zonder een versie van 1 of hoger |
events | De commando's die je afhandelt, getoond als gedeclareerd tot ze zijn getest |
covers | true wanneer je pagina's bij elk artikel een cover tonen. De schrijver van AutoPSEO maakt de cover dan tijdens het schrijven, zodat de eerste aflevering hem meebrengt, en je eigen AI wordt gevraagd er een aan te leveren. Zie Covers |
indexNow | { key, keyLocation? }: de IndexNow-sleutel die je aanbiedt (8 tot 128 letters, cijfers en streepjes) en, als het sleutelbestand niet op /<key>.txt staat, de https://-locatie ervan |
export | Wat site.export aanlevert: articles, topics, profile |
adopt | Je accepteert articles.adopted, zodat een volledige scan je bestaande artikelen kan overnemen |
handover | Je accepteert site.handover |
types | De sleutels van de contenttypes die je accepteert (maximaal 50) |
writer | Wie je nieuwe artikelen op dit moment schrijft: site of autopseo |
IndexNow voor de adressen die je aanbiedt
Wanneer je ping-antwoord indexNow declareert, pingt AutoPSEO IndexNow op api.indexnow.org na de afleveringen die je accepteert, en pingt je site zelf niets.
- Het stuurt de
urlsdie je antwoord opsomde. Zonderurlsstuurt het deurluit je antwoord, anders de URL van het artikel.urls: []stuurt niets. - Alleen adressen op je eigen host gaan mee, zoals het IndexNow-protocol vereist: de host van
keyLocation, anders de host van het adres van je site. - Elke afleverronde stuurt één ping per site, met maximaal 10.000 adressen.
- 200 en 202 worden geaccepteerd. Bij een 429, een 5xx of geen antwoord blijven de adressen bewaard en volgt na een uur een nieuwe poging, telkens verdubbeld tot maximaal een dag (langer als
Retry-Afterdaarom vraagt), samen met wat latere afleveringen hebben toegevoegd. Een 403 (sleutel ongeldig) of 422 (adressen niet op de host) wordt vastgelegd en verworpen. - De pagina Content toont de laatste ping: wanneer, hoeveel adressen, de HTTP-status en de volgende poging.
- Zolang AutoPSEO de artikelen ook host, worden de gehoste pagina's gepingd met de eigen sleutel van AutoPSEO, en wordt de jouwe niet gebruikt.
Het hulpmiddel IndexNow onder Indexering is een aparte, handmatige indiener; zie IndexNow.
Conflicten: de tekst van je site is het origineel
De version in je antwoord (of de version van een artikel in je export) is de versie waarvan AutoPSEO denkt dat je pagina erop staat. Elke article.updated brengt die mee als basedOn. Als de pagina sindsdien op je site is gewijzigd, bijvoorbeeld na een correctie van een redacteur, antwoord dan met 409 en { "error": "conflict" } en sla niets op. De aflevering mislukt zonder nieuwe pogingen.
Voordat een wijziging in de wachtrij voor een live artikel wordt geschreven, leest AutoPSEO dat artikel opnieuw van je site met site.export en paths, als je site site.export afhandelt. Als je versie is doorgeschoven, wordt jouw tekst de volgende revisie van het artikel (writer import), en de update die daarop volgt draagt jouw huidige versie als basedOn.
path.add en path.remove
Deze twee afleveringen gaan over één waarde van een getypeerd contenttype, bijvoorbeeld een bedrijf in /interviews/{company}/{role}. Ze bevatten een path-object in plaats van 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"] }
}
}
| Veld | Betekenis |
|---|---|
path | Het eigen adres van de waarde op je site |
type | Het contenttype, als { key, name, path } |
part | Het deel van het adres dat de waarde invult |
value | { value, name }, plus bij path.add de aliases, url, sameAs, description en facts die de schrijver van AutoPSEO heeft verzameld. Elk feit heeft text, source, lastVerified en een tier van official of reported |
takes | Bij path.add: de waarden van de andere onderdelen waarmee mensen er samen op zoeken |
redirectTo | Bij path.remove: waar de adressen van de waarde voortaan met een 301 naartoe moeten leiden |
Antwoord met een 2xx wanneer je het commando hebt verwerkt; bij path.add mag je de url toevoegen waar de waarde nu staat, en bij path.remove de urls die je niet meer aanbiedt, die naar IndexNow gaan. Om te weigeren antwoord je met een 4xx en { "error": "<code>", "details": ["<why>"] }. Bij path.add stuurt invalid_value de waarde met jouw reden terug naar de schrijver (de derde weigering is definitief) en weigert elke andere code de waarde voorgoed. Bij path.remove telt not_found als gedaan, en laat elke andere code, zoals 409 in_use, alles zoals het was. Een wachtend padcommando markeren als Gedaan op de site telt als het ja van je site.
Aanroepen: verzoeken waarvan AutoPSEO het antwoord uitleest
Aanroepen worden net als afleveringen ondertekend. Een aanroep wacht standaard maximaal 60 seconden, en 30 seconden bij hernieuwd inlezen en padcontroles, en leest antwoorden tot 24 MB. Elk ander antwoord dan een 2xx laat de aanroep mislukken.
site.export
Het verzoek bevat export: { what, cursor, limit }, plus since (een ISO-tijd) voor onderwerpen of paths voor hernieuwd inlezen. Antwoord met { "items": [], "next": "<cursor>" }; AutoPSEO vraagt opnieuw met cursor gelijk aan jouw next totdat die leeg of null is.
what: "articles"draait in volledige sitescans, 10 per pagina, wanneer jeexportmetarticlesenadopt: truehebt gedeclareerd. Een 400-antwoord betekent "niet ondersteund". Metpaths: ["/answers/x"]enlimit: 1leest het één artikel opnieuw in; antwoord dan alleen met dat artikel.what: "topics"draait bij elke scan en in de dagelijkse sync, 50 per pagina; de dagelijkse sync stuurtsince, het tijdstip van de laatste ophaalronde.what: "profile"draait bij elke scan. Antwoord met{ "profile": { types, languages, rules, categories, format } }, elk optioneel:typesals[{ key, name, path }], enrulesmetbrandTerms,synonyms,noise,onTopicenoffTopic.
Een geëxporteerd artikel heeft externalId nodig, een slug (letters en cijfers van elk schrift en streepjes, maximaal 200 tekens, segmenten samengevoegd met /), een path die op die slug eindigt, en copy met minstens title, h1 en één sectie. Optionele velden: lang (standaard en), type (de sleutel van een contenttype), url, keyword, category, datePublished, dateModified, version, translations, owner (de artikel-id van AutoPSEO, zodra het is overgenomen) en maintenance: { kind: "refresh" or "optimize", reason }, die die taak in de wachtrij zet. Elk geëxporteerd artikel wordt een beheerd artikel met jouw tekst als eerste revisie, en er wordt niets teruggestuurd. Opnieuw exporteren is veilig, en een artikel dat AutoPSEO offline heeft gehaald, komt nooit terug via een export.
Een onderwerpitem is { keyword, status?, volume?, source?, externalId?, type?, reason? }, waarbij status queued, planned of rejected is.
articles.adopted
Na het importeren vraagt AutoPSEO je om de nieuwe artikelen te laten beheren: { "articles": [{ "externalId", "articleId", "slug", "path" }] }. Antwoord met { "adopted": ["<externalId>"], "refused": [{ "externalId": "<id>", "reason": "<why>" }] }. Een artikel dat je niet bevestigt, wordt weer uit AutoPSEO verwijderd, tenzij AutoPSEO er al een revisie van heeft geschreven.
site.handover
{ "handover": { "types": ["answers"], "writer": "autopseo" } }, verstuurd wanneer iemand op de pagina Je site van de property Laat AutoPSEO ze schrijven of Schrijven teruggeven aan de site kiest. Antwoord met { "ok": true, "writer": "autopseo" }. Na een overdracht aan AutoPSEO geeft je site de onderwerpen die hij screent door aan AutoPSEO in plaats van ze zelf te schrijven.
path.check
{ "paths": [{ "path": "/interviews/google/data-engineer" }, { "path": "/interviews/jane-street", "new": { "part": "company", "value": "jane-street", "name": "Jane Street" } }] }. Antwoord met één resultaat per pad: { "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."] }] }. Een weigering wordt met jouw reden bewaard en niet opnieuw gevraagd; een pad dat je weglaat, wordt later opnieuw gevraagd.
Een minimale ontvanger
Een Cloudflare Worker die de handtekening verifieert met de Web Crypto API, de drie artikelcommando's afhandelt en de rest als unknown_event weigert. Hij bewaart artikelen in een KV-namespace die als ARTICLES is gekoppeld, en het ondertekeningsgeheim in een Worker-secret met de naam 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);
},
};
Op Node.js verifieer je op dezelfde manier met createHmac en timingSafeEqual, over de ruwe body die je framework heeft ontvangen:
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));
}
Hierna: wat de webhook bereikt, wordt bepaald door schrijven en review en onderhoud; de hele flow staat in het overzicht van de content engine.