Skip to content

ТЗ — Исходящие вебхуки (cms/webhooks-out)

Слой: 🔵 инфра-модуль · Зрелость доноров: ★ · Донор: — (spatie/webhook-server) Статус: ТЗ к разработке

Назначение и возможности

Подписка внешних систем на события ядра и модулей: отправка POST-запросов наружу с HMAC-подписью, контроль доставки, ретраи и автоматическое отключение мёртвых endpoint'ов.

  • Регистрация подписок: событие → endpoint URL, из Filament
  • Подпись тела запроса HMAC (секрет per-подписка)
  • Ретраи с backoff при недоступности endpoint'а
  • Журнал доставки (статус, код ответа, время, попытка)
  • Автоматическое отключение подписки после серии неудачных доставок подряд
  • Ручная повторная отправка конкретного события из журнала
  • Тестовая отправка (ping) при создании подписки

Зависимости и выключение

requires: ядро suggests: cms/health (метрики и алерты об отставании очереди/автоотключённых подписках), cms/audit (журналирование ручных операций — resend, создание/удаление подписки), cms/notifications-bus (уведомление админа об автоотключении подписки через NotificationDispatch; отсутствует — log-fallback ядра)

Поведение при выключении: подписки не удаляются, но рассылка событий приостанавливается — внешние системы перестают получать уведомления до включения модуля обратно, данные студии не затрагиваются.

Модель данных

ТаблицаКлючевые поляПримечание
cms_webhooks_out_subscriptionsid, event, target_url, secret, previous_secret, previous_secret_expires_at, payload_mode, origin_code, is_active, failure_count, circuit_state, circuit_opened_at, last_success_at, avg_response_msподписки на события
cms_webhooks_out_deliveriesid, subscription_id, payload (json), status_code, attempt, response_time_ms, delivered_atжурнал доставки

Заметки: payload — JSONB с касом 'array'; FK cms_webhooks_out_deliveries.subscription_idconstrained() + index(); индекс по (event, is_active) под быструю выборку активных получателей события; cms_webhooks_out_deliveries — журнальная таблица, BRIN по delivered_at. secret/previous_secret — Laravel encrypted-каст (не plain-text, см. «Крайние случаи»). payload_mode — enum thin/full, дефолт thin. origin_code — nullable-код назначения подписки, для анти-эхо. circuit_state — enum closed/open/half_open, circuit_opened_at — момент перехода в open. last_success_at, avg_response_ms — агрегаты health-report по подписчику.

Входные и выходные данные

Входы

ИсточникДанные/поляЧем валидируется
Filament: форма подписки (создание/редактирование)event (тип из реестра зарегистрированных событий), target_url, secret, payload_mode (thin/full), origin_code (опционально), is_activeFormRequest: event — whitelist типов, target_urlurl + whitelist схемы (только https), secret — минимальная длина, payload_mode — whitelist thin/full, лимит webhooks-out.max_subscriptions — при создании
API POST /api/v1/admin/webhooks-out/subscriptionsте же поля, что формаFormRequest, право webhooks-out.manage
API POST /api/v1/admin/webhooks-out/deliveries/{id}/resendid доставки (route model binding)существование записи + принадлежность активной подписке, право webhooks-out.manage
Слушаемое событие ядра/модуля (динамическая подписка)typed payload события, зарегистрированного в реестре событий ядраpayload уже валиден на стороне издателя; сериализатор модуля берёт только whitelist полей события, не toArray() целиком (см. крайние случаи)
Filament: кнопка «Тестовое событие» (ping)subscription_idсуществование подписки, право webhooks-out.manage

Whitelist-принцип: подписка на незарегистрированный тип события отклоняется формой/FormRequest; событие без активной подписки на его тип просто не попадает в очередь модуля — расширять список типов «на лету» изнутри модуля запрещено (типы объявляет издатель события).

Выходы

