Documentation
Webhook de livraison : recevoir les articles sur votre propre site
Le webhook signé qui envoie les articles AutoPSEO à votre site : en-têtes, chaque événement et sa charge utile, les réponses à renvoyer, les nouvelles tentatives, les commandes en attente et IndexNow.
AutoPSEO envoie chaque article qu'il publie, met à jour ou retire à votre propre site sous la forme d'un POST HTTPS signé. Votre site stocke l'article et l'affiche avec ses propres gabarits : le contenu reste donc dans votre système et continue de fonctionner si vous quittez AutoPSEO un jour. Cette page est la référence du développeur qui construit le récepteur : signature, chaque événement avec sa charge utile, les réponses qu'AutoPSEO lit, les nouvelles tentatives, les commandes en attente et IndexNow.
Configurer le webhook
- Ouvrez la page Contenu de la propriété et allez à Où les articles sont publiés.
- Renseignez Adresse du webhook sur votre site. Ce doit être une URL
https://sur un nom d'hôte public : les adresses IP etlocalhostsont refusées. AutoPSEO ne suit pas les redirections, indiquez donc l'adresse définitive. - Renseignez Adresse de votre site, par exemple
https://example.com. Le chemin de chaque type de contenu s'y rattache : un article d'un type situé à/answersse trouve donc àhttps://example.com/answers/<slug>. - Choisissez Enregistrer et envoyer un test. AutoPSEO crée un secret de signature (
whsec_suivi de 48 caractères hexadécimaux) et envoie un ping à votre adresse. Un récepteur qui vérifie les signatures échoue à ce premier ping, car il ne possède pas encore le secret. - Affichez le Secret de signature avec Afficher ou Copier, enregistrez-le comme secret sur votre site, puis choisissez Envoyer un test.
- Lisez Ce que gère votre site : chaque commande affiche Connecté, Non connecté, Déclaré, non testé ou Non testé.
Les nouvelles propriétés démarrent avec des articles hébergés par AutoPSEO à sa propre adresse (voir Hébergement), et l'hébergement et le webhook peuvent fonctionner en parallèle. Une fois que votre site affiche les articles, choisissez Arrêter l'hébergement sur AutoPSEO : chaque article en ligne passe à l'adresse de votre site et est envoyé à votre webhook sous la forme article.published. Ce bouton exige un webhook qui a répondu à son dernier test, ainsi que l'adresse de votre site. Tant que l'hébergement est désactivé, le webhook ne peut pas être supprimé.
Nouveau secret remplace aussitôt le secret de signature, et l'ancien cesse immédiatement d'être valide. Installez le nouveau secret sur votre site juste après l'avoir généré.
AutoPSEO ne met des livraisons en file d'attente que pour un webhook dont le dernier ping a reçu une réponse 2xx. Un ping en échec (à l'enregistrement, sur Envoyer un test ou au début d'une analyse du site) place le webhook en erreur, et les articles publiés pendant cette période ne sont pas mis en file pour lui. Faites en sorte que ping réponde toujours, et choisissez Envoyer un test après avoir corrigé une panne.
En-têtes et signature
Chaque requête est un POST avec un corps JSON et ces en-têtes :
| En-tête | Valeur |
|---|---|
Content-Type | application/json |
User-Agent | AutoPSEO-Webhook/1 |
X-AutoPSEO-Event | Le nom de l'événement, repris dans le corps sous event |
X-AutoPSEO-Delivery | L'identifiant de la livraison, repris dans le corps sous id. Identique à chaque nouvelle tentative d'une même livraison |
X-AutoPSEO-Timestamp | Heure Unix, en secondes, à laquelle cette tentative a été signée |
X-AutoPSEO-Signature | sha256= suivi du HMAC-SHA256 hexadécimal de <timestamp>.<raw body> |
Pour vérifier une requête :
- Prenez l'en-tête d'horodatage, un point, puis le corps brut de la requête tel qu'il est arrivé, avant tout traitement JSON.
- Calculez le HMAC-SHA256 de cette chaîne. La clé est l'intégralité du secret de signature tel que l'application l'affiche, préfixe
whsec_compris, en octets UTF-8. - Encodez le résultat en hexadécimal et comparez-le, en temps constant, avec la partie de
X-AutoPSEO-Signaturequi suitsha256=. Rejetez la requête si les deux valeurs diffèrent.
Chaque tentative est signée à nouveau avec son propre horodatage : vous pouvez donc refuser les requêtes dont l'horodatage s'écarte trop de votre horloge (cinq minutes est une fenêtre courante) sans perturber les nouvelles tentatives. HMAC est défini dans la RFC 2104.
Chaque corps contient id, event, occurredAt (ISO 8601, UTC) et site: { propertyId, domain }.
Événements
| Événement | Type | Quand AutoPSEO l'envoie |
|---|---|---|
ping | Vérification | À l'enregistrement du webhook, sur Envoyer un test, et au début de chaque analyse du site |
test | Vérification | Juste après un ping réussi, un par commande |
article.published | Livraison | Un article est mis en ligne pour la première fois ; chaque article en ligne lorsque vous arrêtez l'hébergement |
article.updated | Livraison | Une nouvelle révision est mise en ligne (actualisation, optimisation, retour en arrière, nouvelle publication après un retrait, langues ajoutées) ou une image de couverture a été ajoutée |
article.unpublished | Livraison | Un article est retiré, ou fusionné dans une autre page |
path.add | Livraison | Une nouvelle valeur d'un type de contenu à variables a été approuvée |
path.remove | Livraison | Quelqu'un a demandé le retrait du chemin d'une valeur |
path.check | Appel | Avant qu'AutoPSEO planifie une page à une adresse où votre site n'a pas de page, ou propose une nouvelle valeur |
site.export | Appel | Pendant une analyse du site, lors de la synchronisation quotidienne des sujets, et avant qu'AutoPSEO modifie un article en ligne |
articles.adopted | Appel | Après qu'une analyse a importé des articles exportés par votre site |
site.handover | Appel | Quand quelqu'un confie la rédaction de types de contenu à AutoPSEO, ou la reprend |
Les livraisons passent par une boîte d'envoi (outbox) : elles sont enregistrées dans le même lot de base de données que la modification, envoyées aussitôt, retentées, et peuvent attendre votre site. Les appels sont de simples requêtes avec réponse : AutoPSEO lit la réponse immédiatement. Le moment où les commandes de chemin sont envoyées est expliqué dans Pages à variables et nouveaux chemins.
La charge utile d'un article
article.published et article.updated contiennent l'article complet, toutes langues comprises :
{
"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
}
}
}
L'entrée allemande est abrégée ici ; chaque traduction a la même forme que copy.
| Champ | Signification |
|---|---|
id | L'identifiant de l'article chez AutoPSEO. Il ne change jamais : c'est donc une clé fiable |
slug | La dernière partie de l'adresse. Sur les pages à variables, il contient des segments joints par /, comme google/software-engineer |
path | L'adresse relative à votre site : le chemin du type suivi du slug. Figée une fois la page créée |
type | { key, name, path }. key est stable ; path est l'endroit où se trouvent les articles du type (vide pour la racine du site) |
lang, langs | La langue principale et toutes les langues de cette révision |
rev | Le numéro de révision |
url | L'adresse complète telle qu'AutoPSEO la connaît : celle de votre site une fois l'hébergement désactivé, ou l'url que votre récepteur a renvoyée en dernier |
datePublished, dateModified | YYYY-MM-DD. La date de publication reste fixe au fil des révisions |
contentHash | Une empreinte de la révision |
writer | L'auteur de la révision : platform (AutoPSEO), mcp (votre propre IA), user, import (le texte de votre site) ou aggregate (une page de liste) |
copy | L'article dans sa langue principale |
translations | Les autres langues, indexées par code de langue |
category | La catégorie de votre site pour cet article, lorsque votre format gère les catégories |
topic | { externalId, keyword } lorsque l'article a été rédigé pour un sujet transmis par votre site, afin que vous puissiez clore ce sujet |
parts | Sur les pages à variables, la valeur de chaque partie : { "company": "google", "role": "software-engineer" } |
kind | "list" sur une page de liste. Son contenu est { title, h1, items: [{ from, text }] }, où from est le chemin de la page d'où provient chaque élément, et parts nomme la valeur qu'elle rassemble |
cover | Une couverture créée par AutoPSEO pour l'article. Récupérez-la une fois et conservez votre propre copie ; l'adresse ne change jamais, car le nom du fichier est le SHA-256 de l'image |
basedOn | Sur article.updated : la version de votre page à partir de laquelle cette révision a été rédigée (voir les conflits plus bas) |
redirectTo | Sur article.unpublished après une fusion : l'endroit où mène désormais l'ancienne adresse. Répondez par une 301 et ne publiez rien de nouveau à cette adresse |
article.unpublished contient les mêmes champs article, sauf copy, translations, category et cover.
Le contenu de copy
| Champ | Signification |
|---|---|
title, metaDescription, h1 | Le titre de la page, la meta description et le titre principal |
directAnswer | La réponse courte affichée en premier |
keyTakeaways | Une liste de chaînes |
sections | [{ heading, bodyHtml }] |
faq | [{ q, a }] |
sources | [{ title, url, lastVerified? }] |
question | Facultatif : la question de la personne qui cherche, dans ses mots, lorsque votre format la demande |
category | Facultatif : l'une des catégories de votre site |
lists | Facultatif : des listes en texte brut, indexées par clé, pour les types de contenu qui en demandent (les exemples de questions d'un guide d'entretien) |
Dans les articles qu'AutoPSEO rédige, bodyHtml n'utilise que p, ul, li, strong, em, a et code, et les liens ne conservent que leur href. Le texte repris de votre propre site conserve votre HTML. Faites-le aussi passer par votre propre outil d'assainissement si vos gabarits l'exigent.
Ce que votre récepteur répond
Toute réponse 2xx termine une livraison. AutoPSEO lit aussi un objet JSON facultatif dans le corps de la réponse ; gardez-le sous 64 KB, car une réponse plus volumineuse compte comme un échec.
| Champ | Effet |
|---|---|
url | L'adresse https:// où l'article se trouve désormais. AutoPSEO l'enregistre comme URL de l'article |
version | La version de votre page, une chaîne opaque de 128 caractères au plus. Elle revient sous basedOn avec la mise à jour suivante |
urls | Les adresses https:// que cette livraison a modifiées, pour IndexNow (toutes les langues, ou les adresses que vous avez cessé de servir). [] signifie que rien n'a changé |
cover | "wanted" lorsque vous n'avez pas de couverture pour l'article et que la livraison n'en contenait pas. AutoPSEO en crée une et l'envoie dans un article.updated |
Les réponses d'erreur n'ont pas toutes la même signification :
| Votre réponse | Comment AutoPSEO l'interprète | Ce qui se passe |
|---|---|---|
Une 4xx avec { "error": "unknown_event" }, ou une 501 | Vous ne gérez pas encore cette commande | La livraison attend, sans nouvelle tentative |
Une 409 avec { "error": "conflict" } | La mise à jour a été rédigée à partir d'une version que votre page n'a plus | La livraison échoue sans nouvelle tentative, et l'article est relu depuis votre site avant sa prochaine modification |
Une 4xx autre que 408 ou 429 avec { "error": "<code>", "details": [] } en réponse à path.add ou path.remove | Un refus accompagné de votre motif | Enregistré ; voir Pages à variables et nouveaux chemins |
| Tout le reste : pas de réponse en 10 secondes, une erreur réseau, une redirection, 408, 429, 5xx ou une autre 4xx | Un échec temporaire | Nouvelle tentative avec délai croissant |
Nouvelles tentatives et livraisons remplacées
Une livraison est envoyée dès que la modification est enregistrée, et un cycle de cinq minutes envoie tout ce qui est dû, en commençant par le plus ancien. Après un échec temporaire, AutoPSEO attend 1 minute, puis 5 minutes, 15 minutes, 1 heure, 3 heures, 6 heures et 12 heures entre les tentatives. Le huitième échec marque la livraison comme échouée, et la page Contenu la compte dans un badge rouge à côté du webhook.
Lorsqu'une livraison plus récente existe pour le même article, une livraison plus ancienne encore en attente est marquée comme remplacée et n'est jamais envoyée. Chaque livraison contient l'article complet : la plus récente suffit donc à votre site. Les commandes de chemin concernant la même valeur fonctionnent de la même manière.
Votre récepteur peut voir une livraison deux fois, par exemple lorsqu'il a stocké l'article mais répondu après le délai de 10 secondes. Dédupliquez sur X-AutoPSEO-Delivery, ou rendez chaque écriture idempotente en stockant sur article.id. Un article.updated peut aussi viser un article que vous avez supprimé après un article.unpublished, car republier un article retiré envoie une mise à jour : traitez les mises à jour comme « créer ou remplacer ».
Commandes que votre site ne gère pas encore
Un site peut implémenter le protocole une commande à la fois. AutoPSEO conserve l'état de chaque commande et l'apprend de trois façons :
- Les tests. Après chaque ping réussi, AutoPSEO envoie un
testpar commande. Le corps est{ id, event: "test", occurredAt, site, test: { event: "<command>", ... } }, oùtestcontient un exemple conforme à la vraie commande et portant sur<type path>/autopseo-connection-test, une adresse qu'aucun site n'a. Vérifiez-le comme vous vérifieriez la vraie commande, ne modifiez rien, et répondez par une 2xx :{ "test": "<command>", "ok": true }pour Connecté,{ "test": "<command>", "ok": false, "error": "unknown_event" }pour Non connecté. Une réponse avecok: falseet un autre code d'erreur, comme le retrait d'une adresse que vous n'avez pas, compte tout de même comme connectée. L'exemple se trouve soustest, jamais au premier niveau : un récepteur qui ne sait rien des tests n'y trouve donc aucun article. - Les vraies livraisons et les vrais appels. Une 2xx marque la commande comme connectée ;
unknown_eventla marque comme non connectée. - La réponse au ping. Les commandes que votre réponse liste dans
autopseo.eventss'affichent comme Déclaré, non testé jusqu'à ce qu'un test ou une livraison en dise davantage.
Une livraison destinée à une commande non connectée attend. La page Contenu liste ce qui attend, par exemple « Votre site ne retire pas encore les articles », avec un bouton Fait sur le site pour tout ce que vous avez effectué à la main ; AutoPSEO n'envoie alors jamais cette livraison. Lorsqu'un test ou une livraison ultérieurs trouvent la commande connectée, tout ce qui l'attendait est envoyé.
La réponse au ping
Répondez à ping par une 2xx. Un site qui ne fait que recevoir des articles n'a besoin de rien de plus. Pour indiquer à AutoPSEO ce que fait d'autre votre site, incluez un objet autopseo :
{
"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"
}
}
| Champ | Signification |
|---|---|
version | 1. L'objet est ignoré sans une version égale ou supérieure à 1 |
events | Les commandes que vous gérez, affichées comme déclarées jusqu'à ce qu'elles soient testées |
covers | true lorsque vos pages affichent une couverture avec chaque article. Le rédacteur d'AutoPSEO crée alors la couverture pendant la rédaction, de sorte que la première livraison la contient, et votre propre IA est invitée à en fournir une. Voir Couvertures |
indexNow | { key, keyLocation? } : la clé IndexNow que vous servez (de 8 à 128 lettres, chiffres et tirets) et, lorsque le fichier de clé n'est pas à /<key>.txt, son emplacement en https:// |
export | Ce que site.export fournit : articles, topics, profile |
adopt | Vous acceptez articles.adopted, de sorte qu'une analyse complète peut reprendre vos articles existants |
handover | Vous acceptez site.handover |
types | Les clés des types de contenu que vous acceptez (50 au plus) |
writer | Qui rédige vos nouveaux articles aujourd'hui : site ou autopseo |
IndexNow pour les adresses que vous servez
Lorsque votre réponse au ping déclare indexNow, AutoPSEO notifie IndexNow à api.indexnow.org après les livraisons que vous acceptez, et votre site n'a rien à notifier lui-même.
- Il envoie les
urlslistées dans votre réponse. Sansurls, il envoie l'urlde votre réponse, à défaut l'URL de l'article.urls: []n'envoie rien. - Seules les adresses de votre propre hôte sont incluses, comme l'exige le protocole IndexNow : l'hôte de
keyLocation, à défaut l'hôte de l'adresse de votre site. - Chaque exécution de livraisons envoie un seul ping par site, avec jusqu'à 10 000 adresses.
- 200 et 202 sont acceptés. Une 429, une 5xx ou l'absence de réponse conserve les adresses et réessaie au bout d'une heure, avec un délai doublé à chaque fois jusqu'à un jour (davantage si
Retry-Afterle demande), en y ajoutant ce que les livraisons ultérieures ont apporté. Une 403 (clé non valide) ou une 422 (adresses n'appartenant pas à l'hôte) est enregistrée puis abandonnée. - La page Contenu affiche le dernier ping : quand, combien d'adresses, le statut HTTP et la prochaine tentative.
- Tant qu'AutoPSEO héberge aussi les articles, les pages hébergées sont notifiées avec la clé propre à AutoPSEO, et la vôtre n'est pas utilisée.
L'outil IndexNow sous Indexation est un outil de soumission distinct, manuel ; voir IndexNow.
Conflits : le texte de votre site fait foi
La version de votre réponse (ou la version d'un article de votre export) est ce qu'AutoPSEO croit être l'état de votre page. Chaque article.updated la transmet sous basedOn. Si la page a changé sur votre site depuis, par exemple après la correction d'un éditeur, répondez 409 avec { "error": "conflict" } et ne stockez rien. La livraison échoue sans nouvelle tentative.
Avant qu'une modification en attente sur un article en ligne soit rédigée, AutoPSEO relit cet article depuis votre site avec site.export et paths, lorsque votre site gère site.export. Si votre version a évolué, votre texte devient la révision suivante de l'article (writer import), et la mise à jour qui suit transmet votre version actuelle sous basedOn.
path.add et path.remove
Ces deux livraisons concernent une valeur d'un type de contenu à variables, par exemple une entreprise dans /interviews/{company}/{role}. Elles contiennent un objet path au lieu de 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"] }
}
}
| Champ | Signification |
|---|---|
path | L'adresse propre à la valeur sur votre site |
type | Le type de contenu, sous la forme { key, name, path } |
part | La partie de l'adresse que la valeur remplit |
value | { value, name }, et, sur path.add, les aliases, url, sameAs, description et facts que le rédacteur d'AutoPSEO a rassemblés. Chaque fait a un text, une source, un lastVerified et un tier valant official ou reported |
takes | Sur path.add : les valeurs des autres parties que les internautes recherchent avec elle |
redirectTo | Sur path.remove : l'endroit où les adresses de la valeur doivent désormais mener, par une 301 |
Répondez par une 2xx lorsque vous avez pris en compte la commande ; sur path.add, vous pouvez ajouter l'url où la valeur se trouve désormais, et sur path.remove les urls que vous avez cessé de servir, qui sont transmises à IndexNow. Pour refuser, répondez par une 4xx avec { "error": "<code>", "details": ["<why>"] }. Sur path.add, invalid_value renvoie la valeur au rédacteur avec votre motif (le troisième refus est définitif) et tout autre code la refuse définitivement. Sur path.remove, not_found compte comme fait, et tout autre code, comme une 409 in_use, laisse tout en l'état. Marquer une commande de chemin en attente comme Fait sur le site vaut accord de votre site.
Appels : requêtes dont AutoPSEO lit la réponse
Les appels sont signés comme les livraisons. Un appel attend jusqu'à 60 secondes par défaut et 30 secondes pour les relectures et les vérifications de chemin, et lit des réponses allant jusqu'à 24 MB. Toute réponse autre qu'une 2xx fait échouer l'appel.
site.export
La requête contient export: { what, cursor, limit }, plus since (une date ISO) pour les sujets ou paths pour une relecture. Répondez { "items": [], "next": "<cursor>" } ; AutoPSEO redemande avec cursor égal à votre next jusqu'à ce qu'il soit vide ou null.
what: "articles"s'exécute lors des analyses complètes du site, par pages de 10, lorsque vous avez déclaréexportavecarticlesetadopt: true. Une réponse 400 signifie « non pris en charge ». Avecpaths: ["/answers/x"]etlimit: 1, il relit un seul article ; répondez uniquement avec cet article.what: "topics"s'exécute à chaque analyse et lors de la synchronisation quotidienne, par pages de 50 ; la synchronisation quotidienne envoiesince, l'heure de la dernière récupération.what: "profile"s'exécute à chaque analyse. Répondez{ "profile": { types, languages, rules, categories, format } }, chaque élément étant facultatif :typessous la forme[{ key, name, path }], etrulesavecbrandTerms,synonyms,noise,onTopicetoffTopic.
Un article exporté a besoin d'un externalId, d'un slug (lettres et chiffres de n'importe quelle écriture et traits d'union, 200 caractères au plus, segments joints par /), d'un path se terminant par ce slug, et d'un copy avec au moins title, h1 et une section. Champs facultatifs : lang (en par défaut), type (une clé de type de contenu), url, keyword, category, datePublished, dateModified, version, translations, owner (l'identifiant d'article d'AutoPSEO, une fois adopté) et maintenance: { kind: "refresh" or "optimize", reason }, qui met cette tâche en file d'attente. Chaque article exporté devient un article géré dont votre texte est la première révision, et rien n'est renvoyé. Exporter à nouveau est sans danger, et un article qu'AutoPSEO a retiré ne revient jamais par un export.
Un élément de sujet est { keyword, status?, volume?, source?, externalId?, type?, reason? }, où status vaut queued, planned ou rejected.
articles.adopted
Après l'import, AutoPSEO vous demande de le laisser gérer les nouveaux articles : { "articles": [{ "externalId", "articleId", "slug", "path" }] }. Répondez { "adopted": ["<externalId>"], "refused": [{ "externalId": "<id>", "reason": "<why>" }] }. Un article que vous ne confirmez pas est de nouveau retiré d'AutoPSEO, sauf si AutoPSEO en a déjà rédigé une révision.
site.handover
{ "handover": { "types": ["answers"], "writer": "autopseo" } }, envoyé lorsque quelqu'un choisit Laisser AutoPSEO les rédiger ou Rendre la rédaction au site sur la page Votre site de la propriété. Répondez { "ok": true, "writer": "autopseo" }. Après un transfert à AutoPSEO, votre site lui transmet les sujets qu'il valide au lieu de les rédiger.
path.check
{ "paths": [{ "path": "/interviews/google/data-engineer" }, { "path": "/interviews/jane-street", "new": { "part": "company", "value": "jane-street", "name": "Jane Street" } }] }. Répondez avec un résultat par chemin : { "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."] }] }. Un refus est conservé avec votre motif et n'est plus redemandé ; un chemin que vous omettez sera redemandé plus tard.
Un récepteur minimal
Un Cloudflare Worker qui vérifie la signature avec la Web Crypto API, gère les trois commandes d'article et refuse les autres avec unknown_event. Il conserve les articles dans un espace de noms KV lié sous le nom ARTICLES, et le secret de signature dans un secret de Worker nommé 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);
},
};
Sous Node.js, vérifiez de la même façon avec createHmac et timingSafeEqual, sur le corps brut reçu par votre framework :
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));
}
Pour la suite : ce qui parvient au webhook dépend de la rédaction et de la relecture ainsi que de la maintenance ; l'ensemble du flux est décrit dans la vue d'ensemble du moteur de contenu.