Ga naar inhoud
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.

Property → Content → Waar artikelen worden gepubliceerd20 min leestijd

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

  1. Open de pagina Content van de property en ga naar Waar artikelen worden gepubliceerd.
  2. Vul Webhook-adres op je site in. Dit moet een https://-URL op een openbare hostnaam zijn: IP-adressen en localhost worden geweigerd. AutoPSEO volgt geen redirects, dus geef het definitieve adres op.
  3. Vul Adres van je site in, bijvoorbeeld https://example.com. Het pad van elk contenttype hangt hieronder, dus een artikel van een type op /answers staat op https://example.com/answers/<slug>.
  4. 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.
  5. Toon het Ondertekeningsgeheim met Tonen of Kopiëren, bewaar het als geheim op je site en kies dan Test sturen.
  6. 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:

HeaderWaarde
Content-Typeapplication/json
User-AgentAutoPSEO-Webhook/1
X-AutoPSEO-EventDe naam van het event, ook in de body als event
X-AutoPSEO-DeliveryDe aflevering-id, ook in de body als id. Gelijk bij elke nieuwe poging van één aflevering
X-AutoPSEO-TimestampUnix-tijd in seconden waarop deze poging is ondertekend
X-AutoPSEO-Signaturesha256= gevolgd door de hex-HMAC-SHA256 van <timestamp>.<raw body>

Zo verifieer je een verzoek:

  1. Neem de timestamp-header, een punt en de ruwe request-body precies zoals die binnenkwam, vóór enige JSON-parsing.
  2. 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.
  3. Codeer het resultaat als hex en vergelijk het in constante tijd met het deel van X-AutoPSEO-Signature na sha256=. 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

EventSoortWanneer AutoPSEO het stuurt
pingControleWanneer je de webhook opslaat, bij Test sturen en aan het begin van elke sitescan
testControleDirect na een geslaagde ping, één per commando
article.publishedAfleveringEen artikel gaat voor het eerst live; elk live artikel wanneer je stopt met hosten
article.updatedAfleveringEen nieuwe revisie gaat live (verversen, optimaliseren, terugdraaien, opnieuw publiceren na een takedown, talen toegevoegd) of er is een cover toegevoegd
article.unpublishedAfleveringEen artikel wordt offline gehaald of samengevoegd met een andere pagina
path.addAfleveringEen nieuwe waarde van een getypeerd contenttype is goedgekeurd
path.removeAfleveringIemand heeft gevraagd het pad van een waarde offline te halen
path.checkAanroepVoordat AutoPSEO een pagina plant op een adres waar je site geen pagina heeft, of een nieuwe waarde voorstelt
site.exportAanroepTijdens een sitescan, in de dagelijkse onderwerpensync en voordat AutoPSEO een live artikel wijzigt
articles.adoptedAanroepNadat een scan artikelen heeft geïmporteerd die je site heeft geëxporteerd
site.handoverAanroepWanneer 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.

VeldBetekenis
idDe id van het artikel bij AutoPSEO. Die verandert nooit, dus je kunt hem veilig als sleutel gebruiken
slugHet laatste deel van het adres. Bij getypeerde pagina's bevat het segmenten die met / zijn samengevoegd, zoals google/software-engineer
pathHet 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, langsDe primaire taal en alle talen van deze revisie
revHet revisienummer
urlHet 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, dateModifiedYYYY-MM-DD. De publicatiedatum blijft gelijk bij latere revisies
contentHashEen vingerafdruk van de revisie
writerWie de revisie heeft geschreven: platform (AutoPSEO), mcp (je eigen AI), user, import (de eigen tekst van je site) of aggregate (een lijstpagina)
copyHet artikel in de primaire taal
translationsDe andere talen, met de taalcode als sleutel
categoryDe 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
partsBij 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
coverEen 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
basedOnBij article.updated: de versie van je pagina waarop deze revisie is gebaseerd (zie conflicten hieronder)
redirectToBij 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

VeldBetekenis
title, metaDescription, h1De paginatitel, de metabeschrijving en de kop
directAnswerHet korte antwoord dat als eerste wordt getoond
keyTakeawaysEen lijst met strings
sections[{ heading, bodyHtml }]
faq[{ q, a }]
sources[{ title, url, lastVerified? }]
questionOptioneel: de vraag van de zoeker in diens eigen woorden, wanneer je formaat daarom vraagt
categoryOptioneel: een van de categorieën van je site
listsOptioneel: 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.

VeldEffect
urlHet https://-adres waar het artikel nu staat. AutoPSEO legt het vast als de URL van het artikel
versionDe versie van je pagina, een ondoorzichtige string van maximaal 128 tekens. Komt bij de volgende update terug als basedOn
urlsDe 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 antwoordHoe AutoPSEO het leestWat er gebeurt
Een 4xx met { "error": "unknown_event" }, of een 501Je handelt dit commando nog niet afDe aflevering wacht, zonder nieuwe pogingen
Een 409 met { "error": "conflict" }De update is geschreven vanuit een versie die je pagina niet meer heeftDe 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.removeEen weigering met jouw redenVastgelegd; 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 4xxEen tijdelijke storingNieuwe 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 test per commando. De body is { id, event: "test", occurredAt, site, test: { event: "<command>", ... } }, waarbij test een 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 met ok: false en een andere foutcode, zoals het offline halen van een adres dat je niet hebt, telt nog steeds als verbonden. Het voorbeeld staat onder test, 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_event markeert het als niet verbonden.
  • Het ping-antwoord. Commando's die je antwoord opsomt in autopseo.events verschijnen 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"
  }
}
VeldBetekenis
version1. Het object wordt genegeerd zonder een versie van 1 of hoger
eventsDe commando's die je afhandelt, getoond als gedeclareerd tot ze zijn getest
coverstrue 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
exportWat site.export aanlevert: articles, topics, profile
adoptJe accepteert articles.adopted, zodat een volledige scan je bestaande artikelen kan overnemen
handoverJe accepteert site.handover
typesDe sleutels van de contenttypes die je accepteert (maximaal 50)
writerWie 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 urls die je antwoord opsomde. Zonder urls stuurt het de url uit 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-After daarom 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"] }
  }
}
VeldBetekenis
pathHet eigen adres van de waarde op je site
typeHet contenttype, als { key, name, path }
partHet 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
takesBij path.add: de waarden van de andere onderdelen waarmee mensen er samen op zoeken
redirectToBij 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 je export met articles en adopt: true hebt gedeclareerd. Een 400-antwoord betekent "niet ondersteund". Met paths: ["/answers/x"] en limit: 1 leest 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 stuurt since, het tijdstip van de laatste ophaalronde.
  • what: "profile" draait bij elke scan. Antwoord met { "profile": { types, languages, rules, categories, format } }, elk optioneel: types als [{ key, name, path }], en rules met brandTerms, synonyms, noise, onTopic en offTopic.

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.

Zet je SEO-content op de automatische piloot

Koppel Search Console en je site. AutoPSEO scant de site en plant meteen de eerste onderwerpen. Geen creditcard nodig.