Documentación
Webhook de entrega: recibir artículos en tu propio sitio
El webhook firmado que envía los artículos de AutoPSEO a tu sitio: cabeceras, cada evento y su payload, las respuestas que debes dar, reintentos, comandos en espera e IndexNow.
AutoPSEO envía a tu propio sitio cada artículo que publica, actualiza o retira, mediante un POST HTTPS firmado. Tu sitio guarda el artículo y lo muestra con sus propias plantillas, de modo que el contenido vive en tu sistema y sigue funcionando aunque algún día dejes de usar AutoPSEO. Esta página es la referencia para quien desarrolla el receptor: firma, cada evento con su payload, las respuestas que lee AutoPSEO, reintentos, comandos en espera e IndexNow.
Configurar el webhook
- Abre la página Content de la propiedad y ve a Where articles are published.
- Rellena Webhook address on your site. Debe ser una URL
https://con un nombre de host público: se rechazan las direcciones IP ylocalhost. AutoPSEO no sigue redirecciones, así que indica la dirección final. - Rellena Your site's address, por ejemplo
https://example.com. La ruta de cada tipo de contenido cuelga de ella, de modo que un artículo de un tipo en/answersestá enhttps://example.com/answers/<slug>. - Elige Save and send a test. AutoPSEO crea un secreto de firma (
whsec_seguido de 48 caracteres hexadecimales) y hace ping a tu dirección. Un receptor que compruebe las firmas fallará en este primer ping, porque todavía no tiene el secreto. - Muestra el Signing secret con Show o Copy, guárdalo como secreto en tu sitio y elige Send a test.
- Lee What your site handles: cada comando muestra Connected, Not connected, Declared, not tested o Not tested.
Las propiedades nuevas empiezan con AutoPSEO alojando los artículos en su propia dirección (consulta Alojamiento), y el alojamiento y el webhook pueden funcionar a la vez. Cuando tu sitio ya muestre los artículos, elige Stop hosting on AutoPSEO: todos los artículos publicados pasan a la dirección de tu sitio y se envían a tu webhook como article.published. Ese botón requiere un webhook que haya respondido a su última prueba y la dirección de tu sitio. Mientras el alojamiento esté desactivado, el webhook no se puede eliminar.
New secret sustituye el secreto de firma al instante y el anterior deja de verificarse de inmediato. Coloca el nuevo secreto en tu sitio justo después de generarlo.
AutoPSEO solo encola entregas para un webhook cuyo último ping obtuvo un 2xx. Un ping fallido (al guardar, en Send a test o al inicio de un escaneo del sitio) marca el webhook como con error, y los artículos publicados mientras está con error no se encolan para él. Mantén ping respondiendo y elige Send a test cuando hayas resuelto una caída.
Cabeceras y firma
Cada petición es un POST con cuerpo JSON y estas cabeceras:
| Cabecera | Valor |
|---|---|
Content-Type | application/json |
User-Agent | AutoPSEO-Webhook/1 |
X-AutoPSEO-Event | El nombre del evento, que también aparece en el cuerpo como event |
X-AutoPSEO-Delivery | El id de la entrega, que también aparece en el cuerpo como id. Es el mismo en cada reintento de una misma entrega |
X-AutoPSEO-Timestamp | Hora Unix en segundos en que se firmó este intento |
X-AutoPSEO-Signature | sha256= seguido del HMAC-SHA256 en hexadecimal de <timestamp>.<raw body> |
Para verificar una petición:
- Toma la cabecera de la marca de tiempo, un punto y el cuerpo de la petición en bruto exactamente como llegó, antes de cualquier análisis del JSON.
- Calcula el HMAC-SHA256 de esa cadena. La clave es el secreto de firma completo tal como lo muestra la aplicación, con el prefijo
whsec_incluido, en bytes UTF-8. - Codifica el resultado en hexadecimal y compáralo, en tiempo constante, con la parte de
X-AutoPSEO-Signatureposterior asha256=. Rechaza la petición si no coinciden.
Cada intento se firma de nuevo con su propia marca de tiempo, así que puedes rechazar las peticiones cuya marca de tiempo se aleje mucho de tu reloj (cinco minutos es una ventana habitual) sin romper los reintentos. El propio HMAC se define en el RFC 2104.
Todos los cuerpos incluyen id, event, occurredAt (ISO 8601, UTC) y site: { propertyId, domain }.
Eventos
| Evento | Tipo | Cuándo lo envía AutoPSEO |
|---|---|---|
ping | Comprobación | Al guardar el webhook, en Send a test y al inicio de cada escaneo del sitio |
test | Comprobación | Justo después de un ping correcto, uno por comando |
article.published | Entrega | Un artículo se publica por primera vez; todos los artículos publicados cuando dejas de alojar |
article.updated | Entrega | Se publica una nueva revisión (actualización, optimización, reversión, nueva publicación tras una retirada, idiomas añadidos) o se añadió una portada |
article.unpublished | Entrega | Un artículo se retira o se fusiona con otra página |
path.add | Entrega | Se aprobó un nuevo valor de un tipo de contenido con tipos |
path.remove | Entrega | Alguien pidió retirar la ruta de un valor |
path.check | Llamada | Antes de que AutoPSEO planifique una página en una dirección donde tu sitio no tiene ninguna, o proponga un valor nuevo |
site.export | Llamada | Durante un escaneo del sitio, en la sincronización diaria de temas y antes de que AutoPSEO modifique un artículo publicado |
articles.adopted | Llamada | Después de que un escaneo importe artículos que tu sitio exportó |
site.handover | Llamada | Cuando alguien cede a AutoPSEO la redacción de tipos de contenido, o la recupera |
Las entregas pasan por un outbox: se registran en el mismo lote de base de datos que el cambio, se envían de inmediato, se reintentan y pueden quedar en espera de tu sitio. Las llamadas son simples petición y respuesta: AutoPSEO lee la respuesta en el momento. Cuándo se envían los comandos de ruta se explica en Páginas con tipos y nuevas rutas.
El payload del artículo
article.published y article.updated incluyen el artículo completo, con todos los idiomas:
{
"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
}
}
}
La entrada en alemán aparece abreviada aquí; cada traducción tiene la misma forma que copy.
| Campo | Significado |
|---|---|
id | El id del artículo en AutoPSEO. Nunca cambia, por lo que es una clave segura |
slug | La última parte de la dirección. En las páginas con tipos contiene segmentos unidos por /, como google/software-engineer |
path | La dirección relativa a tu sitio: la ruta del tipo más el slug. Queda fija una vez que existe la página |
type | { key, name, path }. key es estable; path es donde viven los artículos del tipo (vacío si es la raíz del sitio) |
lang, langs | El idioma principal y todos los idiomas de esta revisión |
rev | El número de revisión |
url | La dirección completa tal como la conoce AutoPSEO: la de tu sitio cuando el alojamiento está desactivado, o la url que tu receptor respondió por última vez |
datePublished, dateModified | YYYY-MM-DD. La fecha de publicación se mantiene fija en las revisiones posteriores |
contentHash | Una huella de la revisión |
writer | Quién escribió la revisión: platform (AutoPSEO), mcp (tu propia IA), user, import (el texto propio de tu sitio) o aggregate (una página de lista) |
copy | El artículo en su idioma principal |
translations | Los demás idiomas, indexados por código de idioma |
category | La categoría de tu sitio para el artículo, cuando tu formato usa categorías |
topic | { externalId, keyword } cuando el artículo se escribió para un tema que entregó tu sitio, de modo que puedas cerrarlo |
parts | En las páginas con tipos, el valor de cada parte: { "company": "google", "role": "software-engineer" } |
kind | "list" en una página de lista. Su copy es { title, h1, items: [{ from, text }] }, donde from es la ruta de la página de la que procede cada elemento, y parts nombra el valor que agrupa |
cover | Una portada que AutoPSEO creó para el artículo. Descárgala una vez y conserva tu propia copia; la dirección nunca cambia porque el nombre del archivo es el SHA-256 de la imagen |
basedOn | En article.updated: la versión de tu página a partir de la cual se escribió esta revisión (consulta los conflictos más abajo) |
redirectTo | En article.unpublished tras una fusión: a dónde lleva ahora la dirección antigua. Respóndelo con un 301 y no sirvas nada nuevo ahí |
article.unpublished incluye los mismos campos de article salvo copy, translations, category y cover.
Dentro de copy
| Campo | Significado |
|---|---|
title, metaDescription, h1 | El título de la página, la meta descripción y el encabezado |
directAnswer | La respuesta breve que se muestra primero |
keyTakeaways | Una lista de cadenas |
sections | [{ heading, bodyHtml }] |
faq | [{ q, a }] |
sources | [{ title, url, lastVerified? }] |
question | Opcional: la pregunta del usuario con sus propias palabras, cuando tu formato la pide |
category | Opcional: una de las categorías de tu sitio |
lists | Opcional: listas de texto plano por clave, para los tipos de contenido que las piden (las preguntas de ejemplo de una guía de entrevistas) |
En los artículos que escribe AutoPSEO, bodyHtml solo usa p, ul, li, strong, em, a y code, y los enlaces conservan únicamente su href. El texto tomado de tu propio sitio conserva tu HTML. Pásalo también por tu propio sanitizador si tus plantillas lo exigen.
Qué responde tu receptor
Cualquier 2xx completa una entrega. AutoPSEO también lee un objeto JSON opcional del cuerpo de la respuesta; mantenlo por debajo de 64 KB, porque una respuesta mayor cuenta como un fallo.
| Campo | Efecto |
|---|---|
url | La dirección https:// donde vive ahora el artículo. AutoPSEO la registra como la URL del artículo |
version | La versión de tu página, una cadena opaca de 128 caracteres como máximo. Vuelve como basedOn con la siguiente actualización |
urls | Las direcciones https:// que cambió esta entrega, para IndexNow (todos los idiomas, o las direcciones que dejaste de servir). [] significa que no cambió nada |
cover | "wanted" cuando no tienes portada para el artículo y la entrega no traía ninguna. AutoPSEO crea una y la envía en un article.updated |
Las respuestas de error significan cosas distintas:
| Tu respuesta | Cómo la interpreta AutoPSEO | Qué ocurre |
|---|---|---|
Un 4xx con { "error": "unknown_event" }, o un 501 | Todavía no gestionas este comando | La entrega queda en espera, sin reintentos |
Un 409 con { "error": "conflict" } | La actualización se escribió a partir de una versión que tu página ya no tiene | La entrega falla sin reintentos, y el artículo se vuelve a leer desde tu sitio antes de su próximo cambio |
Un 4xx distinto de 408 o 429 con { "error": "<code>", "details": [] } a path.add o path.remove | Un rechazo con tu motivo | Se registra; consulta Páginas con tipos y nuevas rutas |
| Cualquier otra cosa: ninguna respuesta en 10 segundos, un error de red, una redirección, 408, 429, 5xx u otro 4xx | Un fallo temporal | Se reintenta con espera progresiva |
Reintentos y entregas reemplazadas
Una entrega se envía en cuanto se guarda el cambio, y un programa de cinco minutos envía lo que esté pendiente, empezando por lo más antiguo. Tras un fallo temporal, AutoPSEO espera 1 minuto, luego 5 minutos, 15 minutos, 1 hora, 3 horas, 6 horas y 12 horas entre intentos. El octavo intento fallido marca la entrega como fallida, y la página Content la cuenta en una insignia roja junto al webhook.
Cuando existe una entrega más reciente para el mismo artículo, una anterior que siga pendiente se marca como reemplazada y no se envía nunca. Cada entrega lleva el artículo completo, así que la más reciente es todo lo que necesita tu sitio. Los comandos de ruta sobre el mismo valor funcionan igual.
Tu receptor puede ver una entrega dos veces, por ejemplo cuando guardó el artículo pero respondió pasado el tiempo límite de 10 segundos. Deduplica por X-AutoPSEO-Delivery, o haz que cada escritura sea idempotente guardando por article.id. article.updated también puede llegar a un artículo que borraste tras un article.unpublished, porque volver a publicar un artículo retirado envía una actualización: trata las actualizaciones como "crear o reemplazar".
Comandos que tu sitio aún no gestiona
Un sitio puede implementar el protocolo comando a comando. AutoPSEO guarda el estado de cada comando y lo averigua de tres maneras:
- Pruebas. Tras cada ping correcto, AutoPSEO envía un
testpor comando. El cuerpo es{ id, event: "test", occurredAt, site, test: { event: "<command>", ... } }, dondetestcontiene una muestra con la forma del comando real y que trata de<type path>/autopseo-connection-test, una dirección que ningún sitio tiene. Compruébala como harías con el comando real, no cambies nada y responde con un 2xx:{ "test": "<command>", "ok": true }para Connected,{ "test": "<command>", "ok": false, "error": "unknown_event" }para Not connected. Una respuesta conok: falsey otro código de error, como la retirada de una dirección que no tienes, cuenta igualmente como conectado. La muestra va dentro detest, nunca en el nivel superior, de modo que un receptor que no sepa nada de pruebas no encuentra en ella ningún artículo. - Entregas y llamadas reales. Un 2xx marca el comando como conectado;
unknown_eventlo marca como no conectado. - La respuesta al ping. Los comandos que tu respuesta enumera en
autopseo.eventsaparecen como Declared, not tested hasta que una prueba o una entrega diga algo más.
Una entrega de un comando que no está conectado queda en espera. La página Content enumera lo que espera, por ejemplo "Your site does not take articles down yet", con un botón Done on the site para todo lo que hayas hecho a mano; AutoPSEO no volverá a enviar esa entrega. Cuando una prueba o una entrega posterior detecta el comando como conectado, se envía todo lo que esperaba por él.
La respuesta al ping
Responde a ping con un 2xx. Un sitio que solo recibe artículos no necesita nada más. Para indicar a AutoPSEO qué otras cosas hace tu sitio, incluye un objeto 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"
}
}
| Campo | Significado |
|---|---|
version | 1. El objeto se ignora si no lleva una versión igual o superior a 1 |
events | Los comandos que gestionas, mostrados como declarados hasta que se prueban |
covers | true cuando tus páginas muestran una portada con cada artículo. El redactor de AutoPSEO entonces crea la portada mientras escribe, de modo que la primera entrega la incluye, y se pide a tu propia IA que aporte una. Consulta Portadas |
indexNow | { key, keyLocation? }: la clave de IndexNow que sirves (de 8 a 128 letras, dígitos y guiones) y, cuando el archivo de la clave no está en /<key>.txt, su ubicación https:// |
export | Lo que entrega site.export: articles, topics, profile |
adopt | Aceptas articles.adopted, de modo que un escaneo completo puede asumir tus artículos existentes |
handover | Aceptas site.handover |
types | Las claves de los tipos de contenido que aceptas (hasta 50) |
writer | Quién escribe hoy tus artículos nuevos: site o autopseo |
IndexNow para las direcciones que sirves
Cuando tu respuesta al ping declara indexNow, AutoPSEO hace ping a IndexNow en api.indexnow.org después de las entregas que aceptas, y tu sitio no hace ningún ping por su cuenta.
- Envía las
urlsque enumeró tu respuesta. Sinurls, envía laurlde tu respuesta y, si no la hay, la URL del artículo.urls: []no envía nada. - Solo se incluyen direcciones de tu propio host, como exige el protocolo IndexNow: el host de
keyLocationo, si no, el host de la dirección de tu sitio. - Cada ejecución de entregas envía un ping por sitio, con hasta 10,000 direcciones.
- Se aceptan 200 y 202. Un 429, un 5xx o la falta de respuesta conserva las direcciones y vuelve a intentarlo después de una hora, duplicando el plazo cada vez hasta un día (más si
Retry-Afterlo pide), junto con lo que hayan añadido las entregas posteriores. Un 403 (clave no válida) o un 422 (direcciones que no pertenecen al host) se registra y se descarta. - La página Content muestra el último ping: cuándo, cuántas direcciones, el estado HTTP y el próximo intento.
- Mientras AutoPSEO también aloja los artículos, las páginas alojadas se notifican con la clave propia de AutoPSEO, y la tuya no se usa.
La herramienta IndexNow de Indexing es un envío manual aparte; consulta IndexNow.
Conflictos: el texto de tu sitio es el original
La version de tu respuesta (o la version de un artículo en tu exportación) es la versión en la que AutoPSEO cree que está tu página. Cada article.updated la incluye como basedOn. Si la página ha cambiado en tu sitio desde entonces, por ejemplo tras la corrección de un editor, responde 409 con { "error": "conflict" } y no guardes nada. La entrega falla sin reintentos.
Antes de escribir un cambio en cola sobre un artículo publicado, AutoPSEO vuelve a leer ese artículo desde tu sitio con site.export y paths, cuando tu sitio gestiona site.export. Si tu versión ha avanzado, tu texto pasa a ser la siguiente revisión del artículo (redactor import), y la actualización que sigue lleva tu versión actual como basedOn.
path.add y path.remove
Estas dos entregas se refieren a un valor de un tipo de contenido con tipos, por ejemplo una empresa en /interviews/{company}/{role}. Llevan un objeto path en lugar 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"] }
}
}
| Campo | Significado |
|---|---|
path | La dirección propia del valor en tu sitio |
type | El tipo de contenido, como { key, name, path } |
part | La parte de la dirección que ocupa el valor |
value | { value, name }, y en path.add también los aliases, url, sameAs, description y facts que reunió el redactor de AutoPSEO. Cada dato tiene text, source, lastVerified y un tier que puede ser official o reported |
takes | En path.add: los valores de las otras partes por los que la gente busca junto con este |
redirectTo | En path.remove: a dónde deben llevar a partir de ahora las direcciones del valor, con un 301 |
Responde con un 2xx cuando hayas aceptado el comando; en path.add puedes añadir la url donde vive ahora el valor, y en path.remove las urls que dejaste de servir, que van a IndexNow. Para rechazar, responde con un 4xx con { "error": "<code>", "details": ["<why>"] }. En path.add, invalid_value devuelve el valor al redactor con tu motivo (el tercer rechazo es definitivo) y cualquier otro código lo rechaza para siempre. En path.remove, not_found cuenta como hecho, y cualquier otro código, como un 409 in_use, deja todo como estaba. Marcar un comando de ruta en espera como Done on the site cuenta como el sí de tu sitio.
Llamadas: peticiones cuya respuesta lee AutoPSEO
Las llamadas se firman igual que las entregas. Una llamada espera hasta 60 segundos por defecto y 30 segundos en las relecturas y las comprobaciones de ruta, y lee respuestas de hasta 24 MB. Cualquier respuesta distinta de un 2xx hace fallar la llamada.
site.export
La petición incluye export: { what, cursor, limit }, más since (una hora ISO) para los temas o paths para una relectura. Responde { "items": [], "next": "<cursor>" }; AutoPSEO vuelve a preguntar con cursor igual a tu next hasta que esté vacío o sea null.
what: "articles"se ejecuta en los escaneos completos del sitio, 10 por página, cuando declarasteexportconarticlesyadopt: true. Una respuesta 400 significa "no compatible". Conpaths: ["/answers/x"]ylimit: 1vuelve a leer un artículo; responde solo con ese artículo.what: "topics"se ejecuta en cada escaneo y en la sincronización diaria, 50 por página; la sincronización diaria envíasince, la hora de la última extracción.what: "profile"se ejecuta en cada escaneo. Responde{ "profile": { types, languages, rules, categories, format } }, todos opcionales:typescomo[{ key, name, path }], yrulesconbrandTerms,synonyms,noise,onTopicyoffTopic.
Un artículo exportado necesita externalId, un slug (letras y dígitos de cualquier escritura y guiones, 200 caracteres como máximo, con segmentos unidos por /), un path que termine en ese slug y copy con al menos title, h1 y una sección. Campos opcionales: lang (por defecto en), type (una clave de tipo de contenido), url, keyword, category, datePublished, dateModified, version, translations, owner (el id del artículo en AutoPSEO, una vez adoptado) y maintenance: { kind: "refresh" or "optimize", reason }, que pone en cola esa tarea. Cada artículo exportado se convierte en un artículo gestionado con tu texto como primera revisión, y no se envía nada de vuelta. Exportar de nuevo es seguro, y un artículo que AutoPSEO retiró nunca vuelve a través de una exportación.
Un elemento de tema es { keyword, status?, volume?, source?, externalId?, type?, reason? }, donde status es queued, planned o rejected.
articles.adopted
Tras importar, AutoPSEO te pide que le dejes gestionar los artículos nuevos: { "articles": [{ "externalId", "articleId", "slug", "path" }] }. Responde { "adopted": ["<externalId>"], "refused": [{ "externalId": "<id>", "reason": "<why>" }] }. Un artículo que no confirmes se elimina de nuevo de AutoPSEO, salvo que AutoPSEO ya haya escrito una revisión de él.
site.handover
{ "handover": { "types": ["answers"], "writer": "autopseo" } }, que se envía cuando alguien elige Let AutoPSEO write them o Hand writing back to the site en la página Your site de la propiedad. Responde { "ok": true, "writer": "autopseo" }. Tras ceder la redacción a AutoPSEO, tu sitio le pasa los temas que revisa en lugar de escribirlos.
path.check
{ "paths": [{ "path": "/interviews/google/data-engineer" }, { "path": "/interviews/jane-street", "new": { "part": "company", "value": "jane-street", "name": "Jane Street" } }] }. Responde con un resultado por ruta: { "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 rechazo se conserva con tu motivo y no se vuelve a preguntar; una ruta que omitas se vuelve a preguntar más adelante.
Un receptor mínimo
Un Cloudflare Worker que verifica la firma con la Web Crypto API, gestiona los tres comandos de artículo y rechaza el resto como unknown_event. Guarda los artículos en un espacio de nombres de KV enlazado como ARTICLES, y el secreto de firma en un secreto del Worker llamado 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);
},
};
En Node.js, verifica de la misma forma con createHmac y timingSafeEqual, sobre el cuerpo en bruto que recibió tu 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));
}
A continuación: lo que llega al webhook lo deciden la redacción y revisión y el mantenimiento; el flujo completo está en la visión general del motor de contenido.