Dokumentation
Leverans-webhook: ta emot artiklar på din egen webbplats
Den signerade webhooken som skickar AutoPSEO-artiklar till din webbplats: headers, varje händelse och payload, svaren som ska ges, omförsök, väntande kommandon och IndexNow.
AutoPSEO skickar varje artikel som publiceras, uppdateras eller tas ned till din egen webbplats som en signerad HTTPS POST. Din webbplats lagrar artikeln och renderar den med sina egna mallar, så innehållet finns i ditt system och fortsätter fungera om du någon gång lämnar tjänsten. Den här sidan är referensen för utvecklaren som bygger mottagaren: signering, varje händelse med sin payload, svaren som AutoPSEO läser, omförsök, väntande kommandon och IndexNow.
Konfigurera webhooken
- Öppna egenskapens sida Innehåll och gå till Var artiklarna publiceras.
- Fyll i Webhook-adress på din webbplats. Den måste vara en
https://-URL på ett publikt värdnamn: IP-adresser ochlocalhostnekas. AutoPSEO följer inte omdirigeringar, så ange den slutliga adressen. - Fyll i Din webbplats adress, till exempel
https://example.com. Sökvägen för varje innehållstyp läggs under den, så en artikel av en typ på/answersfinns påhttps://example.com/answers/<slug>. - Välj Spara och skicka ett test. AutoPSEO skapar en signeringshemlighet (
whsec_följt av 48 hexadecimala tecken) och pingar din adress. En mottagare som kontrollerar signaturer misslyckas med den första pingen, eftersom den ännu inte har hemligheten. - Visa Signeringshemlighet med Visa eller Kopiera, lagra den som en hemlighet på din webbplats och välj sedan Skicka ett test.
- Läs Vad din webbplats hanterar: varje kommando visar Ansluten, Inte ansluten, Deklarerad, inte testad eller Inte testad.
Nya egenskaper börjar med att AutoPSEO värdar artiklarna på sin egen adress (se Värdskap), och värdskapet och webhooken kan köras sida vid sida. När din webbplats visar artiklarna väljer du Sluta värda på AutoPSEO: varje publicerad artikel flyttas till din webbplats adress och skickas till din webhook som article.published. Den knappen kräver en webhook som besvarade sitt senaste test samt din webbplats adress. Så länge värdskapet är avstängt går webhooken inte att ta bort.
Ny hemlighet ersätter signeringshemligheten omedelbart, och den gamla slutar verifieras direkt. Lägg in den nya hemligheten på din webbplats så fort du har skapat den.
AutoPSEO köar leveranser endast för en webhook vars senaste ping besvarades med en 2xx. En misslyckad ping (vid sparande, vid Skicka ett test eller i början av en webbplatsskanning) markerar webhooken som felaktig, och artiklar som publiceras medan den är felaktig köas inte för den. Låt ping fortsätta svara och välj Skicka ett test när du har åtgärdat ett avbrott.
Headers och signatur
Varje begäran är en POST med en JSON-body och följande headers:
| Header | Värde |
|---|---|
Content-Type | application/json |
User-Agent | AutoPSEO-Webhook/1 |
X-AutoPSEO-Event | Händelsens namn, finns även i bodyn som event |
X-AutoPSEO-Delivery | Leverans-id, finns även i bodyn som id. Detsamma vid varje omförsök av en leverans |
X-AutoPSEO-Timestamp | Unix-tid i sekunder när det här försöket signerades |
X-AutoPSEO-Signature | sha256= följt av den hexadecimala HMAC-SHA256 av <timestamp>.<raw body> |
Så verifierar du en begäran:
- Ta timestamp-headern, en punkt och den råa begäranbodyn exakt som den kom in, före all JSON-tolkning.
- Beräkna HMAC-SHA256 över den strängen. Nyckeln är hela signeringshemligheten så som appen visar den, med prefixet
whsec_, som UTF-8-bytes. - Hexkoda resultatet och jämför det med delen av
X-AutoPSEO-Signatureeftersha256=, i konstant tid. Avvisa begäran om de skiljer sig åt.
Varje försök signeras på nytt med sin egen timestamp, så du kan avvisa begäranden vars timestamp ligger långt från din klocka (fem minuter är ett vanligt fönster) utan att omförsöken går sönder. Själva HMAC definieras i RFC 2104.
Varje body innehåller id, event, occurredAt (ISO 8601, UTC) och site: { propertyId, domain }.
Händelser
| Händelse | Typ | När AutoPSEO skickar den |
|---|---|---|
ping | Kontroll | När du sparar webhooken, vid Skicka ett test och i början av varje webbplatsskanning |
test | Kontroll | Direkt efter en lyckad ping, en per kommando |
article.published | Leverans | En artikel publiceras för första gången; varje publicerad artikel när du slutar värda |
article.updated | Leverans | En ny revision publiceras (uppfräschning, optimering, återställning, ny publicering efter en nedtagning, tillagda språk) eller en omslagsbild lades till |
article.unpublished | Leverans | En artikel tas ned eller slås ihop med en annan sida |
path.add | Leverans | Ett nytt värde för en typad innehållstyp godkändes |
path.remove | Leverans | Någon bad om att ett värdes sökväg skulle tas ned |
path.check | Anrop | Innan AutoPSEO planerar en sida på en adress där din webbplats saknar sida, eller föreslår ett nytt värde |
site.export | Anrop | Under en webbplatsskanning, i den dagliga ämnessynkroniseringen och innan AutoPSEO ändrar en publicerad artikel |
articles.adopted | Anrop | Efter att en skanning har importerat artiklar som din webbplats exporterade |
site.handover | Anrop | När någon överlämnar skrivandet av innehållstyper till AutoPSEO, eller tillbaka |
Leveranserna går via en outbox: de registreras i samma databasbatch som ändringen, skickas direkt, försöks igen och kan vänta på din webbplats. Anrop är vanlig begäran och svar: AutoPSEO läser svaret på stående fot. När sökvägskommandona skickas förklaras i Typade sidor och nya sökvägar.
Artikelns payload
article.published och article.updated innehåller hela artikeln, med alla språk:
{
"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
}
}
}
Den tyska posten är förkortad här; varje översättning har samma form som copy.
| Fält | Betydelse |
|---|---|
id | AutoPSEO:s id för artikeln. Det ändras aldrig, så det är en säker nyckel |
slug | Sista delen av adressen. På typade sidor innehåller den segment sammanfogade med /, till exempel google/software-engineer |
path | Adressen relativt din webbplats: typens sökväg plus sluggen. Fast när sidan väl finns |
type | { key, name, path }. key är stabil; path är var typens artiklar finns (tom för webbplatsens rot) |
lang, langs | Det primära språket och alla språk i den här revisionen |
rev | Revisionsnumret |
url | Den fullständiga adressen så som AutoPSEO känner till den: din webbplats när värdskapet är avstängt, eller den url som din mottagare senast svarade med |
datePublished, dateModified | YYYY-MM-DD. Publiceringsdatumet ligger fast genom senare revisioner |
contentHash | Ett fingeravtryck av revisionen |
writer | Vem som skrev revisionen: platform (AutoPSEO), mcp (din egen AI), user, import (din webbplats egen text) eller aggregate (en listsida) |
copy | Artikeln på det primära språket |
translations | De övriga språken, nycklade på språkkod |
category | Din webbplats kategori för artikeln, när ditt format har kategorier |
topic | { externalId, keyword } när artikeln skrevs för ett ämne som din webbplats lämnade över, så att du kan stänga ämnet |
parts | På typade sidor, värdet för varje del: { "company": "google", "role": "software-engineer" } |
kind | "list" på en listsida. Dess copy är { title, h1, items: [{ from, text }] }, där from är sökvägen till sidan som varje post kommer från, och parts anger värdet som sidan samlar |
cover | En omslagsbild som AutoPSEO skapade för artikeln. Hämta den en gång och behåll din egen kopia; adressen ändras aldrig eftersom filnamnet är bildens SHA-256 |
basedOn | På article.updated: den version av din sida som revisionen skrevs utifrån (se konflikter nedan) |
redirectTo | På article.unpublished efter en sammanslagning: dit den gamla adressen nu leder. Svara med en 301 och publicera inget nytt där |
article.unpublished innehåller samma article-fält utom copy, translations, category och cover.
Inuti copy
| Fält | Betydelse |
|---|---|
title, metaDescription, h1 | Sidtiteln, metabeskrivningen och rubriken |
directAnswer | Det korta svaret som visas först |
keyTakeaways | En lista med strängar |
sections | [{ heading, bodyHtml }] |
faq | [{ q, a }] |
sources | [{ title, url, lastVerified? }] |
question | Valfritt: sökarens fråga med deras egna ord, när ditt format ber om den |
category | Valfritt: en av din webbplats kategorier |
lists | Valfritt: oformaterade listor per nyckel, för innehållstyper som ber om dem (en intervjuguides exempelfrågor) |
I artiklar som AutoPSEO skriver använder bodyHtml bara p, ul, li, strong, em, a och code, och länkar behåller bara sin href. Text som tagits över från din egen webbplats behåller din HTML. Kör den även genom din egen sanerare om dina mallar kräver det.
Vad din mottagare svarar
Varje 2xx slutför en leverans. AutoPSEO läser också ett valfritt JSON-objekt ur svarsbodyn; håll det under 64 KB, eftersom ett större svar räknas som ett misslyckande.
| Fält | Effekt |
|---|---|
url | https://-adressen där artikeln nu finns. AutoPSEO registrerar den som artikelns URL |
version | Din sidas version, en ogenomskinlig sträng på högst 128 tecken. Den kommer tillbaka som basedOn vid nästa uppdatering |
urls | De https://-adresser som den här leveransen ändrade, för IndexNow (alla språk, eller de adresser du slutat servera). [] betyder att inget ändrades |
cover | "wanted" när du saknar omslagsbild för artikeln och leveransen inte bar någon. AutoPSEO skapar en och skickar den i en article.updated |
Felsvar betyder olika saker:
| Ditt svar | Så läser AutoPSEO det | Vad som händer |
|---|---|---|
En 4xx med { "error": "unknown_event" }, eller en 501 | Du hanterar inte det här kommandot ännu | Leveransen väntar, utan omförsök |
En 409 med { "error": "conflict" } | Uppdateringen skrevs utifrån en version som din sida inte längre har | Leveransen misslyckas utan omförsök, och artikeln läses om från din webbplats före nästa ändring |
En 4xx som inte är 408 eller 429 med { "error": "<code>", "details": [] } på path.add eller path.remove | Ett avslag med din motivering | Registreras; se Typade sidor och nya sökvägar |
| Allt annat: inget svar inom 10 sekunder, ett nätverksfel, en omdirigering, 408, 429, 5xx eller en annan 4xx | Ett tillfälligt fel | Försöks igen med backoff |
Omförsök och ersatta leveranser
En leverans skickas så fort ändringen sparas, och ett schema på fem minuter skickar allt som är förfallet, äldst först. Efter ett tillfälligt fel väntar AutoPSEO 1 minut, sedan 5 minuter, 15 minuter, 1 timme, 3 timmar, 6 timmar och 12 timmar mellan försöken. Det åttonde misslyckade försöket markerar leveransen som misslyckad, och sidan Innehåll räknar den i ett rött märke bredvid webhooken.
När det finns en nyare leverans för samma artikel markeras en äldre som fortfarande väntar som ersatt och skickas aldrig. Varje leverans innehåller hela artikeln, så den nyaste är allt din webbplats behöver. Sökvägskommandon om samma värde fungerar likadant.
Din mottagare kan se en leverans två gånger, till exempel när den lagrade artikeln men svarade efter timeouten på 10 sekunder. Deduplicera på X-AutoPSEO-Delivery, eller gör varje skrivning idempotent genom att lagra på article.id. article.updated kan dessutom nå en artikel som du raderade efter en article.unpublished, eftersom en ny publicering av en nedtagen artikel skickar en uppdatering: behandla uppdateringar som "skapa eller ersätt".
Kommandon som din webbplats ännu inte hanterar
En webbplats kan implementera protokollet ett kommando i taget. AutoPSEO håller reda på läget för varje kommando och får veta det på tre sätt:
- Tester. Efter varje lyckad ping skickar AutoPSEO ett
testper kommando. Bodyn är{ id, event: "test", occurredAt, site, test: { event: "<command>", ... } }, därtestinnehåller ett exempel utformat som det riktiga kommandot och gäller<type path>/autopseo-connection-test, en adress som ingen webbplats har. Kontrollera det som du skulle kontrollera det riktiga, ändra ingenting och svara med en 2xx:{ "test": "<command>", "ok": true }för Ansluten,{ "test": "<command>", "ok": false, "error": "unknown_event" }för Inte ansluten. Ett svar medok: falseoch en annan felkod, till exempel en nedtagning av en adress som du inte har, räknas ändå som ansluten. Exemplet ligger undertest, aldrig på toppnivå, så en mottagare som inte känner till tester hittar ingen artikel i det. - Riktiga leveranser och anrop. En 2xx markerar kommandot som anslutet;
unknown_eventmarkerar det som inte anslutet. - Ping-svaret. Kommandon som ditt svar listar i
autopseo.eventsvisas som Deklarerad, inte testad tills ett test eller en leverans visar mer.
En leverans för ett kommando som inte är anslutet väntar. Sidan Innehåll listar vad som väntar, till exempel "Din webbplats tar inte ned artiklar ännu", med en knapp Gjort på webbplatsen för allt du utfört för hand; AutoPSEO skickar då aldrig just den. När ett senare test eller en senare leverans visar att kommandot är anslutet skickas allt som väntar på det.
Ping-svaret
Svara på ping med en 2xx. En webbplats som bara tar emot artiklar behöver inget mer. För att berätta för AutoPSEO vad din webbplats i övrigt gör tar du med ett autopseo-objekt:
{
"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"
}
}
| Fält | Betydelse |
|---|---|
version | 1. Objektet ignoreras utan version 1 eller högre |
events | Kommandona du hanterar, visas som deklarerade tills de testats |
covers | true när dina sidor visar en omslagsbild till varje artikel. AutoPSEO:s skribent skapar då omslaget medan den skriver, så den första leveransen bär det, och din egen AI ombeds leverera ett. Se Omslagsbilder |
indexNow | { key, keyLocation? }: IndexNow-nyckeln du serverar (8 till 128 bokstäver, siffror och bindestreck) och, när nyckelfilen inte ligger på /<key>.txt, dess https://-adress |
export | Vad site.export lämnar över: articles, topics, profile |
adopt | Du accepterar articles.adopted, så att en fullständig skanning kan ta över dina befintliga artiklar |
handover | Du accepterar site.handover |
types | Nycklarna för de innehållstyper du accepterar (upp till 50) |
writer | Vem som skriver dina nya artiklar i dag: site eller autopseo |
IndexNow för adresserna du serverar
När ditt ping-svar deklarerar indexNow pingar AutoPSEO IndexNow på api.indexnow.org efter de leveranser du accepterar, och din webbplats pingar inget själv.
- Den skickar de
urlssom ditt svar listade. Utanurlsskickar den ditt svarsurl, annars artikelns URL.urls: []skickar ingenting. - Endast adresser på din egen värd tas med, så som IndexNow-protokollet kräver: värden i
keyLocation, annars värden i din webbplats adress. - Varje leveranskörning skickar en ping per webbplats, med upp till 10 000 adresser.
- 200 och 202 accepteras. En 429, en 5xx eller uteblivet svar behåller adresserna och försöker igen efter en timme, med fördubbling varje gång upp till ett dygn (längre när
Retry-Afterbegär det), tillsammans med det som senare leveranser lagt till. En 403 (ogiltig nyckel) eller 422 (adresserna ligger inte på värden) registreras och förkastas. - Sidan Innehåll visar den senaste pingen: när, hur många adresser, HTTP-statusen och nästa försök.
- Medan AutoPSEO också värdar artiklarna pingas de värdade sidorna med AutoPSEO:s egen nyckel, och din används inte.
Verktyget IndexNow under Indexering är en separat, manuell inskickare; se IndexNow.
Konflikter: din webbplats text är originalet
Ditt svars version (eller version för en artikel i din export) är det som AutoPSEO tror att din sida har. Varje article.updated bär den som basedOn. Om sidan har ändrats på din webbplats sedan dess, till exempel efter en redaktörs rättelse, svara 409 med { "error": "conflict" } och lagra ingenting. Leveransen misslyckas utan omförsök.
Innan en köad ändring av en publicerad artikel skrivs läser AutoPSEO om artikeln från din webbplats med site.export och paths, när din webbplats hanterar site.export. Om din version har gått vidare blir din text artikelns nästa revision (writer import), och uppdateringen som följer bär din aktuella version som basedOn.
path.add och path.remove
De här två leveranserna gäller ett värde i en typad innehållstyp, till exempel ett företag i /interviews/{company}/{role}. De bär ett path-objekt i stället för 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"] }
}
}
| Fält | Betydelse |
|---|---|
path | Värdets egen adress på din webbplats |
type | Innehållstypen, som { key, name, path } |
part | Den del av adressen som värdet fyller |
value | { value, name }, och på path.add dessutom aliases, url, sameAs, description och facts som AutoPSEO:s skribent samlat in. Varje faktum har text, source, lastVerified och en tier som är official eller reported |
takes | På path.add: värdena för de andra delarna som folk söker på tillsammans med det |
redirectTo | På path.remove: dit värdets adresser ska leda från och med nu, med en 301 |
Svara med en 2xx när du tog emot kommandot; på path.add kan du lägga till url där värdet nu finns, och på path.remove de urls som du slutat servera, vilka går till IndexNow. För att avslå svarar du med en 4xx med { "error": "<code>", "details": ["<why>"] }. På path.add skickar invalid_value tillbaka värdet till skribenten med din motivering (det tredje avslaget är slutgiltigt) och varje annan kod avslår det för gott. På path.remove räknas not_found som gjort, och varje annan kod, till exempel 409 in_use, lämnar allt som det var. Att markera ett väntande sökvägskommando som Gjort på webbplatsen räknas som din webbplats ja.
Anrop: förfrågningar där AutoPSEO läser svaret
Anrop signeras som leveranser. Ett anrop väntar som standard upp till 60 sekunder och 30 sekunder för omläsningar och sökvägskontroller, och läser svar på upp till 24 MB. Varje svar som inte är en 2xx får anropet att misslyckas.
site.export
Begäran bär export: { what, cursor, limit }, plus since (en ISO-tid) för ämnen eller paths för en omläsning. Svara { "items": [], "next": "<cursor>" }; AutoPSEO frågar igen med cursor satt till ditt next tills det är tomt eller null.
what: "articles"körs i fullständiga webbplatsskanningar, 10 per sida, när du deklareradeexportmedarticlesochadopt: true. Ett 400-svar betyder "stöds inte". Medpaths: ["/answers/x"]ochlimit: 1läses en artikel om; svara med enbart den artikeln.what: "topics"körs i varje skanning och i den dagliga synkroniseringen, 50 per sida; den dagliga synkroniseringen skickarsince, tidpunkten för den senaste hämtningen.what: "profile"körs i varje skanning. Svara{ "profile": { types, languages, rules, categories, format } }, alla valfria:typessom[{ key, name, path }], ochrulesmedbrandTerms,synonyms,noise,onTopicochoffTopic.
En exporterad artikel behöver externalId, en slug (bokstäver och siffror i valfritt skriftsystem samt bindestreck, högst 200 tecken, segment sammanfogade med /), en path som slutar på den sluggen, och copy med minst title, h1 och en sektion. Valfria fält: lang (standard en), type (en innehållstypsnyckel), url, keyword, category, datePublished, dateModified, version, translations, owner (AutoPSEO:s artikel-id, när den väl är övertagen) och maintenance: { kind: "refresh" or "optimize", reason }, som köar den uppgiften. Varje exporterad artikel blir en hanterad artikel med din text som första revision, och ingenting skickas tillbaka. Det är säkert att exportera igen, och en artikel som AutoPSEO tagit ned kommer aldrig tillbaka via en export.
Ett ämnesobjekt är { keyword, status?, volume?, source?, externalId?, type?, reason? }, där status är queued, planned eller rejected.
articles.adopted
Efter importen ber AutoPSEO dig att låta det hantera de nya artiklarna: { "articles": [{ "externalId", "articleId", "slug", "path" }] }. Svara { "adopted": ["<externalId>"], "refused": [{ "externalId": "<id>", "reason": "<why>" }] }. En artikel som du inte bekräftar tas bort från AutoPSEO igen, såvida AutoPSEO inte redan har skrivit en revision av den.
site.handover
{ "handover": { "types": ["answers"], "writer": "autopseo" } }, skickas när någon väljer Låt AutoPSEO skriva dem eller Lämna tillbaka skrivandet till webbplatsen på egenskapens sida Din webbplats. Svara { "ok": true, "writer": "autopseo" }. Efter en överlämning till AutoPSEO skickar din webbplats de ämnen den granskar vidare till AutoPSEO i stället för att skriva dem.
path.check
{ "paths": [{ "path": "/interviews/google/data-engineer" }, { "path": "/interviews/jane-street", "new": { "part": "company", "value": "jane-street", "name": "Jane Street" } }] }. Svara med ett resultat per sökväg: { "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."] }] }. Ett avslag sparas med din motivering och frågas inte om igen; en sökväg som du utelämnar frågas om senare.
En minimal mottagare
En Cloudflare Worker som verifierar signaturen med Web Crypto API, hanterar de tre artikelkommandona och avvisar resten som unknown_event. Den lagrar artiklar i en KV-namnrymd bunden som ARTICLES, och signeringshemligheten i en Worker-hemlighet med namnet 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);
},
};
På Node.js verifierar du på samma sätt med createHmac och timingSafeEqual, över den råa body som ditt ramverk tog emot:
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));
}
Nästa steg: vad som når webhooken avgörs av skrivande och granskning och underhåll; hela flödet finns i översikten över innehållsmotorn.