Представьте обработчик, который получил обновление, спокойно ответил HTTP 200 и в следующую миллисекунду был убит перезапуском. Telegram считает, что доставка состоялась. Пользователь считает, что заплатил. А в вашей базе нет ни строки о том, что этот человек теперь вправе пользоваться купленным. Транспорт свою работу сделал, право не записано, и спросить не с кого, кроме собственного кода.

Этот эпизод хорошо показывает главную мысль статьи: доставка обновления и выдача оплаченного доступа — две разные задачи. Выбор между webhook и long polling влияет на то, как обновление доезжает до процесса. Но надёжность платёжного бота определяется тем, что процесс делает с обновлением дальше. Разберём, где именно проходит эта граница.

Два способа получить одно и то же обновление

Telegram отдаёт боту обновления двумя путями. В первом вы сами ходите за ними методом getUpdates (это и есть long polling), во втором регистрируете адрес через setWebhook, и Telegram присылает обновления на него HTTPS-запросами. Эти режимы взаимоисключающие: для одного бота одновременно работает либо один, либо другой. Входящие обновления при этом хранятся ограниченное время, и это ограничение важно для всего дальнейшего разговора.

Разница в направлении соединения. При polling процесс бота сам инициирует исходящие запросы, ему не нужен публичный адрес, сертификат и открытый порт. При webhook инициатором выступает Telegram, поэтому у вас должен быть доступный снаружи HTTPS-эндпоинт, который в нужный момент жив и отвечает.

Из этого вытекают разные естественные точки отказа:

  • у polling это зависший или упавший цикл опроса, потерянное состояние позиции чтения и параллельный второй экземпляр, который начинает забирать те же обновления;
  • у webhook это недоступный адрес, медленный ответ, ошибка прокси или сертификата, а также чужие запросы, прилетающие на публичный URL.

Ни одна из этих точек не про деньги. Они про то, дойдёт ли до вас сообщение «платёж завершён». А вот что произойдёт после того, как оно дошло, зависит от вашего кода и хранилища.

Что считается подтверждением получения

Здесь два транспорта различаются принципиально, и именно здесь прячется самая коварная ошибка.

В режиме webhook подтверждением служит ваш HTTP-ответ. Если ответ неуспешный, Telegram повторяет доставку. Значит, если эндпоинт упал до ответа, обновление придёт снова. Это полезное свойство, но оно же создаёт соблазн: «ответим 200 сразу, а обработаем потом». Ответили, процесс умер, обработка не случилась, повтора не будет.

В режиме polling подтверждением служит продвижение позиции чтения: когда вы в следующем запросе просите обновления начиная с позиции за последним обработанным, прежние считаются полученными. Тот же соблазн принимает другую форму: «сдвинем позицию, а потом разберёмся». Сдвинули, процесс умер, обработки нет, повторно обновление не придёт.

Видна симметрия. И в том и в другом случае есть момент, когда транспорт перестаёт считать обновление своей заботой, и с этого момента ответственность целиком на вас. Правило из этого одно: подтверждать получение транспорту нужно только после того, как событие надёжно записано в ваше хранилище. Не после того, как оно обработано целиком, а именно после записи. Запись должна быть короткой и дешёвой, а тяжёлая работа пойдёт уже из хранилища.

Заметьте, что такая схема не обещает отсутствия повторов. Наоборот, она их допускает: если процесс записал событие и умер до ответа, Telegram пришлёт то же обновление снова. Поэтому второй обязательный элемент — умение узнавать повтор.

Два события оплаты, которые легко перепутать

Теперь перейдём к самой оплате. Для цифровых товаров внутри Telegram используются Stars, и в потоке оплаты бот видит как минимум два разных обновления.

Первое — предварительная проверка перед списанием (pre_checkout). Это вопрос Telegram к боту: «Всё в порядке, можно проводить платёж?» Ответ на него — подтверждение готовности, а не факт оплаты. Деньги к этому моменту ещё не списаны окончательно, и пользователь ещё может передумать или столкнуться с отказом.

Второе — successful_payment, сообщение об успешной оплате. Только оно говорит, что платёж состоялся. По документации выдача товара следует после него, а не после одной лишь предварительной проверки, и идентификатор оплаты нужно сохранить, потому что он понадобится для возврата.

