Платёжный webhook нельзя проектировать как одноразовый запрос. Банк повторяет уведомления при сетевой ошибке, статусы могут прийти с задержкой, а приложение способно упасть между обновлением платежа и открытием доступа. Корректный обработчик обязан безопасно принять одно событие несколько раз.
Ниже рассматривается поток T-Bank на примере zr.paidaccess. Используются только общие названия публичного API; ключи, реальные адреса и клиентские данные в коде не нужны.
Тонкий endpoint
Файл webhook должен решить только транспортные задачи:
- прочитать тело с ограничением размера;
- декодировать JSON;
- определить настроенный gateway;
- передать payload в provider adapter;
- вернуть ожидаемое банком подтверждение.
Проверка подписи и provider-specific поля остаются в адаптере. Изменение подписки и фонда выполняет application service. Схема потока:
HTTP endpoint
-> T-Bank adapter: подпись, поля, статус
-> нормализованный WebhookResult
-> PaymentWebhookService
-> PaymentCompletionService
-> SubscriptionService + FundMovementServiceНе логируйте полное тело уведомления без фильтрации. В журнале достаточно технических идентификаторов, нормализованного статуса, результата проверки и correlation id.
Сначала аутентичность, затем состояние
Поля подписи нужно собирать строго по актуальной документации банка. Секрет добавляется только на сервере и никогда не записывается в лог. Сравнение выполняется constant-time функцией:
if (!hash_equals($expectedToken, $receivedToken)) {
throw new InvalidWebhookSignature();
}Пример схематический: алгоритм формирования $expectedToken зависит от версии публичного API T-Bank. Нельзя принимать статус только потому, что в JSON указан известный PaymentId.
После проверки подписи найдите локальный платёж по устойчивому внешнему идентификатору и убедитесь, что сумма и OrderId соответствуют ожидаемым. Сумму храните в минимальных денежных единицах, без float.
Идемпотентная машина состояний
Обработчик должен разрешать только допустимые переходы. Для одностадийной оплаты возможна последовательность AUTHORIZED, затем CONFIRMED; доступ открывается после подтверждения. Повторный CONFIRMED возвращает успешный ответ, но ничего не создаёт повторно.
public function complete(WebhookResult $event): void
{
$payment = $this->payments->lockByExternalId($event->paymentId());
if ($payment->isPaid()) {
return;
}
if (!$event->isConfirmed()) {
$this->payments->applyIntermediateStatus($payment, $event);
return;
}
$this->payments->markPaid($payment);
$this->subscriptions->activateOnce($payment);
$this->fund->recordIncomeOnce($payment);
}Код схематический. lockByExternalId означает транзакционную блокировку или эквивалентный механизм сериализации. Методы activateOnce и recordIncomeOnce должны иметь уникальный ключ источника, а не надеяться только на предварительный SELECT.
Почему одного флага недостаточно
Если сначала записать paid, затем процесс завершится до активации подписки, повтор webhook увидит флаг и выйдет — доступ останется закрытым. Возможны два подхода:
- одна транзакция для локального платежа, подписки и ledger;
- локальное состояние плюс outbox/retry, где каждый следующий шаг идемпотентен.
Транзакция проще, если все данные находятся в одной базе. Outbox лучше, когда завершение вызывает внешние системы. В обоих случаях повтор должен доводить сценарий до целевого состояния.
Политика duplicate OrderId
Ошибка существующего заказа возникает на этапе инициализации, а не при webhook. В zr.paidaccess политика PAYMENT_DUPLICATE_ORDER_POLICY имеет три режима:
fail— локальный платёж переводится в ошибку; подходит, когда повтор означает дефект генерации идентификатора;ignore— платёж остаётся ожидающим, событие фиксируется в журнале; полезно для ручной диагностики;reuse— модуль запрашивает существующий платёж и связывает его с локальной записью.
reuse нельзя трактовать как «принять любой найденный заказ». Нужно сопоставить сумму, назначение, терминал и внутреннего владельца. При несовпадении безопаснее остановить сценарий и потребовать разбор.
OrderId должен быть детерминированным для одной попытки, но уникальным для новой попытки, если бизнес допускает повторную оплату. Не используйте email или телефон в идентификаторе.
Ответ webhook и повторная доставка
Успешный HTTP-ответ означает, что событие надёжно принято, а не просто распарсено. Если транзакция не завершилась, верните ошибку, чтобы банк повторил уведомление. Для необратимо некорректной подписи повтор бессмысленен, но инцидент нужно зафиксировать с rate limit.
Защититесь от параллельных запросов:
- уникальный индекс по внешнему
PaymentId; - уникальный ключ ledger по локальному платежу и типу движения;
- блокировка строки при переходе статуса;
- сравнение текущего и входящего статуса;
- журнал результата обработки.
Что тестировать
Обязательные сценарии: два одинаковых CONFIRMED; AUTHORIZED после CONFIRMED; параллельное завершение; неверная подпись; несовпадение суммы; возврат после оплаты; сбой после записи статуса; каждый режим duplicate OrderId.
Тест должен проверять не только статус платежа, но и количество активаций и движений ledger: после любого числа повторов оно остаётся равным одному.
Практический вывод
Надёжный webhook — это аутентифицированное событие и идемпотентная машина состояний, а не контроллер с несколькими if. Provider adapter отвечает за язык банка, application service — за бизнес-переход, а база гарантирует уникальность побочных эффектов.
При разборе действующей интеграции сначала нарисуйте состояния и точки отказа, затем добавьте ограничения базы и тесты повторов. Это даёт больше безопасности, чем попытка запретить банку повторно отправлять уведомления.