Документація
Webhook доставки: отримання статей на вашому сайті
Підписаний webhook, який надсилає статті AutoPSEO на ваш сайт: заголовки, кожна подія та її payload, відповіді, які слід повертати, повторні спроби, команди в очікуванні та IndexNow.
AutoPSEO надсилає кожну статтю, яку публікує, оновлює або знімає з публікації, на ваш сайт як підписаний HTTPS POST-запит. Ваш сайт зберігає статтю й відображає її власними шаблонами, тож контент залишається у вашій системі й продовжує працювати, навіть якщо ви колись перестанете користуватися AutoPSEO. Ця сторінка є довідником для розробника, який створює приймач: підпис, усі події з їхніми 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" на сторінці зі списком. Її copy має вигляд { 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 і головний заголовок |
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 KB, бо більша відповідь вважається невдалою.
| Поле | Дія |
|---|---|
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 також розміщує статті, розміщені сторінки надсилаються з власним ключем 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 MB. Будь-яка відповідь, окрім 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, визначають написання та перевірка і супровід; увесь процес описано в огляді рушія контенту.