Перейти к содержимому
Документация

Webhook доставки: приём статей на вашем сайте

Подписанный webhook, который отправляет статьи AutoPSEO на ваш сайт: заголовки, каждое событие и payload, ожидаемые ответы, повторные попытки, ожидающие команды и IndexNow.

Ресурс → Контент → Где публикуются статьи19 мин чтения

AutoPSEO отправляет каждую статью, которую публикует, обновляет или снимает с публикации, на ваш собственный сайт в виде подписанного HTTPS POST-запроса. Ваш сайт сохраняет статью и выводит её по своим шаблонам, поэтому контент живёт в вашей системе и продолжает работать, даже если вы перестанете пользоваться сервисом. Эта страница — справочник для разработчика, который создаёт приёмник: подпись, все события с их payload, ответы, которые читает AutoPSEO, повторные попытки, ожидающие команды и IndexNow.

Настройка webhook

  1. Откройте страницу Контент нужного ресурса и перейдите в раздел Где публикуются статьи.
  2. Заполните поле Адрес webhook на вашем сайте. Это должен быть URL с https:// на публичном имени хоста: IP-адреса и localhost не принимаются. AutoPSEO не следует за редиректами, поэтому указывайте конечный адрес.
  3. Заполните поле Адрес вашего сайта, например https://example.com. Путь каждого типа контента отсчитывается от него, поэтому статья типа с путём /answers будет находиться по адресу https://example.com/answers/<slug>.
  4. Нажмите Сохранить и отправить тест. AutoPSEO создаст секрет для подписи (whsec_ и 48 шестнадцатеричных символов) и отправит ping на ваш адрес. Приёмник, который проверяет подписи, не пройдёт этот первый ping: у него ещё нет секрета.
  5. Откройте Секрет для подписи кнопкой Показать или Копировать, сохраните его на своём сайте как секрет и нажмите Отправить тест.
  6. Посмотрите раздел Что умеет ваш сайт: рядом с каждой командой будет указано Подключено, Не подключено, Заявлено, не проверено или Не проверено.

Новые ресурсы начинают с того, что статьи размещает сам AutoPSEO на своём адресе (см. Хостинг); хостинг и webhook могут работать одновременно. Когда ваш сайт начнёт показывать статьи, нажмите Прекратить размещение в AutoPSEO: все опубликованные статьи переедут на адрес вашего сайта и будут отправлены на ваш webhook как article.published. Для этой кнопки нужен webhook, который ответил на последний тест, и адрес вашего сайта. Пока размещение отключено, webhook удалить нельзя.

Новый секрет сразу заменяет секрет для подписи, и старый перестаёт проходить проверку мгновенно. Добавьте новый секрет на свой сайт сразу после создания.

AutoPSEO ставит доставки в очередь только для webhook, на последний ping которого пришёл ответ 2xx. Неудачный ping (при сохранении, по кнопке Отправить тест или в начале сканирования сайта) переводит webhook в состояние ошибки, и статьи, опубликованные в это время, для него в очередь не попадают. Следите, чтобы ping всегда получал ответ, и после устранения сбоя нажимайте Отправить тест.

Заголовки и подпись

Каждый запрос — это POST с JSON-телом и следующими заголовками:

ЗаголовокЗначение
Content-Typeapplication/json
User-AgentAutoPSEO-Webhook/1
X-AutoPSEO-EventНазвание события, оно же передаётся в теле как event
X-AutoPSEO-DeliveryИдентификатор доставки, он же передаётся в теле как id. Одинаков при всех повторных попытках одной доставки
X-AutoPSEO-TimestampUnix-время в секундах, когда была подписана эта попытка
X-AutoPSEO-Signaturesha256= и далее шестнадцатеричный HMAC-SHA256 от строки <timestamp>.<raw body>

Чтобы проверить запрос:

  1. Возьмите значение заголовка с временной меткой, точку и необработанное тело запроса ровно в том виде, в каком оно пришло, до любого разбора JSON.
  2. Вычислите HMAC-SHA256 от этой строки. Ключ — весь секрет для подписи в том виде, как его показывает приложение, вместе с префиксом whsec_, в виде байтов UTF-8.
  3. Преобразуйте результат в шестнадцатеричную строку и сравните её с частью X-AutoPSEO-Signature после sha256= за постоянное время. Если значения различаются, отклоните запрос.

Каждая попытка подписывается заново со своей временной меткой, поэтому вы можете отклонять запросы, у которых метка сильно расходится с вашими часами (обычно допускают пять минут), и это не помешает повторным попыткам. Сам HMAC описан в RFC 2104.

