Тема
ТЗ — Исходящие вебхуки (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_subscriptions | id, 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_deliveries | id, subscription_id, payload (json), status_code, attempt, response_time_ms, delivered_at | журнал доставки |
Заметки: payload — JSONB с касом 'array'; FK cms_webhooks_out_deliveries.subscription_id — constrained() + 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_active | FormRequest: event — whitelist типов, target_url — url + 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}/resend | id доставки (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.enabled | bool | true | нет | Включить рассылку исходящих вебхуков |
webhooks-out.retry_attempts | int | 3 | нет | Число попыток доставки |
webhooks-out.retry_backoff_seconds | int | 10 | нет | Базовая задержка между попытками |
webhooks-out.auto_disable_threshold | int | 10 | нет | Число подряд неудач до автоотключения подписки |
webhooks-out.delivery_log_retention_days | int | 30 | нет | Хранение журнала доставки |
webhooks-out.request_timeout_seconds | int | 5 | нет | Таймаут HTTP-запроса к endpoint'у подписчика |
webhooks-out.max_payload_kb | int | 256 | нет | Лимит размера тела исходящего запроса, при превышении — обрезка/ссылка вместо вложенных данных |
webhooks-out.allowed_url_schemes | array | ["https"] | нет | Whitelist схем target_url — заведомо запрещает http:// и нестандартные схемы |
webhooks-out.secret_rotation_grace_minutes | int | 0 | нет | Сколько минут после смены секрета уже поставленные в очередь доставки ещё подписываются старым секретом (0 — сразу новым, см. крайние случаи) |
webhooks-out.secret_overlap_max_hours | int | 24 | нет | Максимальное окно перекрытия secret/previous_secret, которое администратор может задать при явной ротации секрета |
webhooks-out.default_payload_mode | enum(thin|full) | thin | нет | Режим payload по умолчанию для новых подписок |
webhooks-out.max_subscriptions | int | 50 | нет | Лимит активных подписок на сайт |
webhooks-out.enabled — глобальный kill-switch: false мгновенно останавливает рассылку без выключения модуля целиком. Отличие от circuit breaker/auto_disable_threshold (per-подписка, постепенно): kill-switch — сразу и по всем подпискам, ручным решением администратора.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/admin/webhooks-out/subscriptions | admin (webhooks-out.view) | Список подписок |
| POST | /api/v1/admin/webhooks-out/subscriptions | admin (webhooks-out.manage) | Создание подписки |
| POST | /api/v1/admin/webhooks-out/deliveries/{id}/resend | admin (webhooks-out.manage) | Повторная отправка события |
| POST | /api/v1/admin/webhooks-out/subscriptions/{id}/rotate-secret | admin (webhooks-out.manage) | Ротация секрета: текущий secret → previous_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. Демо-сидеры: не применимо — модуль не имеет блоков/виджетов контента для галереи.
Ранбук (симптом → команда).
| Симптом | Действие |
|---|---|
Подписка массово получает failed | cms: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) | событие | out | WebhookSubscriptionDisabled, ручной 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.view | webhooks-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, издаётсяWebhookSubscriptionDisabled→NotificationDispatch(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запрещён