ПотребительДанныеФормат
Внешний endpoint подписчикаevent_id, event, occurred_at, origin (источник изменения, если издатель его заполнил); thin — только id+event+origin, full — whitelist-payload события целикомJSON, POST, заголовки X-Webhook-Signature (HMAC-SHA256, по актуальному secret либо previous_secret в окне перекрытия), X-Webhook-Event-Id
Filament admin UIжурнал доставок (статус, код ответа, попытка, время, усечённое тело ответа), health-report по подписчикутаблица с фильтром по статусу/подписке/периоду
cms/healthчисло активных/автоотключённых подписок, отставание очереди webhooks-out, агрегат circuit-breaker состоянийhealth-check payload (агрегируется в /api/v1/system/health)
EventBus (внутренний)WebhookDeliveryFailed, WebhookSubscriptionDisabledтипизированные события — для cms/audit/cms/notifications-bus, если включены

Анти-эхо (origin). Не доставлять подписке событие, у которого origin совпадает с origin_code этой подписки — защита от цикла обратной синхронизации (например через cms/integrations-bus); правило опционально — работает только когда origin заполнен издателем, для обычных правок через админку origin пуст и правило не применяется.

Режим thin/full. thin (дефолт) — тело содержит только id+event+origin, подписчик дозапрашивает данные через публичный API издателя (меньше утечки, не завязано на совместимость payload); full — whitelist-payload целиком, осознанный выбор (см. «UX-требования»). Исключение: события удаления (см. «События и обмен») всегда thin, независимо от payload_mode.

Настройки (группа webhooks-out)

КлючТипДефолтaffectsPageCacheОписание
webhooks-out.enabledbooltrueнетВключить рассылку исходящих вебхуков
webhooks-out.retry_attemptsint3нетЧисло попыток доставки
webhooks-out.retry_backoff_secondsint10нетБазовая задержка между попытками
webhooks-out.auto_disable_thresholdint10нетЧисло подряд неудач до автоотключения подписки
webhooks-out.delivery_log_retention_daysint30нетХранение журнала доставки
webhooks-out.request_timeout_secondsint5нетТаймаут HTTP-запроса к endpoint'у подписчика
webhooks-out.max_payload_kbint256нетЛимит размера тела исходящего запроса, при превышении — обрезка/ссылка вместо вложенных данных
webhooks-out.allowed_url_schemesarray["https"]нетWhitelist схем target_url — заведомо запрещает http:// и нестандартные схемы
webhooks-out.secret_rotation_grace_minutesint0нетСколько минут после смены секрета уже поставленные в очередь доставки ещё подписываются старым секретом (0 — сразу новым, см. крайние случаи)
webhooks-out.secret_overlap_max_hoursint24нетМаксимальное окно перекрытия secret/previous_secret, которое администратор может задать при явной ротации секрета
webhooks-out.default_payload_modeenum(thin|full)thinнетРежим payload по умолчанию для новых подписок
webhooks-out.max_subscriptionsint50нетЛимит активных подписок на сайт

webhooks-out.enabled — глобальный kill-switch: false мгновенно останавливает рассылку без выключения модуля целиком. Отличие от circuit breaker/auto_disable_threshold (per-подписка, постепенно): kill-switch — сразу и по всем подпискам, ручным решением администратора.

API

МетодПутьДоступНазначение
GET/api/v1/admin/webhooks-out/subscriptionsadmin (webhooks-out.view)Список подписок
POST/api/v1/admin/webhooks-out/subscriptionsadmin (webhooks-out.manage)Создание подписки
POST/api/v1/admin/webhooks-out/deliveries/{id}/resendadmin (webhooks-out.manage)Повторная отправка события
POST/api/v1/admin/webhooks-out/subscriptions/{id}/rotate-secretadmin (webhooks-out.manage)Ротация секрета: текущий secretprevious_secret с previous_secret_expires_at (≤ webhooks-out.secret_overlap_max_hours), генерируется новый secret

Список подписок и журнал доставки — keyset-пагинация (не OFFSET).

Компоненты

Filament: страница подписок и журнала доставки, кнопка «Отправить тестовое событие»; колонка/ деталь health-report по подписчику (last_success_at, failure_count, avg_response_ms, circuit_state); действие «Ротация секрета» с выбором окна перекрытия. Команды: cms:webhooks-out:resend --json, cms:webhooks-out:status --json. Демо-сидеры: не применимо — модуль не имеет блоков/виджетов контента для галереи.

Ранбук (симптом → команда).