В каждом теле есть id, event, occurredAt (ISO 8601, UTC) и site: { propertyId, domain }.

События

СобытиеТипКогда AutoPSEO его отправляет
pingПроверкаПри сохранении webhook, по кнопке Отправить тест и в начале каждого сканирования сайта
testПроверкаСразу после успешного ping, по одному на каждую команду
article.publishedДоставкаСтатья впервые опубликована; все опубликованные статьи при отключении размещения
article.updatedДоставкаОпубликована новая ревизия (обновление, оптимизация, откат, повторная публикация после снятия, добавление языков) или добавлена обложка
article.unpublishedДоставкаСтатья снята с публикации или объединена с другой страницей
path.addДоставкаУтверждено новое значение типизированного типа контента
path.removeДоставкаЗапрошено снятие пути значения
path.checkВызовПеред тем как AutoPSEO запланирует страницу по адресу, где на вашем сайте страницы нет, или предложит новое значение
site.exportВызовВо время сканирования сайта, при ежедневной синхронизации тем и перед тем, как AutoPSEO изменит опубликованную статью
articles.adoptedВызовПосле того как сканирование импортировало статьи, экспортированные вашим сайтом
site.handoverВызовКогда кто-то передаёт написание типов контента AutoPSEO или забирает обратно

Доставки проходят через outbox: они записываются в той же пакетной транзакции базы данных, что и изменение, отправляются сразу, повторяются при сбоях и могут ждать ваш сайт. Вызовы — это обычные запросы с ответом: AutoPSEO читает ответ сразу. О том, когда отправляются команды путей, рассказано в разделе Типизированные страницы и новые пути.

Payload статьи

article.published и article.updated содержат статью целиком, со всеми языками:

{
  "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
    }
  }
}

Немецкий перевод здесь сокращён; каждый перевод имеет ту же структуру, что и copy.

ПолеЗначение
idИдентификатор статьи в AutoPSEO. Никогда не меняется, поэтому его безопасно использовать как ключ
slugПоследняя часть адреса. На типизированных страницах содержит сегменты, соединённые через /, например google/software-engineer
pathАдрес относительно вашего сайта: путь типа плюс slug. После создания страницы не меняется
type{ key, name, path }. key стабилен; path — место, где находятся статьи этого типа (пустой для корня сайта)
lang, langsОсновной язык и все языки этой ревизии
revНомер ревизии
urlПолный адрес в том виде, в каком его знает AutoPSEO: адрес вашего сайта, когда размещение отключено, или url, который ваш приёмник вернул в последний раз
datePublished, dateModifiedYYYY-MM-DD. Дата публикации остаётся неизменной во всех последующих ревизиях
contentHashОтпечаток ревизии
writerКто написал ревизию: platform (AutoPSEO), mcp (ваш собственный ИИ), user, import (собственный текст вашего сайта) или aggregate (страница-список)
copyСтатья на основном языке
translationsОстальные языки, ключи — коды языков
categoryКатегория статьи на вашем сайте, если ваш формат предусматривает категории
topic{ externalId, keyword }, если статья написана по теме, которую передал ваш сайт, — так вы сможете закрыть эту тему
partsНа типизированных страницах — значение каждой части: { "company": "google", "role": "software-engineer" }
kind"list" для страницы-списка. Её содержимое — { title, h1, items: [{ from, text }] }, где from — путь страницы, из которой взят элемент, а parts называет значение, которое она собирает
coverОбложка, которую AutoPSEO создал для статьи. Скачайте её один раз и храните свою копию; адрес не меняется, потому что имя файла — это SHA-256 изображения
basedOnВ article.updated: версия вашей страницы, на основе которой написана эта ревизия (см. конфликты ниже)
redirectToВ article.unpublished после объединения: куда теперь ведёт старый адрес. Ответьте на него редиректом 301 и ничего нового по нему не публикуйте

article.unpublished содержит те же поля article, кроме copy, translations, category и cover.

Содержимое copy

ПолеЗначение
title, metaDescription, h1Заголовок страницы, meta description и заголовок h1
directAnswerКороткий ответ, который показывается первым
keyTakeawaysСписок строк
sections[{ heading, bodyHtml }]
faq[{ q, a }]
sources[{ title, url, lastVerified? }]
questionНеобязательно: вопрос пользователя его словами, если ваш формат этого требует
categoryНеобязательно: одна из категорий вашего сайта
listsНеобязательно: простые текстовые списки по ключам для типов контента, которым они нужны (примеры вопросов в руководстве по собеседованию)

