Автопродление подписки дарило премиум бесплатно: чиню платёжный контур telegram-бота
Нельзя доверять телу вебхука ЮKassa: это публичный эндпоинт, данные в нём назначает отправитель. Проверяйте платёж обратным запросом в API кассы (Payment.find_one) плюс IP-allowlist, делайте обработчик идемпотентным через атомарный processing-claim и держите фоновую сверку (reconciliation), которая раз в час добивает зависшие pending до терминального статуса.
Автопродление подписки в telegram-боте раздавало премиум даром: вебхук верил телу запроса, pending блокировал продления. Разбор hardening: проверка вебхука, идемпотентность и сверка платежей.

Мой платёжный контур раздавал премиум бесплатно, и я об этом не знал.
Вебхук оплаты ЮKassa верил телу запроса. Присылаешь боту JSON, в котором telegram_id и план подписки лежат прямым текстом, - и оно включает премиум. Никакого автопродления подписки оформлять не надо: один curl, и доступ твой навсегда. Всплыло это не в поддержке и не на проде, а на security-аудите, который я как-то натравил на свой вайб-кодинг. Дыра оказалась не одна.
Контур - это подписка в боте-парсере вакансий, чью статистику я уже разбирал цифрами. Дальше - разбор граблей платёжного контура, и половина из них не про безопасность, а про то, что платёж - это распределённая система, где всё врёт и всё отваливается в самый неудобный момент.
Почему нельзя доверять телу вебхука от ЮKassa
Верить телу вебхука нельзя, потому что вебхук - это публичный HTTP-эндпоинт, дёрнуть который может кто угодно. ЮKassa прямо рекомендует не доверять payload'у, а проверять уведомление самому: запросить актуальный статус платежа через API и сверить IP отправителя с диапазонами кассы. Доверять надо кассе, а не тому, кто постучался.
У меня же telegram_id и план брались напрямую из JSON запроса - то есть их назначал сам отправитель. Фикс - обратная верификация: приходит вебхук, я по его payment_id иду в API кассы (Payment.find_one) и беру телефон... тьфу, беру статус, сумму и метаданные оттуда, из источника правды. Плюс IP-allowlist ЮKassa на входе. После деплоя и подделка тела, и спуфинг заголовка снаружи дают честный 403.
Отмена подписки, которая ничего не отменяла
Вторая дыра тише первой, но итог тот же - вечный бесплатный премиум. Отмена подписки в боте гасила только флаг auto_renew, а статус подписки оставался active. Пользователь жал "отменить продление", деньги с него больше не списывались, а доступ не отбирался никогда. Формально отписался - фактически пожизненный премиум.
Лечится тривиально, когда увидел: отмена должна не только снимать auto_renew, но и проставлять подписке терминальный статус по окончании оплаченного периода. Но пока ты этого не видишь, оно тихо работает "в плюс пользователю" и в минус тебе, и ни один тест на это не ругается - потому что оба поля по отдельности выглядят правильными.
Как зависшие pending-платежи убивают автопродление
Зависший pending - это платёж, который не дошёл до терминального статуса (succeeded или canceled) и застрял в промежуточном. У меня таких накопилось 4 штуки, и они молча ломали автопродление: в коде стояла защита "есть незакрытый pending - не ретраить, вдруг деньги спишутся дважды". Логика здравая, эффект - катастрофа. На этих четырёх зависших платежах я потерял лучшего платящего клиента: продление ему просто не ушло, а я даже алерта не получил.
Вот это и есть главный урок всего разбора. Вебхук - ненадёжный канал. Он может не дойти, дойти дважды, дойти с таймаутом. Если у тебя нет второго механизма, который сам ходит и проверяет реальность, то любой потерянный вебхук превращается в тихо застрявший платёж и потерянного клиента.
Что такое reconciliation платежей и зачем джоб в lifespan
Reconciliation платежей (сверка) - это регулярное приведение своих записей о платежах в соответствие с тем, что реально произошло на стороне кассы. Не ждать вебхука, а самому спрашивать: касса, что там на самом деле с этим платежом? Я вынес сверку в фоновый джоб прямо в lifespan бэкенда.
Раз в час джоб берёт все платежи в статусе pending или processing старше часа и добивает их до терминального статуса через API кассы - тем же самым идемпотентным кодом, что и вебхук. Один обработчик на два входа: живой вебхук и фоновая сверка. Сироты - платежи без external_id, которые вообще непонятно как появились, - помечаются failed с алертом мне в личку. Это ровно то, что советуют делать гайды по сверке платежей: автоматизировать рутину и оставлять человеку только аномалии, а не глазами перебирать таблицу.
Идемпотентность вебхука через processing-claim
Идемпотентность - это когда повторный вызов обрабатывается ровно так же, как одиночный, без двойного эффекта. Касса может прислать один и тот же вебхук дважды, а сверка - подхватить его параллельно; списать премиум за это надо один раз. Я сделал идемпотентность через processing-claim: атомарный переход pending -> processing, который гарантирует единственного обработчика.
Кто первым в транзакции забрал claim (перевёл платёж в processing) - тот и обрабатывает, все остальные видят, что claim занят, и тихо отваливаются. Зависшие в processing (обработчик умер на середине) через час подберёт та же reconciliation. Снизу всё это подпирает unique-индекс по external_id в БД - последняя линия обороны от дублей. Была идея сделать проще, через "перенос claim" от зависшего обработчика к новому, но на ревью её зарубили: такой перенос в редких гонках терял продления. Сама ЮKassa, к слову, держит идемпотентность через Idempotence-Key и советует слать туда UUID v4 - логика ровно та же, единственный обработчик на операцию.
Какие HTTP-статусы отдавать вебхуку ЮKassa
ЮKassa считает уведомление доставленным только после ответа HTTP 200; на любой другой код она повторяет доставку в течение суток. Отсюда правило, до которого я дошёл не сразу: транзиентная ошибка верификации - это 500 (пусть касса ретраит, потом разберёмся), а событие "такого платежа у меня нет" - это 200 (оно не про нас, закрываем и забываем).
Раньше у меня любая ошибка внутри обработчика отдавала 200. То есть упала верификация по сети - касса получала бодрое "ок, принято", успокаивалась и больше не ретраила. Событие терялось навсегда, платёж повисал, привет reconciliation. Один неправильный код ответа - и весь ретрай-механизм кассы, который тебе бесплатно дают, работает против тебя.
Грабли деплоя: боевая БД встретила сюрпризом
Последнее поймалось уже на проде и было самым обидным. CHECK-констрейнт payments_status_check в боевой базе не знал про новый статус processing, который я добавил ради идемпотентности. Локально всё зелёное, на сервере в тестах всё зелёное, а на живой схеме первый же переход в processing упирается в констрейнт и падает.
Ловится такое только на настоящей схеме БД - на фейках и моках в тестах его не видно в принципе, там нет никакого констрейнта. Итог по цифрам: 259 зелёных тестов локально и на сервере, и всё равно миграцию констрейнта пришлось накатывать руками отдельным шагом. Мораль банальная, но выстраданная: тесты проверяют твой код, а не чужую боевую схему.
Сам бот, в котором всё это живёт, я когда-то собирал с нуля на Python, и тогда мне искренне казалось, что оплата - это "прикрутить вебхук за вечер". Оказалось, что оплата - это распределённая система: вебхук врёт, сеть отваливается, касса ретраит, а единственный источник правды живёт не у тебя. Весь hardening от аудита до деплоя сводится к одному выводу. Не верь вебхуку, проверяй по API, держи сверку, которая сама ходит и сверяет реальность. Остальное - детали реализации.
Частые вопросы
Почему платёж завис в статусе pending и не продлевает подписку?
Вебхук о финальном статусе не дошёл или обработчик ответил кассе 200 при ошибке, и она перестала ретраить. Платёж остаётся в pending, а защита 'есть pending - не ретраить' блокирует новые автопродления. Лечится фоновой сверкой, которая сама добивает такие платежи до терминального статуса через API.
Какой HTTP-статус возвращать на вебхук ЮKassa при ошибке?
На транзиентную ошибку (не смогли проверить платёж по сети) - 500, тогда касса повторит доставку в течение суток. На событие о неизвестном вам платеже - 200, чтобы закрыть его и не ловить бесконечные ретраи. Отвечать 200 на любую ошибку нельзя: событие потеряется навсегда.
Как сделать обработку вебхука идемпотентной?
Атомарный переход pending -> processing (processing-claim) гарантирует единственного обработчика: кто первым забрал claim, тот и обрабатывает, остальные отваливаются. Снизу подпирает unique-индекс по external_id в БД, а зависшие в processing подбирает фоновая сверка.
Как готовился материал: черновик собран моим AI-конвейером по темам и заметкам из практики, факты сверены с первоисточниками, финальный текст я прочитал, поправил и утвердил перед публикацией. Обложку к статье тоже рисует нейросеть. Про сам конвейер — в разделе обо мне.