СимптомДействие
Подписка массово получает failedcms:webhooks-out:status --json → health-report подписчика (латенси, failure_count)
Очередь webhooks-out отстаётПроверить нагрузку воркеров очереди против «Ожидаемых объёмов» (см. «Производительность и кеш»)
Подписка автоотключена (is_active=false)Журнал причины → cms:webhooks-out:status --json; ручное включение сбрасывает failure_count
Circuit breaker подписки в openДождаться cooldown (автопереход в half-open) либо проверить endpoint вручную кнопкой «Тестовое событие»

События и обмен

СобытиеКогдаPayload
WebhookDeliveryFailedпопытка доставки завершилась ошибкойsubscription_id, event, status_code
WebhookSubscriptionDisabledподписка автоотключена после серии неудачsubscription_id, failure_count

Слушает: все типизированные события ядра и модулей, на которые оформлена подписка (динамически, без хардкода списка). FilterBus и provides-контракты не используются — модуль только ретранслирует уже опубликованные события во внешние endpoint'ы.

Таблица взаимодействий

Сущность/модульКаналНаправлениеЧто происходит
Ядро: PagePublished, LeadCreatedсобытиеinтиповой кейс подписки — «уведомить CRM о новой заявке», «оповестить о публикации»
Ядро: ContentEntrySaved, MediaUploaded, SettingChanged, MenuSaved, WidgetSaved/WidgetDeleted, ThemeChanged, UserRegisteredсобытиеinретранслируются наружу только при наличии активной подписки на конкретный тип
Ядро: PageDeleted, ContentEntryDeleted, MediaDeleted, UserDeletedсобытиеinподписка оформляется наравне с остальными событиями (см. ревизия ядра 14.07.2026, п.3); payload — тонкий по определению ревизии (id + slug/путь удалённого) и всегда доставляется как thin независимо от payload_mode подписки — явное исключение из режима payload
Ядро: ModuleEnabled/ModuleDisabledсобытиеinмодуль отслеживает доступность своих suggests-зависимостей (cms/health, cms/audit, cms/notifications-bus)
Любой модуль с типизированным событием (например commerce-orders: OrderPaid)событиеinподписка оформляется на любой зарегистрированный тип, без хардкода конкретных модулей-издателей
cms/health (suggests)сервис-вызов (health-чек из манифеста)outмодуль отдаёт метрики (активные/автоотключённые подписки, отставание очереди) в общий агрегат /api/v1/system/health
cms/audit (suggests)событиеoutWebhookSubscriptionDisabled, ручной resend — журналируются в аудит, если модуль включён
cms/notifications-bus (suggests, provides: notification-channel)сервис-вызов (NotificationDispatch)outуведомление админа об автоотключении подписки; модуль выключен — log-fallback ядра, доставка вебхуков не блокируется

Фоновая работа

Именованная очередь webhooks-out для каждой доставки: подписанный POST-запрос, ретраи с backoff, учёт failure_count для автоотключения — всё в очереди, никогда синхронно в обработчике события ядра. Ручная повторная отправка и тестовый ping также ставятся в очередь. Перед фактическим HTTP-вызовом джоба проверяет circuit_state подписки: open — доставка сразу помечается skipped без реального запроса, half_open — ровно одна пробная доставка (успех → closed, провал → снова open с новым circuit_opened_at).

Производительность и кеш

Ожидаемые объёмы. Типичный сайт: 1–5 активных подписок, до ~50 исходящих доставок в час (публикации, заявки). Крупный сайт с десятками подписчиков на разные типы событий (CRM, 1С, партнёрские интеграции): до нескольких тысяч доставок в час — основной буфер нагрузки — очередь webhooks-out, не синхронный обработчик события.

Горячие пути и бюджет запросов. Диспетчеризация события: 1 запрос — выборка активных подписок по (event, is_active); постановка джобы на доставку — без доп. запросов (payload уже в памяти листенера). Доставка (в очереди): 1 запрос на создание записи в cms_webhooks_out_deliveries + HTTP-вызов вне БД, с таймаутом (webhooks-out.request_timeout_seconds). Список подписок и журнал в Filament/API — keyset-пагинация, без COUNT(*) на больших журналах.

Критичные индексы.

  • cms_webhooks_out_subscriptions (event, is_active) — выборка получателей события на каждой диспетчеризации;
  • cms_webhooks_out_deliveries (subscription_id) — FK, история доставок конкретной подписки;
  • cms_webhooks_out_deliveries (delivered_at) — BRIN, чистка по delivery_log_retention_days и хронологический журнал;
  • cms_webhooks_out_deliveries (status_code) — фильтр «только failed» в админке и выборка для массового resend.