Ошибка здесь типична и дорога. Разработчик выдаёт доступ в обработчике предварительной проверки, потому что «там уже всё известно». Дальше возможны два плохих сценария: платёж не завершился, а доступ уже выдан; либо проверка пришла повторно, и выдача случилась дважды. Предварительную проверку стоит использовать по назначению: быстро убедиться, что товар существует, доступен и цена соответствует заказу, и ответить. Всё, что меняет права пользователя, ждёт successful_payment.

Отдельно про скорость. Ответ на предварительную проверку нужно отдавать быстро, поэтому тяжёлую логику туда не кладут, а проверки держат лёгкими: поиск заказа по ключу и сравнение суммы. Конкретный лимит времени смотрите в документации платежей; мы не будем придумывать цифру.

Один платёж на двух временных линиях

Чтобы увидеть, чем webhook и polling отличаются в беде и чем похожи, проследим один и тот же платёж в обоих режимах. Сценарий условный: пользователь оплатил, и ровно в этот момент процесс бота перезапускается при выкатке новой версии.

Один платёж при перезапуске: webhook и polling параллельно
ШагWebhookLong polling
1. Платёж завершёнTelegram формирует обновление с successful_payment и отправляет POST на ваш адресОбновление ждёт в очереди Telegram, пока вы не запросите его через getUpdates
2. Процесс перезапускаетсяPOST приходит в момент, когда сервис недоступен: ответа нет или он неуспешный, доставка будет повторенаЦикл опроса остановлен, новых запросов нет, обновление остаётся неподтверждённым
3. Процесс поднялсяTelegram повторяет доставку, эндпоинт принимает событиеНовый процесс возобновляет опрос с сохранённой позиции и получает то же обновление
4. ЗаписьСобытие с ID оплаты записывается в базу под уникальным ключом, затем отдаётся 200Событие записывается в базу под уникальным ключом, затем сдвигается позиция чтения
5. Выдача праваОтдельный обработчик читает запись и выдаёт доступТо же самое, независимо от транспорта

Теперь две дополнительные линии, которые в таблице сливаются, но в жизни случаются: процесс умирает после записи, но до подтверждения.

При webhook событие в базе есть, ответ 200 не отправлен, Telegram пришлёт то же обновление ещё раз. Вторая доставка упирается в уникальный ключ, и вместо второй выдачи мы просто отвечаем «принято». При polling событие в базе есть, позиция не сдвинута, после старта то же обновление придёт снова. Результат тот же.

А теперь худший вариант, с которого мы начали: процесс ответил 200 (или сдвинул позицию) и умер до записи. Ни один транспорт здесь не поможет, и ни одна его настройка не спасёт, потому что транспорт честно исполнил своё обязательство. Вывод очевиден: порядок «сначала запись, потом подтверждение» важнее выбора между двумя способами.

Повторная доставка — это не новый платёж

Самая частая причина двойной выдачи — считать каждое входящее обновление за новое событие. Повтор, пришедший из-за неуспешного ответа, технически является тем же самым обновлением, и логика обязана его так и воспринимать.

Опорой для распознавания служит идентификатор оплаты из successful_payment. Это то же значение, которое потребуется для возврата, поэтому хранить его нужно в любом случае. Практическая схема такая:

  1. завести таблицу платёжных событий с уникальным ограничением на идентификатор оплаты;
  2. при получении successful_payment пытаться вставить строку: успех означает «событие новое», конфликт означает «мы это уже видели»;
  3. выдачу права привязать к этой строке, а не к самому факту прихода обновления;
  4. на конфликт отвечать успешно: повтору нужно спокойное подтверждение, а не ошибка, иначе вы сами спровоцируете новые повторы.

Обратите внимание на тонкую вещь: повторы не бывают признаком неисправности. Это штатный механизм, на котором строится надёжность доставки. Если ваш код реагирует на дубль паникой или двойным действием, он несовместим с этим механизмом.

Отдельно стоит предупредить о соблазне придумать «новый идентификатор попытки» при каждой обработке. Свежий случайный ключ на каждый запуск идемпотентности не создаёт: он только маскирует повтор под новое событие. Ключ должен происходить из самого платежа, а не из вашей попытки его обработать.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Запись события и выдача права — не одна операция

Следующий шаг в разделении ответственности: даже после записи события выдача права не обязана происходить в том же запросе. Полезно представить два независимых этапа.

Этап приёма. Проверили, что это допустимое обновление, вставили строку о платеже в состоянии «получен», подтвердили транспорту. Быстро и без внешних вызовов.

