Оплата без двойных списаний: вебхуки, подписи и сверка
Как принимать вебхуки платёжной системы так, чтобы повторы, гонки и пропуски не превращались в двойные списания и потерянные заказы.
В прототипе оплата работает так: человек нажал «Оплатить», вернулся на страницу «Спасибо», и страница выдала доступ. В продукте эта схема ломается тремя способами: человек закрыл вкладку до возврата, нажал кнопку дважды или открыл страницу «Спасибо» руками. Надёжная оплата строится на другом принципе — источник правды не браузер, а платёжная система, и узнаёшь ты от неё всё через вебхуки.
Страница «Спасибо» ничего не выдаёт
Первое правило: страница возврата только показывает статус. Доступ, кредиты, отгрузку выдаёт обработчик вебхука на сервере. Если страница открылась раньше, чем пришёл вебхук, она пишет «платёж обрабатывается» и опрашивает сервер, а не решает сама.
Так исчезает целый класс проблем: и закрытая вкладка, и подделанный адрес возврата, и человек, который понял, что ?status=success можно дописать руками.
Подпись проверяется по сырому телу
Вебхук — обычный POST-запрос на публичный адрес. Отправить его может кто угодно, поэтому каждый запрос проверяется по подписи, которую платёжная система считает общим секретом.
Частая ошибка: фреймворк сам разбирает JSON до того, как до тела доберётся проверка. Пересобранный JSON отличается от исходного пробелами и порядком полей, подпись не сходится, и разработчик в отчаянии проверку отключает. Подпись считается по сырым байтам запроса — для этого маршрута разбор тела нужно выключить.
Вторая деталь — время. Подпись обычно включает метку времени, и запрос старше нескольких минут стоит отклонять: иначе перехваченный вебхук можно проиграть заново.
Повторы и порядок
Платёжные системы доставляют вебхуки «хотя бы один раз». Значит, одно событие может прийти дважды, а события могут прийти не по порядку: «подписка продлена» раньше, чем «подписка создана».
Отсюда два правила:
- Обработка идемпотентна. Запись о событии и его последствия делаются в одной транзакции. Если запись уже была, обработчик ничего не делает и отвечает успехом.
- Состояние не собирается из последовательности событий. Получив событие, сходи в API платёжной системы за текущим состоянием объекта и приведи свою запись к нему. Тогда порядок доставки перестаёт иметь значение.
insert into payment_events (provider_event_id, received_at)
values ($1, now())
on conflict (provider_event_id) do nothing
returning id;
-- пусто в ответе: событие уже обработано, выходим
И отвечай быстро. Если обработчик делает тяжёлую работу — генерирует документы, шлёт письма — положи событие в очередь и ответь сразу. Иначе платёжная система не дождётся ответа, сочтёт доставку неудачной и пришлёт событие снова.
Двойное нажатие — это не вебхук
Двойное списание чаще всего рождается не в вебхуках, а в момент создания платежа. Человек жмёт кнопку дважды, сеть повторяет запрос, и сервер создаёт два платежа.
Отключённая кнопка помогает, но не защищает. Защищает ключ идемпотентности: сервер создаёт заказ один раз, а запрос к платёжной системе отправляет с ключом, привязанным к этому заказу. Повтор с тем же ключом вернёт уже созданный платёж, а не новый. Большинство платёжных API такой ключ поддерживают.
Сверка
Даже правильный обработчик однажды пропустит событие: деплой, упавшая база, истёкший сертификат. Поэтому нужна сверка — регулярная задача, которая берёт у платёжной системы список платежей за период и сравнивает со своей базой.
Что ищет сверка:
- оплачено у провайдера, но не выдано у тебя;
- выдано у тебя, но платежа нет или он возвращён;
- суммы и валюты не совпадают.
Каждое расхождение — сообщение живому человеку, а не автоматическое исправление: причины бывают разными, и в половине случаев их стоит понять, а не замазать.
Отдельно не забудь про возвраты и споры. Если по ним не приходят события в твой обработчик, доступ остаётся у человека, который деньги уже вернул.
SUBXCAT помогает выстроить приём оплат так, чтобы бухгалтерия и база сходились без ручных правок.