К другим статьям
1С-БитриксT-BankWebhookИдемпотентность
Для разработчиков

Webhook оплаты в Битрикс: идемпотентность и безопасное завершение платежа

Платёжный webhook нельзя проектировать как одноразовый запрос. Банк повторяет уведомления при сетевой ошибке, статусы могут прийти с задержкой, а приложение способно упасть между обновлением платежа и открытием доступа. Корректный обработчик обязан безопасно принять одно событие несколько раз.

Ниже рассматривается поток T-Bank на примере zr.paidaccess. Используются только общие названия публичного API; ключи, реальные адреса и клиентские данные в коде не нужны.

Тонкий endpoint

Файл webhook должен решить только транспортные задачи:

  1. прочитать тело с ограничением размера;
  2. декодировать JSON;
  3. определить настроенный gateway;
  4. передать payload в provider adapter;
  5. вернуть ожидаемое банком подтверждение.

Проверка подписи и 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 — за бизнес-переход, а база гарантирует уникальность побочных эффектов.

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

Нужен модуль или интеграция?

Разберём архитектуру вашей задачи на 1С-Битрикс

Опишите сценарий и ограничения проекта — предложу безопасную схему реализации, оценку и следующий шаг.