Этап выдачи. Отдельный воркер или цикл берёт записи в состоянии «получен», находит заказ, записывает пользователю право и переводит запись в «выдано». Если он упал, запись остаётся «полученной», и после перезапуска выдача продолжится. Если воркер запустили дважды, переход состояния выполняют условным обновлением («поменять состояние, только если оно ещё „получен“»), и вторая попытка просто ничего не изменит.

Разделение даёт сразу несколько вещей. Перезапуск между этапами перестаёт быть опасным: состояние лежит в базе, а не в памяти процесса. Медленная выдача не тормозит ответ транспорту, и Telegram не получит таймаут, который вызвал бы лишние повторы. А если выдача зависит от внешнего сервиса, то сбой в нём не потеряет платёж: запись о нём уже есть.

Одна оговорка про границы. Транзакция вашей базы охватывает только вашу базу. Если выдача включает вызов внешнего API (скажем, создание аккаунта в чужом сервисе), атомарно связать этот вызов и запись у себя нельзя без отдельного механизма на стороне того сервиса. Поэтому внешние действия стоит делать повторяемыми и проверяемыми: например, передавать им собственный стабильный ключ заказа, если сервис такое поддерживает, и сверять результат перед повтором.

Секретный заголовок и бизнес-проверки: разные вещи

При webhook у публичного адреса есть неприятное свойство: на него может прийти запрос от кого угодно. Для этого при настройке можно задать secret_token, и Telegram будет передавать его в специальном заголовке. Эндпоинт сравнивает значение со своим и отбрасывает запросы без совпадения.

Это хорошая гигиена, но важно понимать, что именно она закрывает. Секрет подтверждает, что запрос пришёл по тому каналу, который вы настроили. Он не отвечает на вопросы бизнес-логики:

  • существует ли заказ, к которому относится платёж;
  • совпадает ли сумма и валюта с ожидаемыми;
  • не использован ли этот платёж уже (то самое уникальное ограничение);
  • принадлежит ли заказ тому пользователю, которому собираются выдать право.

Настоящий successful_payment с корректным секретом всё равно может прийти повторно, и никакой заголовок это не отменит. А ошибка в вашей логике сопоставления заказов превратит легитимный платёж в выдачу не тому человеку. Поэтому секрет и бизнес-проверки не заменяют друг друга: первый отсекает посторонних, вторые защищают от собственных ошибок и от повторов.

У polling этой проблемы входящего адреса нет, потому что вы сами идёте в Telegram. Но бизнес-проверки там нужны ровно в том же объёме.

После простоя: что вернётся, а что уже нет

Теперь о самом неприятном вопросе: что будет, если бот лежал не секунды, а часы или сутки. Здесь надо помнить факт, который мы уже упоминали: обновления на стороне Telegram хранятся ограниченное время. Точный срок смотрите в документации, но принцип таков: если простой превысил это окно, неподобранные обновления могут исчезнуть, и ни один транспорт их не вернёт.

Как это выглядит в двух режимах.

Webhook. Пока эндпоинт недоступен, Telegram повторяет доставку. Но повторы не вечны и накапливаются очередью, которую после восстановления нужно разобрать. Если сервис поднялся и сразу получил поток накопленных запросов, он должен выдержать нагрузку, иначе новые ошибки породят новые повторы. Полезно заранее знать, что принимающий код быстрый: вставка строки и ответ.

Long polling. Обновления копятся на стороне Telegram, пока вы не придёте. После старта процесс забирает их пачками. Здесь удобнее контролировать темп: вы сами решаете, как быстро читать. Но надо следить, чтобы сохранённая позиция не потерялась вместе с диском или контейнером, а второй экземпляр не конкурировал за обновления.

Вывод для платежей одинаков: окно хранения нельзя считать страховкой. Если важно не потерять ни одной оплаты, понадобится вторая линия обороны, не зависящая от доставки обновлений. Идея простая: хранить собственные заказы в состоянии «ожидает оплаты» и периодически разбирать те, которые слишком долго не получили подтверждения, сверяя их с тем, что известно о платеже. Конкретные методы сверки зависят от платёжной интеграции, и мы не будем утверждать, что какая-то из них существует, не имея на то документации. Зато сама логика очевидна: есть заказ без события — есть повод разобраться вручную или автоматически.

Эта сверка полезна не только после аварий. Она ловит и обычные расхождения: пользователь оплатил и закрыл чат, выкатка пришлась на момент платежа, ошибка сопоставления случилась в одном из тысячи случаев.