Кеш. Собственных тегов CacheTags не объявляет — список активных подписок читается напрямую из БД при диспетчеризации события: частота публикации событий и небольшой объём подписок (десятки, не тысячи строк) не оправдывают кеш-слой, а устаревший кеш подписок рискует пропустить доставку только что добавленному получателю. Настройки группы webhooks-out — как у любого модуля, читаются из кеша группы settings-store ядра (0 запросов на горячем пути диспетчеризации).

Health-report по подписчикам. Health-чек агрегирует не только общее число активных/автоотключённых подписок, но и per-подписчик отчёт (last_success_at, failure_count, avg_response_ms) — доступен в Filament (см. «Компоненты»), не только в общем счётчике /api/v1/system/health; основа для выявления «полу-живых» подписчиков.

Circuit breaker экономит бюджет запросов. В open доставка не делает реального HTTP-вызова (механика — см. «Фоновая работа») — попытки на явно недоступный endpoint не расходуются впустую; отдельная лёгкая ступень до auto_disable_threshold.

Безопасность

Границы входа: секрет подписи (secret) хранится в БД зашифрованным Laravel encrypted-кастом (на APP_KEY), не открытым текстом — шифрованные данные записи, а не «секрет в settings-store» (§11 запрещает именно последнее); тело подписывается HMAC на основе секрета per-подписка (текущего secret, либо previous_secret в окне перекрытия). Создание/изменение подписок — только под webhooks-out.manage, URL endpoint'а — ресурс за пределами студии, к нему не применяются ограничения rich-text/файлов.

ПДн-паспорт. Payload доставки может содержать ПДн получателя факта (email лида, данные пользователя) — уходит на внешний endpoint вне контроля студии; создание такой подписки — ответственность администратора (доверенность получателя и согласие на передачу — вне контроля модуля). cms_webhooks_out_deliveries хранит данные delivery_log_retention_days (30 дней) — участвует в «забыть по запросу», как и другие журнальные модули (хук анонимизации по предикату).

Конкретные векторы:

  • SSRF через target_url. Админ (или скомпрометированный аккаунт с webhooks-out.manage) может указать внутренний адрес (127.0.0.1, RFC1918, 169.254.169.254 — метадата облака). target_url валидируется whitelist-ом схем (webhooks-out.allowed_url_schemes, только https) и запретом приватных/loopback/link-local диапазонов — при создании подписки и при резолве DNS перед каждой фактической отправкой (защита от DNS rebinding);
  • утечка ПДн/секретов в payload. Сериализатор события — явный whitelist полей, не toArray() модели целиком: внутренние токены, пароли, полные ПДн сверх необходимого не должны попасть в тело исходящего запроса (см. крайние случаи);
  • подмена получателя. Изменение target_url существующей подписки — то же право webhooks-out.manage, что и создание; смена адреса не требует повторного ping/подтверждения владения endpoint'ом — риск переотправки чужих данных на новый адрес; рекомендуется запись изменения target_url в cms/audit (suggests) и повторный обязательный ping перед включением подписки с новым адресом.

Матрица ролей:

Рольwebhooks-out.viewwebhooks-out.manage
Посетитель
Редакторнетнет
Менеджерда — просмотр журнала доставки и списка подписокнет
Studio/админдада — создание/изменение подписок, target_url, ротация секретов; риск SSRF/утечки оправдывает повышенную роль

UX-требования

