Приймання криптоплатежів у 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.

Інші гайди

Почніть приймати оплату вже сьогодні

Обліковий запис і перший проєкт за хвилину, без абонентської плати.

Створити обліковий запис