Ничто не гарантирует «ровно один раз»

Стоит прямо назвать то, что часто обещают маркетинговые тексты о транспортах: ни webhook, ни polling не дают доставки «ровно один раз». Повторная доставка при неуспешном ответе — это как раз свойство «как минимум один раз». Позиция чтения при polling тоже оставляет окно, в котором обновление может быть получено повторно после аварии. А между этими крайностями лежит случай, когда подтверждение сработало раньше записи, и событие пропало совсем.

Поэтому честная формулировка такая: транспорт обеспечивает доставку в пределах своих правил, а «ровно один раз» вы получаете сами — комбинацией записи до подтверждения и уникального ключа платежа. Это и есть настоящая граница: по одну сторону транспорт и его настройки, по другую ваша таблица событий и логика выдачи.

Как выбирать транспорт по условиям эксплуатации

Раз платёжная защита одинакова в обоих режимах, выбор транспорта становится вопросом удобства и условий, в которых бот живёт. Вот на что стоит смотреть.

  • Есть ли публичный HTTPS-адрес и кто за него отвечает. Если у вас уже есть сервер с доменом, сертификатом и обратным прокси, webhook обходится дёшево. Если нет, polling избавит от этой части инфраструктуры.
  • Сколько экземпляров бота работает. Один процесс с polling проще всего. Несколько экземпляров за балансировщиком естественнее ложатся на webhook, но тогда запись события и уникальный ключ становятся обязательными с первого дня, потому что один и тот же повтор может попасть на разные экземпляры.
  • Как часто вы выкатываете обновления. При частых перезапусках webhook выигрывает тем, что короткая недоступность превращается в повтор доставки. При polling выкатку тоже можно сделать мягкой, но придётся аккуратно сохранять позицию и останавливать старый экземпляр до запуска нового.
  • Нагрузка и всплески. Webhook присылает события сам, и принимающая сторона должна уметь выдержать пик. Polling даёт вам регулятор: вы читаете в своём темпе.
  • Среда разработки. Локально и в стенде polling обычно удобнее, потому что не нужен туннель. Но помните про взаимное исключение: нельзя одновременно держать для того же бота webhook на проде и polling на стенде.

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

Если коротко о типичных ситуациях: небольшой платный бот с одним процессом и редкими выкатками прекрасно живёт на polling. Сервис с несколькими экземплярами, постоянным доменом и регулярными релизами обычно выбирает webhook. Об общей разнице подходов мы подробнее писали в статье «Webhook или polling для Telegram-бота: что выбрать», здесь же важнее другое: какой бы вариант вы ни взяли, защита платежа не меняется.

Где всё это запускать

Платёжному боту нужно место, где процесс работает постоянно, база лежит на диске, а сертификат и домен под вашим управлением. Для такой связки подойдёт обычный VPS: у MAATRIX это серверы с NVMe, AMD EPYC и root-доступом, в публичном предложении указаны локации UK, US, RU и FR. Root-доступ нужен, чтобы настроить обратный прокси для webhook, поставить базу и поднять отдельного воркера выдачи.

По ресурсам платёжный бот обычно не требователен: приём события — это короткая вставка в базу. Начальный тариф Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) выглядит разумной отправной точкой для небольшого бота с базой, а более крупные тарифы, например Uab (2 vCPU, 4 ГБ, 80 ГБ) или Heka (4 vCPU, 8 ГБ, 160 ГБ), оставляют запас под воркеры и рост таблиц. Эти цифры описывают ресурсы, а не обещание ёмкости: сколько платежей в минуту вы выдержите, покажет только замер на вашем коде.

Оплатить VPS можно картой РФ или через СБП, а сервер создаётся автоматически через пару минут после оплаты, поэтому поднять отдельный стенд с отдельным ботом для проверки перезапусков несложно. Автоматические резервные копии в описании тарифов не обещаны, поэтому копии базы с таблицей платёжных событий настраивайте сами: именно эта таблица хранит ваше право отвечать на вопрос «кто и что оплатил».

И напоследок — упражнение, которое стоит провести до релиза. Запустите тестовый платёж на стенде и убейте процесс в трёх точках: до записи события, после записи до подтверждения и после подтверждения до выдачи. Если в каждом из трёх случаев в итоге пользователь получил право ровно один раз, ваш бот готов к платежам, а выбор между webhook и polling можно делать по привычке и удобству эксплуатации.