Вебхуки
Исходящие HTTP-уведомления о событиях пространства: каталог событий, формат конверта, подпись и политика повторов.
Вебхуки — исходящие HTTP-уведомления о событиях рабочего пространства: AnyNote
отправляет POST с JSON-конвертом на ваш HTTPS-адрес при создании и изменении
страниц и комментариев. Подписки настраиваются в Настройки пространства →
Вебхуки (роль OWNER или ADMIN, тариф с пространством разработчика); на одно
пространство — не более 20 подписок.
Полезная нагрузка содержит только идентификаторы и метаданные — никаких
заголовков и содержимого страниц. Актуальное состояние объекта запрашивайте
через REST API по id из события.
Подписка и проверка адреса
При создании подписки вы указываете имя, HTTPS-URL и набор событий. В ответ
выдаётся секрет подписи — строка вида whsec_ + 32 символа base62. Секрет
показывается только один раз; при необходимости его можно сменить
(«Сменить секрет»), после смены старые подписи немедленно перестают действовать.
Прежде чем подписка станет активной, AnyNote проверяет, что адрес принадлежит
вам. На URL отправляется POST с телом:
{
"type": "verification",
"challenge": "строка из 32 символов base62",
"subscriptionId": "uuid подписки"
}
Запрос подписан и снабжён теми же заголовками, что и обычная доставка, но
X-AnyNote-Event: verification, а в X-AnyNote-Delivery передаётся id
подписки. Проверка засчитывается, если ваш сервер ответил статусом 2xx и
строка challenge встречается в первых 4096 символах тела ответа. Редиректы
не выполняются — любой 3xx считается неудачей. Тайм-аут проверки — 10
секунд. Проверка запускается при создании подписки, при смене URL и вручную
кнопкой «Проверить».
Заголовки доставки
Каждая доставка (и проверочный запрос) несёт пять заголовков:
| Заголовок | Значение |
|---|---|
X-AnyNote-Signature | подпись sha256= + 64 hex-символа (см. ниже) |
X-AnyNote-Timestamp | Unix-время подписи в секундах |
X-AnyNote-Event | тип события (page.created, …) или verification |
X-AnyNote-Delivery | uuid доставки (для проверочного запроса — uuid подписки) |
X-AnyNote-Payload-Version | версия конверта, сейчас 1 |
Конверт события (версия 1)
{
"version": 1,
"id": "9f2c1a4e-5b3d-4c7e-8f01-2a3b4c5d6e7f",
"event": "page.created",
"timestamp": "2026-06-11T12:00:00.000Z",
"workspaceId": "11111111-1111-1111-1111-111111111111",
"actor": { "id": "22222222-2222-2222-2222-222222222222" },
"resource": { "type": "page", "id": "55555555-5555-5555-5555-555555555555" },
"hints": {}
}
id— идентификатор события: общий для всех подписок и неизменный при повторных доставках. Дедуплицируйте по нему.actor.id— пользователь, выполнивший действие, илиnull(например, дляpage.content_updatedиз совместного редактирования).resource.type—pageилиcomment. Для событий комментариевresource.id— это id страницы, на которой оставлен комментарий (id самого комментария передаётся вhints).hints— необязательные подсказки, состав зависит от события (см. таблицу).
Каталог событий
| Событие | Когда возникает | hints |
|---|---|---|
page.created | создана страница | {}; при дублировании — { "duplicatedFrom": "<uuid источника>" } |
page.content_updated | сохранено новое содержимое страницы | {} |
page.properties_updated | изменены заголовок, иконка, тип или архивный статус | { "changed": [...] } — массив из title, icon, type, archivedAt |
page.moved | страница перемещена | { "to": "<uuid нового родителя или null>" }; при смене раздела — { "scope": "collection" } |
page.deleted | страница перемещена в корзину | {} |
page.undeleted | страница восстановлена из корзины | {} |
comment.created | новый комментарий на странице | { "threadId": "<uuid>", "commentId": "<uuid>" } |
comment.resolved | обсуждение отмечено как решённое | { "threadId": "<uuid>", "resolved": true } |
Значения-идентификаторы страниц в hints (duplicatedFrom, to) проходят ту
же проверку видимости, что и сам ресурс: если страница-источник или новый
родитель не видны на уровне командных разделов, значение заменяется на null.
Запланированы и появятся скоро (подписаться на них пока нельзя):
| Событие | Статус |
|---|---|
collection.created | скоро |
collection.updated | скоро |
database.row_changed | скоро |
Проверка подписи
Подпись — HMAC-SHA256 от строки {timestamp}.{body}, где timestamp — значение
заголовка X-AnyNote-Timestamp, а body — сырое тело запроса байт в байт.
Результат передаётся как sha256= + 64 hex-символа в X-AnyNote-Signature.
Проверяйте подпись на сыром теле (до разбора JSON), сравнивайте за постоянное время и отклоняйте устаревшие доставки — это защита от повторного воспроизведения:
import { createHmac, timingSafeEqual } from 'node:crypto'
const TOLERANCE_SEC = 300 // 5 минут
/**
* @param {string} secret - секрет подписки (whsec_…)
* @param {string} body - сырое тело запроса
* @param {Record<string, string | undefined>} headers - заголовки запроса
*/
export function verifyAnyNoteSignature(secret, body, headers) {
const timestamp = headers['x-anynote-timestamp']
const signature = headers['x-anynote-signature'] ?? ''
// Отбрасываем доставки со слишком старой (или будущей) меткой времени.
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp))
if (!timestamp || Number.isNaN(age) || age > TOLERANCE_SEC) return false
const expected = `sha256=${createHmac('sha256', secret)
.update(`${timestamp}.${body}`)
.digest('hex')}`
const a = Buffer.from(expected)
const b = Buffer.from(signature)
return a.length === b.length && timingSafeEqual(a, b)
}
Полный обработчик с ответом на проверочный запрос:
import express from 'express'
import { verifyAnyNoteSignature } from './verify.js'
const app = express()
// Подпись считается по сырому телу — не давайте фреймворку разобрать JSON раньше.
app.use(express.raw({ type: 'application/json' }))
app.post('/anynote-webhook', (req, res) => {
const body = req.body.toString('utf8')
if (!verifyAnyNoteSignature(process.env.ANYNOTE_WEBHOOK_SECRET, body, req.headers)) {
return res.status(401).end()
}
const payload = JSON.parse(body)
if (req.headers['x-anynote-event'] === 'verification') {
// Эхо challenge со статусом 2xx — строка должна попасть
// в первые 4096 символов тела ответа.
return res.status(200).send(payload.challenge)
}
// Доставка «минимум один раз»: дедуплицируйте по payload.id.
// Отвечайте 2xx быстро, тяжёлую обработку выполняйте асинхронно.
res.status(200).end()
})
Повторные доставки и автоотключение
- Успехом считается любой ответ 2xx. Тайм-аут запроса — 10 секунд (по умолчанию).
- Неудачная доставка повторяется с экспоненциальной задержкой: 60 с, 120 с, 240 с и далее удвоение, но не более 30 мин между попытками. По умолчанию — до 8 попыток, после чего доставка фиксируется как неуспешная.
- После 10 неуспешных доставок подряд подписка автоматически отключается (статус «Ошибки»). Возобновить её можно в настройках кнопками «Проверить» (повторная проверка адреса) или «Возобновить». Успешная доставка обнуляет счётчик ошибок.
- Журнал доставок (статус, код ответа, фрагмент тела, задержка, ошибки) доступен в настройках подписки.
Порядок и дедупликация
Доставка — «минимум один раз»: возможны повторы, порядок событий не
гарантируется. Стройте обработчик идемпотентно и дедуплицируйте по полю id
конверта — оно одинаково для всех повторных доставок одного события.
Безопасность
- Принимаются только
https://-адреса; URL с учётными данными отклоняются. - Адреса, разрешающиеся в приватные, loopback, link-local и облачные metadata-диапазоны, блокируются — и при создании подписки, и при каждой отправке (адрес разрешается заново).
- Редиректы не выполняются: ответ 3xx считается неудачной доставкой.
- В событиях нет пользовательского контента — только идентификаторы и метаданные. Состояние объектов запрашивайте через REST API.
- Секрет подписи (
whsec_+ 32 base62) показывается один раз и хранится в зашифрованном виде; подпись обязательна на каждой доставке. - Не более 20 подписок на пространство.
- События отправляются только для командных разделов: страницы личных разделов и строки баз данных не покидают пространство; видимость перепроверяется непосредственно перед каждой отправкой.