В статьях, которые пишет AutoPSEO, bodyHtml использует только p, ul, li, strong, em, a и code, а у ссылок сохраняется только href. Текст, перенесённый с вашего сайта, сохраняет ваш HTML. Если ваши шаблоны этого требуют, пропустите его через собственный санитайзер.

Что должен отвечать ваш приёмник

Любой ответ 2xx завершает доставку. AutoPSEO также читает необязательный JSON-объект из тела ответа; он не должен превышать 64 КБ, иначе ответ будет считаться неудачным.

ПолеЭффект
urlАдрес с https://, по которому статья теперь находится. AutoPSEO записывает его как URL статьи
versionВерсия вашей страницы — непрозрачная строка не длиннее 128 символов. Возвращается как basedOn при следующем обновлении
urlsАдреса с https://, которые изменила эта доставка, — для IndexNow (все языки или адреса, которые вы перестали отдавать). [] означает, что ничего не изменилось
cover"wanted", если у вас нет обложки для статьи и доставка её не содержала. AutoPSEO создаст обложку и отправит её в article.updated

Ответы с ошибкой означают разное:

Ваш ответКак его читает AutoPSEOЧто происходит
4xx с { "error": "unknown_event" } или 501Вы пока не обрабатываете эту командуДоставка ждёт, без повторных попыток
409 с { "error": "conflict" }Обновление написано на основе версии, которой у вашей страницы больше нетДоставка завершается неудачей без повторных попыток, а статья перед следующим изменением заново считывается с вашего сайта
4xx, кроме 408 и 429, с { "error": "<code>", "details": [] } в ответ на path.add или path.removeОтказ с вашей причинойЗаписывается; см. Типизированные страницы и новые пути
Всё остальное: нет ответа за 10 секунд, сетевая ошибка, редирект, 408, 429, 5xx или другой 4xxВременный сбойПовтор с нарастающей задержкой

Повторные попытки и устаревшие доставки

Доставка отправляется сразу после сохранения изменения, а расписание с интервалом в пять минут отправляет всё, что подошло по сроку, начиная с самого старого. После временного сбоя AutoPSEO ждёт 1 минуту, затем 5 минут, 15 минут, 1 час, 3 часа, 6 часов и 12 часов между попытками. Восьмая неудачная попытка помечает доставку как неудавшуюся, и страница «Контент» показывает её в красном индикаторе рядом с webhook.

Если существует более новая доставка для той же статьи, более старая, ещё не отправленная, помечается как вытесненная и никогда не отправляется. Каждая доставка содержит статью целиком, поэтому вашему сайту достаточно самой новой. Команды путей для одного и того же значения работают так же.

Ваш приёмник может получить одну доставку дважды, например если он сохранил статью, но ответил после тайм-аута в 10 секунд. Устраняйте дубликаты по X-AutoPSEO-Delivery или делайте каждую запись идемпотентной, сохраняя по article.id. Кроме того, article.updated может прийти для статьи, которую вы удалили после article.unpublished, потому что повторная публикация снятой статьи отправляет обновление: обрабатывайте обновления как «создать или заменить».

Команды, которые ваш сайт пока не обрабатывает

Сайт может реализовывать протокол по одной команде. AutoPSEO хранит состояние каждой команды и узнаёт его тремя способами:

  • Тесты. После каждого успешного ping AutoPSEO отправляет по одному test на команду. Тело имеет вид { id, event: "test", occurredAt, site, test: { event: "<command>", ... } }, где test содержит образец, устроенный как настоящая команда, и относящийся к <type path>/autopseo-connection-test — адресу, которого нет ни на одном сайте. Проверьте его так же, как проверяли бы настоящую команду, ничего не меняйте и ответьте 2xx: { "test": "<command>", "ok": true } — для статуса Подключено, { "test": "<command>", "ok": false, "error": "unknown_event" } — для Не подключено. Ответ с ok: false и другим кодом ошибки, например отказ снять с публикации адрес, которого у вас нет, всё равно считается подключённой командой. Образец лежит в test, а не на верхнем уровне, поэтому приёмник, который ничего не знает о тестах, не найдёт в нём статью.
  • Реальные доставки и вызовы. Ответ 2xx помечает команду как подключённую; unknown_event помечает её как неподключённую.
  • Ответ на ping. Команды, перечисленные в вашем ответе в autopseo.events, показываются как Заявлено, не проверено, пока тест или доставка не скажут больше.

