Документация
Webhook доставки: приём статей на вашем сайте
Подписанный webhook, который отправляет статьи AutoPSEO на ваш сайт: заголовки, каждое событие и payload, ожидаемые ответы, повторные попытки, ожидающие команды и IndexNow.
AutoPSEO отправляет каждую статью, которую публикует, обновляет или снимает с публикации, на ваш собственный сайт в виде подписанного HTTPS POST-запроса. Ваш сайт сохраняет статью и выводит её по своим шаблонам, поэтому контент живёт в вашей системе и продолжает работать, даже если вы перестанете пользоваться сервисом. Эта страница — справочник для разработчика, который создаёт приёмник: подпись, все события с их payload, ответы, которые читает AutoPSEO, повторные попытки, ожидающие команды и IndexNow.
Настройка webhook
- Откройте страницу Контент нужного ресурса и перейдите в раздел Где публикуются статьи.
- Заполните поле Адрес webhook на вашем сайте. Это должен быть URL с
https://на публичном имени хоста: IP-адреса иlocalhostне принимаются. AutoPSEO не следует за редиректами, поэтому указывайте конечный адрес. - Заполните поле Адрес вашего сайта, например
https://example.com. Путь каждого типа контента отсчитывается от него, поэтому статья типа с путём/answersбудет находиться по адресуhttps://example.com/answers/<slug>. - Нажмите Сохранить и отправить тест. AutoPSEO создаст секрет для подписи (
whsec_и 48 шестнадцатеричных символов) и отправит ping на ваш адрес. Приёмник, который проверяет подписи, не пройдёт этот первый ping: у него ещё нет секрета. - Откройте Секрет для подписи кнопкой Показать или Копировать, сохраните его на своём сайте как секрет и нажмите Отправить тест.
- Посмотрите раздел Что умеет ваш сайт: рядом с каждой командой будет указано Подключено, Не подключено, Заявлено, не проверено или Не проверено.
Новые ресурсы начинают с того, что статьи размещает сам AutoPSEO на своём адресе (см. Хостинг); хостинг и webhook могут работать одновременно. Когда ваш сайт начнёт показывать статьи, нажмите Прекратить размещение в AutoPSEO: все опубликованные статьи переедут на адрес вашего сайта и будут отправлены на ваш webhook как article.published. Для этой кнопки нужен webhook, который ответил на последний тест, и адрес вашего сайта. Пока размещение отключено, webhook удалить нельзя.
Новый секрет сразу заменяет секрет для подписи, и старый перестаёт проходить проверку мгновенно. Добавьте новый секрет на свой сайт сразу после создания.
AutoPSEO ставит доставки в очередь только для webhook, на последний ping которого пришёл ответ 2xx. Неудачный ping (при сохранении, по кнопке Отправить тест или в начале сканирования сайта) переводит webhook в состояние ошибки, и статьи, опубликованные в это время, для него в очередь не попадают. Следите, чтобы ping всегда получал ответ, и после устранения сбоя нажимайте Отправить тест.
Заголовки и подпись
Каждый запрос — это POST с JSON-телом и следующими заголовками:
| Заголовок | Значение |
|---|---|
Content-Type | application/json |
User-Agent | AutoPSEO-Webhook/1 |
X-AutoPSEO-Event | Название события, оно же передаётся в теле как event |
X-AutoPSEO-Delivery | Идентификатор доставки, он же передаётся в теле как id. Одинаков при всех повторных попытках одной доставки |
X-AutoPSEO-Timestamp | Unix-время в секундах, когда была подписана эта попытка |
X-AutoPSEO-Signature | sha256= и далее шестнадцатеричный HMAC-SHA256 от строки <timestamp>.<raw body> |
Чтобы проверить запрос:
- Возьмите значение заголовка с временной меткой, точку и необработанное тело запроса ровно в том виде, в каком оно пришло, до любого разбора JSON.
- Вычислите HMAC-SHA256 от этой строки. Ключ — весь секрет для подписи в том виде, как его показывает приложение, вместе с префиксом
whsec_, в виде байтов UTF-8. - Преобразуйте результат в шестнадцатеричную строку и сравните её с частью
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, dateModified | YYYY-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"
}
}
| Поле | Значение |
|---|---|
version | 1. Объект игнорируется, если версия не равна 1 или больше |
events | Команды, которые вы обрабатываете; показываются как заявленные, пока не проверены |
covers | true, если ваши страницы показывают обложку у каждой статьи. Тогда автор 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, определяют написание и проверка и сопровождение; весь процесс описан в обзоре контент-движка.