Для админа:

  • пустое состояние списка подписок — «Подписок пока нет» + кнопка «Добавить подписку» с подсказкой о доступных типах событий;
  • журнал доставок — фильтр по статусу (success/failed/pending), по подписке, по периоду; список без подписок вообще — отдельное пустое состояние, не пустая таблица без объяснения;
  • массовое действие в журнале: «Повторить отправку» для выбранных failed-записей пачкой (не по одной), с прогрессом и итоговым «отправлено N из M»;
  • человеческие ошибки вместо кода/stacktrace: «Получатель не отвечает 5 раз подряд — подписка отключена» (а не «HTTP 000»); при создании — «URL должен быть доступен по HTTPS и не указывать на внутренний адрес»;
  • подтверждение необратимых операций: удаление подписки — предупреждение «связанные записи журнала (N шт.) тоже будут удалены» (либо подписка помечается неактивной, а не удаляется физически, — журнал сохраняется для аудита, см. крайние случаи);
  • кнопка «Тестовое событие» (ping) сразу показывает результат (код ответа, время отклика) без ухода со страницы формы; на подписке в open/half-open — тот же способ вручную приблизить восстановление, не дожидаясь cooldown;
  • выбор payload_mode: thin — дефолт без предупреждений, full — требует явного подтверждения («full-режим требует поддерживать совместимость payload при апгрейдах модуля-издателя»); массовое «Повторить отправку» на большом числе записей — отдельное подтверждение «шторм доставок на чужой endpoint может создать нагрузку на его стороне».

Для посетителя: у модуля нет публичного UI (только Filament/admin API) — раздел «поведение форм/скорость отклика» не применим к фронтенду сайта.

Крайние случаи и типовые баги

  • Получатель недоступен или отвечает медленно → HTTP-запрос обрывается по таймауту (webhooks-out.request_timeout_seconds), доставка повторяется webhooks-out.retry_attempts раз с backoff от webhooks-out.retry_backoff_seconds; после исчерпания попыток запись помечается failed, failure_count увеличивается атомарно (инкремент в БД, не read-modify-write — иначе гонка при параллельных доставках теряет часть инкрементов).
  • Серия провалов подряд достигает auto_disable_threshold → подписка переводится в is_active=false, издаётся WebhookSubscriptionDisabledNotificationDispatch (log-fallback без cms/notifications-bus); ручное включение сбрасывает failure_count.
  • Подлинность запроса у получателя → тело подписывается HMAC-SHA256 на секрете подписки, подпись — в X-Webhook-Signature; схема проверки документируется получателю (hash_hmac('sha256', $rawBody, $secret), сравнение constant-time).
  • Хранение секрета подписки → секрет хранится в БД зашифрованным штатным Laravel encrypted-кастом (на APP_KEY, не собственный секрет-менеджмент) — это шифрованные данные записи, а не «секрет в settings-store» (§11 запрещает именно последнее); поле называется secret, создание/редактирование подписки работает без релиза и правки .env.
  • Payload может содержать внутренние данные → сериализатор payload — explicit whitelist полей события, не toArray() модели целиком; токены/пароли/ПДн сверх необходимого исключаются на уровне сериализатора (контрактный тест на whitelist-сериализацию обязателен).
  • Дубль доставки (at-least-once) → ретрай после таймаута может привести к повторной доставке (первый запрос дошёл, но ответ потерялся до записи в журнал); в payload — уникальный event_id, получателю документируется идемпотентная обработка по нему (аналогично cms/webhooks-in на входящей стороне).
  • Выключение модуля при непустой очереди недоставленных → задания, поставленные до выключения, не удаляются; воркер обязан проверять статус модуля перед фактической отправкой — при disabled джоба возвращается (release) без выполнения, иначе нарушается правило деградации (§3 стандарта); отставание видно в health-чеке.
  • Suggests-модуль выключен (cms/notifications-bus, cms/audit, cms/health) → доставка вебхуков и автоотключение подписок работают штатно без них; уведомление об автоотключении — log-fallback ядра (cms:webhooks-out:status --json), аудит-запись не создаётся, метрики не попадают в общий агрегат здоровья — деградация, не сбой.
  • Огромный payload события → сериализатор ограничивает размер тела (webhooks-out.max_payload_kb); при превышении коллекции обрезаются (truncated: true) или вместо содержимого передаётся id — получатель дозапрашивает данные через API издателя.
  • Смена секрета подписки при неотправленных вебхуках в очереди → подпись вычисляется в момент фактической отправки джобой: используется секрет, актуальный на момент отправки (новый); при secret_rotation_grace_minutes > 0 джобы, поставленные до смены, ещё подписываются старым секретом в течение grace-периода. Отдельно — ротация с явным перекрытием: «Ротация секрета» переносит secret в previous_secret с previous_secret_expires_at (≤ webhooks-out.secret_overlap_max_hours); оба ключа валидны для проверки подписи одновременно, пока получатель не обновит конфиг; по истечении окна previous_secret обнуляется. Grace по постановке job и явное окно перекрытия — разные механизмы одной проблемы, применимы одновременно.
  • Анти-эхо (origin) → подписка с origin_code, совпадающим с origin события (обратная синхронизация, например из cms/integrations-bus), не получает доставку — разрывает цикл «сайт → вебхук → CRM → обратно на сайт → …»; при пустом origin (правка через админку) правило не действует, доставка идёт как обычно.
  • Circuit breaker closed → open → half-open → closed → серия сбоев подряд (порог ниже auto_disable_threshold) переводит подписку в open (механика — см. «Фоновая работа»); не отменяет auto_disable_threshold — при затяжном системном сбое финальное автоотключение всё равно срабатывает.
  • Лимит подписок на сайт → создание подписки сверх webhooks-out.max_subscriptions отклоняется понятной ошибкой «достигнут лимит активных подписок, обратитесь к studio для повышения лимита» — защита от бесконтрольного роста числа исходящих интеграций без явного решения студии повысить лимит.
  • Стоимость внешних вызовов → не применимо для самой студии (endpoint — инфраструктура подписчика, не платный сервис на нашей стороне); риск — массовый resend/шторм доставок может создать нагрузку/расход на стороне подписчика, мера — порог подтверждения при массовом действии в UX (см. «UX-требования»).
  • Двойной сабмит создания подписки (двойной клик по «Сохранить») → уникальность по паре (event, target_url) на уровне БД/валидации; повторный запрос отклоняется ошибкой «подписка на это событие для этого URL уже существует», вторая запись не создаётся.