Доставка для неподключённой команды ждёт. Страница «Контент» перечисляет ожидающее, например «Ваш сайт пока не снимает статьи с публикации», и для всего, что вы выполнили вручную, есть кнопка Сделано на сайте; после этого AutoPSEO больше никогда не отправит эту доставку. Когда последующий тест или доставка покажет, что команда подключена, всё, что ждало её, отправляется.

Ответ на ping

Отвечайте на ping кодом 2xx. Сайту, который только принимает статьи, больше ничего не нужно. Чтобы сообщить AutoPSEO, что ещё умеет ваш сайт, добавьте объект 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"
  }
}
ПолеЗначение
version1. Объект игнорируется, если версия не равна 1 или больше
eventsКоманды, которые вы обрабатываете; показываются как заявленные, пока не проверены
coverstrue, если ваши страницы показывают обложку у каждой статьи. Тогда автор AutoPSEO создаёт обложку в процессе написания, и первая доставка уже содержит её, а ваш собственный ИИ получает просьбу предоставить обложку. См. Обложки
indexNow{ key, keyLocation? }: ключ IndexNow, который вы отдаёте (от 8 до 128 букв, цифр и дефисов), и, если файл ключа лежит не по адресу /<key>.txt, его расположение с https://
exportЧто отдаёт site.export: articles, topics, profile
adoptВы принимаете articles.adopted, поэтому полное сканирование может взять под управление ваши существующие статьи
handoverВы принимаете site.handover
typesКлючи типов контента, которые вы принимаете (до 50)
writerКто сейчас пишет ваши новые статьи: site или autopseo

IndexNow для адресов, которые вы отдаёте

Если в ответе на ping вы объявили indexNow, AutoPSEO отправляет ping в IndexNow на api.indexnow.org после принятых вами доставок, а ваш сайт сам ничего не отправляет.

  • Отправляются urls из вашего ответа. Без urls отправляется url из вашего ответа, а если его нет — URL статьи. urls: [] не отправляет ничего.
  • Принимаются только адреса на вашем собственном хосте, как требует протокол IndexNow: хост из keyLocation, а если его нет — хост адреса вашего сайта.
  • За один цикл доставки отправляется один ping на сайт, до 10 000 адресов.
  • Ответы 200 и 202 принимаются. При 429, 5xx или отсутствии ответа адреса сохраняются и отправляются снова через час, с удвоением интервала каждый раз вплоть до суток (дольше, если этого требует Retry-After), вместе с адресами, добавленными последующими доставками. Ответ 403 (недействительный ключ) или 422 (адреса не на этом хосте) записывается, а адреса отбрасываются.
  • Страница «Контент» показывает последний ping: время, число адресов, HTTP-статус и время следующей попытки.
  • Пока AutoPSEO одновременно размещает статьи, размещённые страницы отправляются в IndexNow с собственным ключом AutoPSEO, а ваш ключ не используется.

Инструмент IndexNow в разделе «Индексация» — это отдельный ручной способ отправки; см. IndexNow.

Конфликты: оригинал — это текст вашего сайта

version из вашего ответа (или version статьи в вашем экспорте) — это версия, на которой, по мнению AutoPSEO, находится ваша страница. Каждый article.updated передаёт её как basedOn. Если с тех пор страница на вашем сайте изменилась, например после правки редактора, ответьте 409 с { "error": "conflict" } и ничего не сохраняйте. Доставка завершится неудачей без повторных попыток.

Перед тем как записать изменение опубликованной статьи из очереди, AutoPSEO заново считывает эту статью с вашего сайта через site.export с paths, если ваш сайт обрабатывает site.export. Если ваша версия ушла вперёд, ваш текст становится следующей ревизией статьи (writer import), а следующее за ним обновление передаёт вашу текущую версию как basedOn.

path.add и path.remove

Эти две доставки относятся к одному значению типизированного типа контента, например компании в /interviews/{company}/{role}. Вместо article они содержат объект path:

{
  "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"] }
  }
}
ПолеЗначение
pathСобственный адрес значения на вашем сайте
typeТип контента в виде { key, name, path }
partЧасть адреса, которую заполняет значение
value{ value, name }, а в path.add ещё и aliases, url, sameAs, description и facts, которые собрал автор AutoPSEO. У каждого факта есть text, source, lastVerified и tier со значением official или reported
takesВ path.add: значения других частей, с которыми это значение ищут
redirectToВ path.remove: куда отныне должны вести адреса значения, с редиректом 301

