Приём криптоплатежей в Telegram-боте на aiogram

Бот на aiogram 3 выставляет счёт Moonvex по команде /buy и присылает кнопку оплаты, а сервер на aiohttp в том же процессе принимает webhook, проверяет подпись и сообщает клиенту об оплате. Для ботов с балансом — постоянный адрес пополнения.

Пошагово

  1. 1.Получите ключ API и секрет webhook

    В кабинете Moonvex откройте «Интеграция» → «Создать ключ» → тип «Секретный» (sk_live_…). Ключ показывается один раз — сохраните его на сервере в MOONVEX_SECRET_KEY. Там же добавьте webhook на https-адрес вашего сервера и сохраните секрет подписи в MOONVEX_WEBHOOK_SECRET. Для бота нужен ещё BOT_TOKEN от @BotFather.

    pip install aiogram httpx asyncpg
    
    export BOT_TOKEN=123456:ABC...
    export MOONVEX_SECRET_KEY=sk_live_...
    export MOONVEX_WEBHOOK_SECRET=whsec_...
    export DATABASE_URL=postgresql://user:pass@localhost:5432/bot
  2. 2.Выставьте счёт по команде /buy

    Хэндлер создаёт платёж через httpx.AsyncClient: orderId из чата и сообщения, customerId — id пользователя Telegram, chatId — в metadata. Metadata возвращается в webhook, и бот знает, кому написать.

    # bot.py
    import os
    
    import httpx
    from aiogram import Bot, Dispatcher
    from aiogram.filters import Command
    from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message
    
    bot = Bot(os.environ["BOT_TOKEN"])
    dp = Dispatcher()
    moonvex = httpx.AsyncClient(
        base_url="https://api.moonvex.org",
        headers={"Authorization": "Bearer " + os.environ["MOONVEX_SECRET_KEY"]},
        timeout=15,
    )
    
    
    @dp.message(Command("buy"))
    async def buy(message: Message) -> None:
        # уникальный номер заказа: чат + сообщение
        order_id = f"tg-{message.chat.id}-{message.message_id}"
        r = await moonvex.post(
            "/v1/payments",
            headers={"Idempotency-Key": order_id + "-1"},
            json={
                "orderId": order_id,
                "amount": "10",
                "currency": "USDT",
                "network": "TRON",
                "customerId": str(message.from_user.id),
                "metadata": {"chatId": message.chat.id},  # вернётся в webhook
            },
        )
        payment = r.json()
        if r.status_code >= 400:
            await message.answer("Не удалось создать счёт, попробуйте позже")
            return
        kb = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Оплатить", url=payment["paymentUrl"])]])
        await message.answer(f"К оплате: {payment['amount']} {payment['asset']} ({payment['network']})", reply_markup=kb)
  3. 3.Отправьте кнопку оплаты

    paymentUrl — готовая страница оплаты с суммой, адресом и QR-кодом; откройте её inline-кнопкой с url. Она открывается в браузере, встраивать её во фрейм нельзя. Показывайте клиенту amount из ответа — это итог с комиссией.

  4. 4.Примите webhook и проверьте подпись

    Moonvex шлёт POST с заголовком Moonvex-Signature: t=<unix-время>,v1=<подпись>. Подпись — HMAC-SHA256 секретом webhook от строки «t.сырое_тело» в hex. Считайте её по байтам тела до разбора JSON, сравнивайте за постоянное время, отклоняйте события старше 5 минут. После смены секрета 24 часа в заголовке два v1 — подходит любой. aiohttp читает сырое тело через await request.read(). Webhook-сервер слушает свой порт, бот работает через polling; снаружи нужен https — например, nginx перед портом 8080.

    # bot.py (продолжение)
    import asyncio
    import hashlib
    import hmac
    import json
    import time
    
    import asyncpg
    from aiohttp import web
    
    
    def verify_moonvex(raw: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
        parts = [p.split("=", 1) for p in (header or "").split(",") if "=" in p]
        t = next((v for k, v in parts if k == "t"), None)
        if t is None or not t.isdigit() or abs(time.time() - int(t)) > tolerance:
            return False
        expected = hmac.new(secret.encode(), t.encode() + b"." + raw, hashlib.sha256).hexdigest()
        return any(hmac.compare_digest(expected, v) for k, v in parts if k == "v1")
    
    
    async def moonvex_webhook(request: web.Request) -> web.Response:
        raw = await request.read()  # сырые байты
        if not verify_moonvex(raw, request.headers.get("Moonvex-Signature", ""), os.environ["MOONVEX_WEBHOOK_SECRET"]):
            return web.Response(status=400, text="bad signature")
        event = json.loads(raw)
        if event["type"] in ("payment.confirmed", "payment.overpaid") and await claim_event(request.app["db"], event["id"]):
            chat_id = event["data"]["metadata"]["chatId"]
            await bot.send_message(chat_id, "Оплата получена, спасибо! Заказ " + event["data"]["orderId"])
        return web.Response(text="ok")
    
    
    async def main() -> None:
        app = web.Application()
        app["db"] = await asyncpg.create_pool(os.environ["DATABASE_URL"])
        app.router.add_post("/moonvex/webhook", moonvex_webhook)
        runner = web.AppRunner(app)
        await runner.setup()
        await web.TCPSite(runner, "0.0.0.0", 8080).start()  # за nginx с https
        await dp.start_polling(bot)
    
    
    if __name__ == "__main__":
        asyncio.run(main())
  5. 5.Отметьте заказ оплаченным ровно один раз

    При повторной доставке id события тот же, поэтому запишите его в таблицу с уникальным ключом в одной транзакции с обновлением заказа: дубль ничего не изменит. Выдавайте товар по payment.confirmed и payment.overpaid, не по payment.detected. claim_event вставляет id события с ON CONFLICT DO NOTHING и возвращает True только для первой доставки — сообщение не придёт дважды.

    # bot.py (продолжение)
    # CREATE TABLE processed_events (event_id text PRIMARY KEY, created_at timestamptz NOT NULL DEFAULT now());
    
    
    async def claim_event(db: asyncpg.Pool, event_id: str) -> bool:
        """True только для первой доставки события"""
        fresh = await db.fetchval(
            "INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING event_id",
            event_id,
        )
        return fresh is not None
  6. 6.Пополнение баланса через постоянный адрес

    Если в боте есть внутренний баланс, выдайте пользователю постоянный депозитный адрес: POST /v1/deposit-addresses с customerId и network. Повторный вызов вернёт тот же адрес. Каждое поступление приходит событием deposit.confirmed с customerId и receivedAmount — зачисляйте его один раз на event id. Ключу нужно право wallets:write.

    # bot.py (продолжение) — нужно право ключа wallets:write
    @dp.message(Command("topup"))
    async def topup(message: Message) -> None:
        r = await moonvex.post(
            "/v1/deposit-addresses",
            json={"customerId": str(message.from_user.id), "network": "TRON"},
        )
        if r.status_code >= 400:
            await message.answer("Не удалось получить адрес, попробуйте позже")
            return
        # тот же адрес при каждом вызове: клиент может пополнять его когда угодно
        await message.answer("Ваш адрес для пополнения (USDT или TRX, сеть TRON):\n" + r.json()["address"])
    
    
    # в moonvex_webhook: зачисление на баланс клиента, один раз на event id
    if event["type"] == "deposit.confirmed" and await claim_event(request.app["db"], event["id"]):
        d = event["data"]
        await credit_balance(d["customerId"], d["asset"], d["receivedAmount"])  # ваша функция
        await bot.send_message(int(d["customerId"]), "Баланс пополнен: " + d["receivedAmount"] + " " + d["asset"])

Подводные камни

  • Не отмечайте оплату по нажатию кнопки или возврату в бот — только по webhook payment.confirmed / payment.overpaid.
  • Сохраняйте chat_id в metadata: значения metadata — строки, числа, true/false или null.
  • Если отправка сообщения после claim_event упала, событие уже отмечено. Для критичных действий ставьте их в свою очередь в той же транзакции.
  • Тестовой сети нет — проверяйте бота платежом на 1 USDT в TRON и кнопкой «Отправить тест» в кабинете.

Частые вопросы

Нужен ли webhook, если бот работает через polling?

Да. Polling — это получение обновлений от Telegram, а о платежах сообщает Moonvex своим webhook на ваш https-адрес. В примере оба работают в одном процессе.

Это Telegram Payments или Stars?

Нет. Клиент платит криптовалютой на странице оплаты Moonvex, деньги приходят на баланс вашего проекта в Moonvex. Платёжный провайдер Telegram не нужен.

Что делать, если клиент не успел оплатить?

Платёж перейдёт в EXPIRED и придёт payment.expired. По тому же orderId можно создать новый платёж — с новым Idempotency-Key (например, увеличьте номер попытки в конце ключа). Срок оплаты задаётся в expiresInMinutes: от 5 до 1440 минут, по умолчанию 30.

Другие гайды

Начните принимать оплату сегодня

Аккаунт и первый проект за минуту, без абонентской платы.

Создать аккаунт