Донорский код

Донор: — (используется пакет spatie/webhook-server, собственного кода-донора нет)

Legacy-импорт: не применим — нет доменных данных для переноса с предыдущей платформы, подписки создаются заново администратором.

Тесты и приёмка

  • [ ] Контрактный тест: тело исходящего запроса подписано корректным HMAC
  • [ ] Health-чек модуля отражает число активных/автоотключённых подписок
  • [ ] При выключении модуля рассылка приостанавливается без потери подписок
  • [ ] Подписка автоматически отключается после webhooks-out.auto_disable_threshold неудач подряд
  • [ ] Права webhooks-out.view/webhooks-out.manage разграничивают просмотр и управление подписками
  • [ ] Ручная повторная отправка создаёт новую запись доставки, не дублирует старую
  • [ ] Таймаут endpoint'а приводит к ретраю с backoff по retry_attempts/retry_backoff_seconds, не к зависанию джобы
  • [ ] event_id в payload уникален на попытку и стабилен при повторной доставке — получатель может дедуплицировать at-least-once доставку
  • [ ] Сериализатор payload отдаёт только whitelist-поля события — тест на утечку лишнего поля (токен/пароль/ПДн) падает
  • [ ] target_url с приватным/loopback/metadata-адресом или схемой не из allowed_url_schemes отклоняется при создании подписки (SSRF-тест)
  • [ ] Смена секрета подписки не рвёт уже поставленные в очередь доставки (grace-период учтён)
  • [ ] Массовый resend failed-доставок из журнала обрабатывает пачку, не создаёт N синхронных HTTP-запросов вне очереди
  • [ ] События удаления (PageDeleted, ContentEntryDeleted, MediaDeleted, UserDeleted) доставляются как thin-payload независимо от payload_mode подписки
  • [ ] Анти-эхо: событие с origin, совпадающим с origin_code подписки-адресата, этой подписке не доставляется; при пустом origin доставка не блокируется
  • [ ] payload_mode = thin отдаёт только id+event+origin, payload_mode = full — whitelist-поля события целиком
  • [ ] Два активных секрета (secret + previous_secret) оба валидны для проверки подписи получателем в окне previous_secret_expires_at; по истечении окна старый секрет обнуляется
  • [ ] Circuit breaker проходит closed → open → half-open → closed по сценарию сбоя/ восстановления; в open реальный HTTP-вызов не выполняется (статус skipped)
  • [ ] Создание подписки сверх webhooks-out.max_subscriptions отклоняется понятной ошибкой
  • [ ] Контрактный набор cms-testing пройден, пакет протестирован в testbench-изоляции
  • [ ] Feature-тест на каждый роут модуля; тестовая БД только webhooks_out_test, migrate:fresh запрещён

Внутренняя база знаний студии