Ответьте 2xx, если вы приняли команду; в ответ на path.add можно добавить url, по которому значение теперь находится, а в ответ на path.remove — urls, которые вы перестали отдавать, они пойдут в IndexNow. Чтобы отказать, ответьте 4xx с { "error": "<code>", "details": ["<why>"] }. На path.add код invalid_value возвращает значение автору вместе с вашей причиной (третий отказ окончательный), а любой другой код отказывает окончательно. На path.remove код not_found считается выполнением, а любой другой код, например 409 in_use, оставляет всё как было. Отметка ожидающей команды пути как Сделано на сайте считается согласием вашего сайта.

Вызовы: запросы, ответ на которые читает AutoPSEO

Вызовы подписываются так же, как доставки. По умолчанию вызов ждёт до 60 секунд, а повторное считывание и проверки путей — до 30 секунд; принимаются ответы размером до 24 МБ. Любой ответ, кроме 2xx, считается неудачей вызова.

site.export

Запрос содержит export: { what, cursor, limit }, а также since (время в формате ISO) для тем или paths для повторного считывания. Ответьте { "items": [], "next": "<cursor>" }; AutoPSEO будет запрашивать снова, подставляя в cursor ваш next, пока он не окажется пустым или null.

  • what: "articles" выполняется при полном сканировании сайта, по 10 на страницу, если вы объявили export со значением articles и adopt: true. Ответ 400 означает «не поддерживается». С paths: ["/answers/x"] и limit: 1 запрос повторно считывает одну статью; отвечайте только этой статьёй.
  • what: "topics" выполняется при каждом сканировании и в ежедневной синхронизации, по 50 на страницу; ежедневная синхронизация передаёт since — время последней загрузки.
  • what: "profile" выполняется при каждом сканировании. Ответьте { "profile": { types, languages, rules, categories, format } }, каждое поле необязательно: types в виде [{ key, name, path }], а rules — с полями brandTerms, synonyms, noise, onTopic и offTopic.

Экспортируемой статье нужны externalId, slug (буквы и цифры любого алфавита и дефисы, не более 200 символов, сегменты соединяются через /), path, оканчивающийся этим slug, и copy как минимум с title, h1 и одним разделом. Необязательные поля: lang (по умолчанию en), type (ключ типа контента), url, keyword, category, datePublished, dateModified, version, translations, owner (идентификатор статьи в AutoPSEO после принятия под управление) и maintenance: { kind: "refresh" or "optimize", reason }, которое ставит в очередь соответствующую задачу. Каждая экспортированная статья становится управляемой статьёй, где ваш текст — её первая ревизия, и ничего не отправляется обратно. Повторный экспорт безопасен, а статья, снятая AutoPSEO с публикации, через экспорт никогда не возвращается.

Элемент темы выглядит так: { keyword, status?, volume?, source?, externalId?, type?, reason? }, где status — queued, planned или rejected.

articles.adopted

После импорта AutoPSEO просит вас разрешить ему управлять новыми статьями: { "articles": [{ "externalId", "articleId", "slug", "path" }] }. Ответьте { "adopted": ["<externalId>"], "refused": [{ "externalId": "<id>", "reason": "<why>" }] }. Статья, которую вы не подтвердите, снова удаляется из AutoPSEO, если только AutoPSEO уже не написал для неё ревизию.

site.handover

{ "handover": { "types": ["answers"], "writer": "autopseo" } } — отправляется, когда кто-то на странице ресурса Ваш сайт нажимает Поручить написание AutoPSEO или Вернуть написание сайту. Ответьте { "ok": true, "writer": "autopseo" }. После передачи AutoPSEO ваш сайт не пишет проверенные им темы сам, а передаёт их AutoPSEO.

path.check

{ "paths": [{ "path": "/interviews/google/data-engineer" }, { "path": "/interviews/jane-street", "new": { "part": "company", "value": "jane-street", "name": "Jane Street" } }] }. Ответьте одним результатом на каждый путь: { "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."] }] }. Отказ сохраняется вместе с вашей причиной, и этот путь больше не запрашивается; путь, который вы пропустили, будет запрошен позже.

Минимальный приёмник

Cloudflare Worker, который проверяет подпись через Web Crypto API, обрабатывает три команды статей и отклоняет остальные как unknown_event. Он хранит статьи в пространстве имён KV с привязкой ARTICLES, а секрет для подписи — в секрете Worker с именем 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);
  },
};

В Node.js проверка выполняется так же, через createHmac и timingSafeEqual, над необработанным телом, полученным вашим фреймворком:

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));
}

Дальше: то, что попадает на webhook, определяют написание и проверка и сопровождение; весь процесс описан в обзоре контент-движка.

Переведите SEO-контент на автопилот

Подключите Search Console и свой сайт. AutoPSEO просканирует его и сразу спланирует первые темы. Банковская карта не нужна.