В Bot API 10.2 появилось отдельное событие для изменений в регулярной подписке — Update.subscription.

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

Внутри Update.subscription находится объект BotSubscriptionUpdated. У него есть пользователь, invoice_payload и одно из трёх состояний:

  • canceled — пользователь отключил продление;

  • active — снова включил ранее отменённое продление;

  • failed — очередной платёж не прошёл.

Первая версия обработчика напрашивается сама:

# Упрощённый псевдокод. Так делать не нужно.
async def handle_subscription(event):
    if event.state in {"canceled", "failed"}:
        await revoke_access(event.user.id)

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

canceled относится к следующему платежу

Допустим, в последнем successful_payment указано, что доступ оплачен до 30 сентября, 12:00 UTC. Если пользователь 2 сентября отключит продление, Telegram пришлёт canceled. Но до 30 сентября услуга уже оплачена — удалять человека из канала или группы нельзя.

Telegram придерживается той же логики в методе editUserStarSubscription: после отмены продления подписка должна оставаться активной до конца текущего периода.

Состояние failed тоже само по себе не является командой на удаление. Оно сообщает, что попытка списания не удалась. Дальнейшее решение зависит от access_until и правил вашего продукта: закончился ли оплаченный период и предусмотрена ли собственная отсрочка.

Я бы разделил данные минимум на три части:

subscriptions
- id
- payer_user_id
- beneficiary_user_id
- access_until
- auto_renew
- payment_issue

payments
- telegram_payment_charge_id  UNIQUE
- subscription_id
- expiration_date
- processed_at

processed_updates
- update_id  UNIQUE

payer_user_id и beneficiary_user_id могут совпадать, но разделение пригодится, если один человек оплачивает доступ другому.

Событие BotSubscriptionUpdated должно менять только информацию о продлении. Ниже — упрощённый псевдокод обработчика, который вызывается уже внутри последовательной очереди конкретной подписки:

async def apply_subscription_state(update):
    event = update.subscription
    subscription_id = parse_subscription_id(event.invoice_payload)

    async with db.transaction():
        claimed = await processed_updates.insert_once(update.update_id)
        if not claimed:
            return

        match event.state:
            case "canceled":
                await subscriptions.set_auto_renew(subscription_id, False)
            case "active":
                await subscriptions.set_auto_renew(subscription_id, True)
            case "failed":
                await subscriptions.mark_payment_issue(subscription_id)

Если вебхуки обрабатывают несколько воркеров, одной проверки на дубликат мало: события одной подписки лучше пропускать через последовательную очередь. Иначе более старое active теоретически может перезаписать более новое canceled. update_id помогает отбрасывать повторы и восстанавливать порядок полученных обновлений.

Срок доступа меняется после подтверждённого платежа

Для регулярного платежа Telegram передаёт в SuccessfulPayment поле subscription_expiration_date. Оно необязательное, поэтому сначала нужно убедиться, что платёж действительно относится к подписке.

# Упрощённый псевдокод.
async def handle_successful_payment(message):
    payment = message.successful_payment

    if not payment.is_recurring:
        return

    if payment.subscription_expiration_date is None:
        return

    subscription_id = parse_subscription_id(payment.invoice_payload)

    async with db.transaction():
        inserted = await payments.insert_once(
            charge_id=payment.telegram_payment_charge_id,
            subscription_id=subscription_id,
            expiration_date=payment.subscription_expiration_date,
        )

        if not inserted:
            return

        await subscriptions.extend_to_at_least(
            subscription_id,
            payment.subscription_expiration_date,
        )
        await subscriptions.clear_payment_issue(subscription_id)

insert_once здесь подразумевает уникальность telegram_payment_charge_id, а extend_to_at_least — обновление через максимальную из двух дат. Поэтому повторный или задержавшийся старый вебхук не создаст вторую транзакцию и не сократит уже выданный срок.

Возвраты — отдельный сценарий. Событие refunded_payment нужно обрабатывать своей веткой: отмена автопродления и возврат уже проведённого платежа — разные операции.

Почему в invoice_payload нужен идентификатор подписки

В BotSubscriptionUpdated нет telegram_payment_charge_id. Для связи события с записью в базе остаются пользователь и invoice_payload.

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

invoice_payload = f"subscription:{subscription_id}"

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

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

При проектировании логики это разделение принципиально: состояние автоплатежа и право доступа — не одно и то же. canceled не должен немедленно исключать участника из канала или группы.

В итоге правило получилось таким:

canceled меняет будущее списание. successful_payment продлевает доступ. Возврат обрабатывается отдельно.

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

Кто уже принимает Update.subscription: вы храните биллинг и право доступа отдельно или пока укладываете всё в один статус?