Skip to content

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

Слой: 🔵 инфра-модуль · Зрелость доноров: ★★ · Донор: notal (платёжные колбэки) Статус: ТЗ к разработке

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

Приём входящих вебхуков (платёжные колбэки, внешние уведомления): верификация подписи, идемпотентная обработка, быстрый ответ 200 с отложенной обработкой в очереди. Граница ответственности с /cms-v2/security — этот модуль отвечает только за приём и маршрутизацию, не за общую фильтрацию трафика.

  • Единая точка приёма /api/v1/webhooks/{provider} для всех внешних источников
  • Верификация подписи запроса (HMAC/провайдер-специфичная схема)
  • Идемпотентность по event_id — повторная доставка не обрабатывается дважды
  • Быстрый ответ 200 сразу после верификации, обработка — в очереди
  • Журнал полученных вебхуков (заголовки, тело, статус обработки)
  • Реплей вебхука из Filament (повторная постановка в очередь обработки)
  • Маршрутизация в обработчик конкретного модуля-получателя по коду провайдера

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

requires: ядро

Поведение при выключении: входящие запросы на /api/v1/webhooks/* отклоняются с 503, внешние системы должны повторить доставку позже — данные не теряются на стороне студии, но требуют ретрая со стороны отправителя.

Стоимость внешних API: не применимо — webhooks-in исключительно входящая сторона (принимает запросы, не инициирует их), платных вызовов вовне не совершает и тарифных лимитов провайдера не расходует.

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

ТаблицаКлючевые поляПримечание
cms_webhooks_in_logid, provider, event_id, headers (json), payload (json), status, processed_atжурнал приёма, уникальность по provider+event_id

Заметки: status — PHP Enum; headers/payload — JSONB с касом 'array'; уникальный составной индекс (provider, event_id) — основа идемпотентности, не только проверка в коде; журнальная таблица — кандидат на BRIN по created_at/processed_at.

ПДн-паспорт (матрица v2.2): cms_webhooks_in_log.headers/payload могут содержать ПДн плательщика/клиента — модуль принимает чужие вебхуки «как есть» (платёжные колбэки, CRM- уведомления) и не знает доменную структуру payload конкретного провайдера, поэтому не может выборочно исключить поля при записи. Срок хранения — webhooks-in.log_retention_days (90 дней), ретеншн-джоба удаляет записи старше порога. Участие в «выгрузить всё по субъекту»/«забыть по запросу» (152-ФЗ) — как и cms/integrations-bus, сам webhooks-in не умеет находить записи по субъекту (не знает, какое поле payload — идентификатор человека у конкретного провайдера); модуль обязан предоставить хук ядра «найти и анонимизировать записи журнала по предикату» (по provider

  • произвольному JSON-условию на payload), который вызывает модуль-получатель, знающий структуру своего провайдера — анонимизация схлопывает headers/payload до {provider, event_id}, статус и факт обработки не теряются.

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

Whitelist-принцип: всё, что не перечислено во «Входах», модуль обязан отвергать (§11 стандарта) — неизвестный провайдер, незадекларированное поле фильтра, отсутствующий заголовок подписи.

Входы

ИсточникДанные/поляЧем валидируется
Вебхук (внешний провайдер), POST /api/v1/webhooks/{provider}заголовки (подпись, timestamp), тело event_id + payload (JSON)EnsureWebhookSignature (HMAC по секрету источника, hash_equals), whitelist полей payload по схеме провайдера; неизвестный {provider} → 404 до чтения тела
Admin API, GET /api/v1/admin/webhooks-in/logfilter[provider], filter[status], filter[event_id], sort=-created_at, cursorFormRequest, whitelist фильтров/сортировки (§7 стандарта); неизвестный параметр → 422
Admin-действие, POST /log/{id}/replayid записи журналаFormRequest: запись существует и принадлежит cms_webhooks_in_log, webhooks-in.replay_enabled = true, право webhooks-in.manage
Настройки (settings-store), группа webhooks-inфлаги/пороги — см. «Настройки»схема настройки (тип, дефолт, валидация), запись — только settings.manage

Выходы

ПотребительДанныеФормат
Модуль-получатель (provides: payment-gateway и др.)событие WebhookReceived (provider, event_id)канал 1 EventBus, payload события — не сырое тело запроса
Очередь webhooks-injob обработки (provider, event_id, log_id)сериализованный job, идемпотентный по event_id
Admin UI (Filament / API журнала)список записей журнала, статусы success/rejected/failedконверт {data, meta}, keyset-пагинация
cms/healthдоля failed-обработок за последний часагрегат health-чека модуля
Внешняя система (ответ на вебхук)HTTP-статус (2xx/4xx)пустое тело или минимальный JSON, без деталей причины отказа

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

КлючТипДефолтaffectsPageCacheОписание
webhooks-in.enabledbooltrueнетПриём входящих вебхуков
webhooks-in.signature_verification_requiredbooltrueнетОбязательность проверки подписи
webhooks-in.log_retention_daysint90нетХранение журнала входящих вебхуков
webhooks-in.replay_enabledbooltrueнетРазрешить реплей из админки
webhooks-in.rate_limit_per_sourceint120нетЛимит запросов/мин на один provider (429 + Retry-After при превышении)
webhooks-in.timestamp_tolerance_secondsint300нетДопустимое расхождение timestamp запроса с текущим временем (защита от replay)
webhooks-in.allowed_sourcesarray[] (= все зарегистрированные)нетWhitelist кодов провайдеров/IP-подсетей; вне списка — 404 до проверки подписи
webhooks-in.max_payload_size_kbint256нетВерхний предел тела запроса, больше — отказ до парсинга JSON
webhooks-in.daily_quota_per_sourceint, nullablenull (без квоты)нетСуточный лимит запросов на один provider — ловит «тихий» шторм ниже rate_limit_per_source, но аномальный в сумме за день
webhooks-in.orphan_detection_minutesint60нетОкно, после которого принятый вебхук без обработавшего подписчика WebhookReceived помечается quarantined

Kill-switch двух уровней. Общий — webhooks-in.enabled: false отдаёт 503 всем источникам (см. «Зависимости и выключение»), рубильник на весь приём. Точечный — webhooks-in.allowed_sources: удаление кода конкретного провайдера из whitelist мгновенно (следующий запрос, без деплоя и рестарта) останавливает приём именно от него (404 до проверки подписи), не затрагивая остальные источники — штатный способ аварийно отключить один скомпрометированный/сбойный provider, не останавливая приём от остальных.

API

МетодПутьДоступНазначение
POST/api/v1/webhooks/{provider}подпись провайдераПриём вебхука
GET/api/v1/admin/webhooks-in/logadmin (webhooks-in.view)Журнал приёма с фильтрами
POST/api/v1/admin/webhooks-in/log/{id}/replayadmin (webhooks-in.manage)Реплей обработки вебхука

Журнал — keyset-пагинация (не OFFSET).

Admin-эндпоинты журнала/реплея (GET /log, POST /log/{id}/replay) отвечают на 401/403/404/422/429 конвертом {"message": …, "code": "...", "errors": {…}} по ревизии ядра 14.07.2026, п.1 — code машиночитаем для клиентов admin API. Сам вебхук-эндпоинт (POST /api/v1/webhooks/{provider}) — исключение по дизайну: см. «Безопасность», код причины отказа существует только во внутреннем журнале (cms_webhooks_in_log.status), в HTTP-ответ внешнему провайдеру не попадает.

Компоненты

Filament: страница журнала входящих вебхуков с кнопкой реплея, фильтром по статусу (включая quarantined, см. «Крайние случаи»). Команды: cms:webhooks-in:replay --json — принимает как одиночный id, так и диапазон --provider=<code> --from=<ts> --to=<ts>, поддерживает --dry-run (отчёт «сколько записей будет переиграно» без постановки в очередь).

Демо-контент: у модуля нет блоков/виджетов — не применимо в терминах schema/demo-props. Для галереи Filament-страницы журнала и playground — сидер из 2–3 фиктивных записей cms_webhooks_in_log (provider = demo, статусы success/rejected/failed), достаточных для демонстрации UI журнала без реального внешнего источника.

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

СобытиеКогдаPayload
WebhookReceivedвебхук принят и верифицированprovider, event_id
WebhookProcessingFailedобработка в очереди завершилась ошибкойprovider, event_id, error

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

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

Сущность/модульКаналНаправлениеЧто происходит
Ядро (RequestContext)сервис-вызов ядраinрезолвит контекст запроса перед приёмом (при мультисайте — site_id источника)
Модуль-получатель по коду провайдера (provides: payment-gateway/delivery-provider/crm-connector и т.п.)событие WebhookReceived (канал 1)outполучатель сам подписывается и обрабатывает факт; webhooks-in не знает, кто подписан (см. ⚠️ Противоречие в «Крайних случаях»)
cms/audit (suggests)событие WebhookReceived/WebhookProcessingFailed (канал 1)outфиксирует приём/ошибку в журнале аудита, если модуль включён — иначе тихая деградация
cms/attack-monitor (suggests)событие WebhookProcessingFailed + счётчик отказов подписи (канал 1)outдетектирует шторм невалидных подписей как атаку; при отсутствии модуля алерт не формируется
cms/healthhealth-чек из манифестаoutагрегирует долю failed-обработок за час, отставание очереди webhooks-in
NotificationDispatch (ядро, контракт notification-channel)сервис-вызов (канал 3)out (опционально)уведомление админа при шторме/деградации подписи; при отсутствии канала — лог-fallback

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

Очередь webhooks-in для отложенной обработки после верификации подписи и быстрого ответа 200; реплей из Filament ставит то же событие повторно в очередь. Внешние вызовы — только из очереди, никогда синхронно в контроллере приёма.

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

Ожидаемые объёмы: типично единицы–десятки вебхуков в минуту на источник (платёжные колбэки, CRM-уведомления); пиковый шторм — сотни в минуту при массовых ретраях внешней системы после её сбоя или дублирующей рассылке события. Модуль обязан переживать шторм без деградации остального сайта — rate_limit_per_source режет поток на входе, очередь webhooks-in работает буфером.

Горячий путь — приём (POST /api/v1/webhooks/{provider}) — обязан отвечать 2xx с минимальным бюджетом: 1 запрос на резолв провайдера/секрета (кешируемо), 1 insert журнала (уникальный индекс идемпотентности). Никакой бизнес-логики получателя синхронно в контроллере — она уходит в очередь уже после ACK.

Критичные индексы: уникальный (provider, event_id) — источник истины для идемпотентности, не кеш; индекс по provider (выборка журнала по источнику, rate-limit); BRIN по created_at/processed_at (append-only журнал, keyset-пагинация по времени).

Что кешируется: версия/хеш секрета источника (для быстрой сверки подписи без похода в .env/конфиг на каждый запрос) — не сам секрет; кеш инвалидируется командой при ротации секрета, не событием SettingChanged (секреты не живут в settings-store, §6 стандарта). Whitelist провайдеров (webhooks-in.allowed_sources) читается из кеша группы настроек — 0 запросов на горячем пути. Собственных тегов CacheTags модуль не объявляет — журнал приёма не участвует в page-cache.

Реконструкция состояния получателя по журналу. cms_webhooks_in_log хранит полное тело каждого принятого вебхука за весь log_retention_days — это делает журнал источником истины, пригодным не только для повтора одной записи, но и для восстановления состояния модуля-получателя целиком (например платёжный модуль потерял часть записей или переехал на новую БД). cms:webhooks-in:replay --provider=<code> --from=<ts> --to=<ts> переигрывает диапазон в хронологическом порядке приёма (не порядке id, если возможна гонка вставки), с --dry-run (отчёт: сколько записей попадёт в диапазон, без постановки в очередь) — так модуль-получатель пересобирает своё состояние из журнала, не дожидаясь повторной доставки от внешней системы (она может не хранить историю так долго или не поддерживать ручной повтор).

Ранбук: симптом → команда

СимптомЧто проверить/сделать
Шторм невалидных подписей от одного providercms/attack-monitor + журнал rejected по этому provider; вероятна атака на конкретный источник — точечный kill-switch (убрать код из webhooks-in.allowed_sources, см. «Настройки»/«UX-требования»)
Очередь webhooks-in отстаёт, health красныйПриём уже отвечает 2xx (потери нет), но обработка задерживается — проверить нагрузку/число воркеров очереди webhooks-in, при необходимости масштабировать воркер
WebhookReceived без подписчиков дольше orphan_detection_minutesЗаписи со статусом quarantined в журнале — проверить, что ожидаемый модуль-получатель (payment-gateway/crm-connector/…) включён; после включения — ручной реплей карантинных записей
Запись status=failed (обработка упала после ретраев)Журнал с фильтром по статусу failed, разобрать причину по WebhookProcessingFailed.error, ручной реплей — cms:webhooks-in:replay --json
Подозрение на утерю/рассинхрон данных у получателяМассовый реплей диапазона --provider --from --to с --dry-run для оценки объёма, затем без него — реконструкция состояния из журнала (см. выше)

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

Единственная публичная поверхность — вебхук-эндпоинт /api/v1/webhooks/{provider}, граница ядра под верификацию подписи (HMAC/провайдер-специфичная схема), обязательна при webhooks-in.signature_verification_required — запрос без валидной подписи отклоняется до постановки в очередь. Ответственность разделена с /cms-v2/security: модуль не занимается общей фильтрацией трафика, только приёмом и маршрутизацией конкретных вебхуков.

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

  • подделка подписи — секрет источника хранится только в .env/config/webhooks-in.php (anti-hardcode), никогда в settings-store и БД;
  • timing-атака на сравнение подписи — сравнение HMAC только через hash_equals() (constant-time), обычное ===/== запрещено;
  • replaytimestamp + nonce/event_id, окно расхождения timestamp_tolerance_seconds, повторный event_id отклоняется идемпотентностью (не как ошибка — см. «Крайние случаи»);
  • SSRF при верификационном handshake — если провайдер требует callback на URL из payload (domain-verification паттерн некоторых платёжных/CRM-систем), такой URL проверяется по allowlist известных хостов провайдера, а не вызывается вслепую; на старте модуля такого handshake нет — вектор фиксируется на будущее для новых провайдеров;
  • утечка секретов в логи/ошибки — журнал (headers/payload) хранит заголовки запроса, но значение подписи и секрет источника в журнал и в тело ответа не попадают; ответ на невалидную подпись — общий человеческий текст, без деталей проверки (см. «Крайние случаи», п. 1).

Матрица ролей (permissions webhooks-in.view, webhooks-in.manage):

Роль.view (журнал).manage (реплей, чувствительные тела)
Посетитель/API-клиентнет (публичен только сам приём вебхука)нет
Редакторнетнет
Менеджерда — список и статусы записей журналанет
Админ/studioдада — реплей одной записи, массовый реплей по диапазону, доступ к телам headers/payload в чувствительном журнале

Реплей и просмотр сырых тел запроса — повышенная роль (.manage), т.к. журнал может содержать ПДн (см. «Модель данных»); менеджер видит статусы и метаданные, но не обязан видеть тело.

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

Админ:

  • пустое состояние журнала: нет ни одного принятого вебхука или нет зарегистрированных источников → подсказка «источники не настроены» со ссылкой, какой модуль-получатель включить;
  • список последних входящих — статус success/rejected/failed цветом/иконкой, без сырых кодов ответа;
  • массовые действия: реплей пачки записей (выбор чекбоксами + «Повторить обработку»), с подтверждением количества и провайдера перед постановкой в очередь;
  • человеческие ошибки: «Подпись вебхука неверна» вместо 401, «Источник не зарегистрирован» вместо 404, «Слишком много запросов от источника» вместо 429 (аудитория «админ» из §12 стандарта);
  • подтверждение необратимых операций: массовый реплей и любая операция очистки/сброса журнала требуют явного подтверждения с указанием периода/количества записей;
  • точечный kill-switch: удаление кода провайдера из списка webhooks-in.allowed_sources в настройках — штатный способ мгновенно остановить приём от одного проблемного источника, не трогая остальные и не выключая модуль целиком (см. «Настройки»); UI подсказывает это действие при шторме невалидных подписей от конкретного provider.

Внешняя система (посетитель API): UI отсутствует, но воспринимаемая скорость — быстрый ACK (2xx до тяжёлой обработки, см. «Крайние случаи», п. 4); ответ на отказ — машиночитаемый код без подробностей причины (см. «Безопасность»).

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

  • неверная/отсутствующая подпись → отказ до постановки в очередь (401/403), тело ответа не раскрывает причину («ожидалась подпись X, получена Y» — запрещено), запись в журнал со статусом rejected;
  • replay-атакаtimestamp вне timestamp_tolerance_seconds отклоняется; повторный event_id в пределах окна не считается ошибкой источника — отклоняется идемпотентностью (см. ниже), не HMAC-проверкой;
  • шторм вебхуков от одного источникаrate_limit_per_source возвращает 429 + Retry-After сверх лимита; очередь буферизует принятое; при переполнении очереди приём продолжает быстро отвечать 2xx, а обработка отстаёт — фиксируется health-чеком, не роняет приём;
  • ответ 2xx отправлен до тяжёлой обработки, обработка потом падает → job уходит в failed после исчерпания ретраев, статус журнала → failed, событие WebhookProcessingFailed; внешняя система уже получила 200 и сама не ретраит — единственный путь довести факт до получателя — ручной/автоматический реплей из Filament, иначе событие теряется безвозвратно;
  • дубль доставки одного event_id → уникальный индекс (provider, event_id) не даёт вставить вторую запись, обработчик повторно не запускается, внешней системе всё равно возвращается 200 (иначе она уйдёт в бесконечный ретрай);
  • гонка параллельной доставки одного event_id → constraint violation на втором insert трактуется как штатный дубль (см. выше), не как ошибка сервера (500);
  • выключение модуля посреди приёма → новые запросы на /api/v1/webhooks/* получают 503 (см. «Зависимости и выключение»); уже поставленные в очередь job'ы доигрываются воркером — выключение модуля не останавливает очередь автоматически, это отдельный шаг эксплуатации;
  • отсутствие suggests-модуля-получателя (например ещё не поставлен нужный payment-gateway-провайдер) → вебхук принимается и логируется штатно (подпись проверяется по источнику, не по наличию подписчика), но WebhookReceived уходит без подписчиков — факт нигде не применяется; риск молчаливой потери бизнес-события. Механизм карантина: запись, чей WebhookReceived не подобран ни одним обработчиком в течение webhooks-in.orphan_detection_minutes (дефолт 60 минут), помечается статусом quarantined — отдельным от success/rejected/failed (там либо успех, либо ошибка проверки/обработки; здесь — «принят, но никем не подхвачен»). Виден в Filament отдельным фильтром журнала, алертится через cms/health как потенциальная потеря бизнес-события; после включения нужного модуля-получателя запись из карантина реплеится вручную (карантин не переигрывается автоматически, чтобы не создать шторм при массовом включении модуля);
  • суточная квота источника превышена (webhooks-in.daily_quota_per_source) → приём не блокируется, внешняя система всё равно получает 200 (иначе — ретрай-шторм с её стороны), но событие алертится через cms/health как «тихий» шторм ниже rate_limit_per_source, требующий проверки — квота задумана как индикатор аномалии, не как ограничитель доставки;
  • огромный payload / некорректный JSON → тело больше max_payload_size_kb отклоняется до парсинга; невалидный JSON → общий 400, в журнал пишутся заголовки и обрезанное тело для диагностики, процесс не падает;
  • неизвестный источник (код провайдера не зарегистрирован либо не входит в webhooks-in.allowed_sources) → 404 до проверки подписи, без подробностей в ответе; попадает в общий rate-limit по IP, чтобы не стать способом перебора списка провайдеров;
  • пустой/не настроенный секрет источника → модуль обязан отказывать все запросы этого провайдера (fail closed), а не пропускать их как «подпись не проверяется» — иначе комбинация signature_verification_required = false с незаполненным секретом превращается в дыру;
  • ⚠️ Противоречие: сквозной пример в обмене данными описывает cms/webhooks-in → PaymentsService::confirm() [канал 4: requires], то есть прямой вызов публичного сервиса конкретного платёжного модуля по жёсткому requires. Текущее ТЗ (разделы «Зависимости», «События и обмен») описывает модуль как generic-роутер: requires: ядро без привязки к конкретным получателям, маршрутизация — событием WebhookReceived, на которое получатель сам подписывается по коду провайдера. Эти две модели несовместимы: либо webhooks-in жёстко зависит от каждого поддерживаемого провайдера (requires растёт с числом провайдеров, что ломает «модуль не трогает чужого» при добавлении нового провайдера без правки webhooks-in), либо сквозной пример в data-exchange.md упрощён и на деле имеет в виду, что получатель события (например cms/pay-gateways-ru) сам вызывает PaymentsService::confirm() внутри своего обработчика WebhookReceived, а не webhooks-in напрямую. Предложение разрешения: считать generic event-based модель (текущее ТЗ) канонической для webhooks-in, а формулировку в data-exchange.md — сокращённой записью, где «webhooks-in → PaymentsService::confirm()» на самом деле означает «обработчик WebhookReceived, зарегистрированный платёжным модулем, вызывает PaymentsService::confirm()»; уточнить текст сквозного примера в data-exchange.md отдельным PR.

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

Что взятьПуть
Приём платёжного колбэкаnotal/src/app/Http/Controllers/WebhookController.php
Верификация подписи (middleware)notal/src/app/Http/Middleware/EnsureWebhookSignature.php

Легенда доноров тут — код, не данные: EnsureWebhookSignature и обработчик колбэка переносятся как реализация, а не как источник строк для импорта. Легаси-импорт (§16 стандарта) не применим — модуль не хранит доменных данных для миграции: журнал cms_webhooks_in_log начинается с нуля в момент подключения модуля на проекте, старые вебхук-логи donor-сайта (если были) не переносятся — они относятся к обработчику-получателю (например платёжному модулю), не к webhooks-in.

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

  • [ ] Контрактный тест: запрос без валидной подписи отклоняется до постановки в очередь, ответ не раскрывает причину отказа
  • [ ] Сравнение подписи использует hash_equals() (тест на constant-time поведение, не строковое ==)
  • [ ] Health-чек модуля отражает долю failed-обработок за последний час
  • [ ] При выключении модуля входящие запросы получают 503, без падения приложения
  • [ ] Повторная доставка с тем же event_id не обрабатывается дважды (тест идемпотентности), ответ внешней системе всё равно 200
  • [ ] Гонка параллельной доставки одного event_id не приводит к 500 (constraint violation трактуется как дубль)
  • [ ] timestamp вне webhooks-in.timestamp_tolerance_seconds отклоняется (тест на replay)
  • [ ] Шторм запросов сверх webhooks-in.rate_limit_per_source получает 429 + Retry-After, очередь не переполняется бесконтрольно
  • [ ] Неизвестный провайдер / источник вне webhooks-in.allowed_sources → 404 до проверки подписи
  • [ ] Payload больше webhooks-in.max_payload_size_kb и невалидный JSON отклоняются без падения процесса
  • [ ] Пустой/не настроенный секрет источника → отказ (fail closed), не пропуск без проверки
  • [ ] Права webhooks-in.view/webhooks-in.manage разграничивают журнал и реплей
  • [ ] Ответ 200 отдаётся до завершения бизнес-обработки (обработка асинхронна через очередь)
  • [ ] Вебхук без подписчика WebhookReceived дольше webhooks-in.orphan_detection_minutes помечается quarantined (не success/failed) и алертится через cms/health
  • [ ] Массовый реплей диапазона (provider + период) воспроизводит записи в хронологическом порядке приёма, --dry-run не ставит задачи в очередь и отдаёт корректный отчёт
  • [ ] Превышение webhooks-in.daily_quota_per_source алертит через cms/health, но не блокирует приём — внешняя система по-прежнему получает 200
  • [ ] Удаление кода провайдера из webhooks-in.allowed_sources мгновенно отдаёт 404 запросам именно этого провайдера, приём остальных источников не затронут
  • [ ] Хук ядра «найти и анонимизировать записи журнала по предикату» схлопывает headers/payload до {provider, event_id}, не трогая записи других provider/предикатов
  • [ ] Контрактный набор cms-testing пройден, пакет протестирован в testbench-изоляции
  • [ ] Feature-тест на каждый роут модуля; тестовая БД только webhooks_in_test, migrate:fresh запрещён

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