Saltar al contenido
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.

Propiedad → Contenido → Dónde se publican los artículos21 min de lectura

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

  1. Abre la página Content de la propiedad y ve a Where articles are published.
  2. Rellena Webhook address on your site. Debe ser una URL https:// con un nombre de host público: se rechazan las direcciones IP y localhost. AutoPSEO no sigue redirecciones, así que indica la dirección final.
  3. 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 /answers está en https://example.com/answers/<slug>.
  4. 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.
  5. Muestra el Signing secret con Show o Copy, guárdalo como secreto en tu sitio y elige Send a test.
  6. 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:

CabeceraValor
Content-Typeapplication/json
User-AgentAutoPSEO-Webhook/1
X-AutoPSEO-EventEl nombre del evento, que también aparece en el cuerpo como event
X-AutoPSEO-DeliveryEl 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-TimestampHora Unix en segundos en que se firmó este intento
X-AutoPSEO-Signaturesha256= seguido del HMAC-SHA256 en hexadecimal de <timestamp>.<raw body>

Para verificar una petición:

  1. 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.
  2. 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.
  3. Codifica el resultado en hexadecimal y compáralo, en tiempo constante, con la parte de X-AutoPSEO-Signature posterior a sha256=. 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

EventoTipoCuándo lo envía AutoPSEO
pingComprobaciónAl guardar el webhook, en Send a test y al inicio de cada escaneo del sitio
testComprobaciónJusto después de un ping correcto, uno por comando
article.publishedEntregaUn artículo se publica por primera vez; todos los artículos publicados cuando dejas de alojar
article.updatedEntregaSe 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.unpublishedEntregaUn artículo se retira o se fusiona con otra página
path.addEntregaSe aprobó un nuevo valor de un tipo de contenido con tipos
path.removeEntregaAlguien pidió retirar la ruta de un valor
path.checkLlamadaAntes de que AutoPSEO planifique una página en una dirección donde tu sitio no tiene ninguna, o proponga un valor nuevo
site.exportLlamadaDurante un escaneo del sitio, en la sincronización diaria de temas y antes de que AutoPSEO modifique un artículo publicado
articles.adoptedLlamadaDespués de que un escaneo importe artículos que tu sitio exportó
site.handoverLlamadaCuando 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.

CampoSignificado
idEl id del artículo en AutoPSEO. Nunca cambia, por lo que es una clave segura
slugLa última parte de la dirección. En las páginas con tipos contiene segmentos unidos por /, como google/software-engineer
pathLa 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, langsEl idioma principal y todos los idiomas de esta revisión
revEl número de revisión
urlLa 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, dateModifiedYYYY-MM-DD. La fecha de publicación se mantiene fija en las revisiones posteriores
contentHashUna huella de la revisión
writerQuié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)
copyEl artículo en su idioma principal
translationsLos demás idiomas, indexados por código de idioma
categoryLa 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
partsEn 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
coverUna 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
basedOnEn 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)
redirectToEn 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

CampoSignificado
title, metaDescription, h1El título de la página, la meta descripción y el encabezado
directAnswerLa respuesta breve que se muestra primero
keyTakeawaysUna lista de cadenas
sections[{ heading, bodyHtml }]
faq[{ q, a }]
sources[{ title, url, lastVerified? }]
questionOpcional: la pregunta del usuario con sus propias palabras, cuando tu formato la pide
categoryOpcional: una de las categorías de tu sitio
listsOpcional: 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.

CampoEfecto
urlLa dirección https:// donde vive ahora el artículo. AutoPSEO la registra como la URL del artículo
versionLa versión de tu página, una cadena opaca de 128 caracteres como máximo. Vuelve como basedOn con la siguiente actualización
urlsLas 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 respuestaCómo la interpreta AutoPSEOQué ocurre
Un 4xx con { "error": "unknown_event" }, o un 501Todavía no gestionas este comandoLa 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 tieneLa 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.removeUn rechazo con tu motivoSe 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 4xxUn fallo temporalSe 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 test por comando. El cuerpo es { id, event: "test", occurredAt, site, test: { event: "<command>", ... } }, donde test contiene 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 con ok: false y otro código de error, como la retirada de una dirección que no tienes, cuenta igualmente como conectado. La muestra va dentro de test, 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_event lo marca como no conectado.
  • La respuesta al ping. Los comandos que tu respuesta enumera en autopseo.events aparecen 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"
  }
}
CampoSignificado
version1. El objeto se ignora si no lleva una versión igual o superior a 1
eventsLos comandos que gestionas, mostrados como declarados hasta que se prueban
coverstrue 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://
exportLo que entrega site.export: articles, topics, profile
adoptAceptas articles.adopted, de modo que un escaneo completo puede asumir tus artículos existentes
handoverAceptas site.handover
typesLas claves de los tipos de contenido que aceptas (hasta 50)
writerQuié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 urls que enumeró tu respuesta. Sin urls, envía la url de 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 keyLocation o, 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-After lo 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"] }
  }
}
CampoSignificado
pathLa dirección propia del valor en tu sitio
typeEl tipo de contenido, como { key, name, path }
partLa 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
takesEn path.add: los valores de las otras partes por los que la gente busca junto con este
redirectToEn 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 declaraste export con articles y adopt: true. Una respuesta 400 significa "no compatible". Con paths: ["/answers/x"] y limit: 1 vuelve 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ía since, la hora de la última extracción.
  • what: "profile" se ejecuta en cada escaneo. Responde { "profile": { types, languages, rules, categories, format } }, todos opcionales: types como [{ key, name, path }], y rules con brandTerms, synonyms, noise, onTopic y offTopic.

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.

Pon tu contenido SEO en piloto automático

Conecta Search Console y tu sitio. AutoPSEO lo analiza y planifica los primeros temas al instante. No necesitas tarjeta de crédito.