Приём криптоплатежей в FastAPI

Полностью асинхронный приём криптоплатежей в FastAPI: общий httpx.AsyncClient для API Moonvex, редирект на страницу оплаты и webhook, который проверяет подпись по сырому телу и отмечает заказ в PostgreSQL через asyncpg.

Пошагово

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

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

    pip install fastapi uvicorn httpx asyncpg
    
    export MOONVEX_SECRET_KEY=sk_live_...
    export MOONVEX_WEBHOOK_SECRET=whsec_...
    export DATABASE_URL=postgresql://user:pass@localhost:5432/shop
  2. 2.Создайте платёж

    Клиент httpx создаётся один раз в lifespan с базовым адресом и заголовком Authorization. Для каждого платежа передаётся Idempotency-Key, а сумма — str(Decimal) из numeric-колонки.

    # main.py
    import os
    from contextlib import asynccontextmanager
    
    import asyncpg
    import httpx
    from fastapi import FastAPI, HTTPException, Request
    from fastapi.responses import RedirectResponse
    
    
    @asynccontextmanager
    async def lifespan(app: FastAPI):
        app.state.db = await asyncpg.create_pool(os.environ["DATABASE_URL"])
        app.state.moonvex = httpx.AsyncClient(
            base_url="https://api.moonvex.org",
            headers={"Authorization": "Bearer " + os.environ["MOONVEX_SECRET_KEY"]},
            timeout=15,
        )
        yield
        await app.state.moonvex.aclose()
        await app.state.db.close()
    
    
    app = FastAPI(lifespan=lifespan)
    
    
    @app.post("/orders/{order_id}/pay")
    async def pay(order_id: int, request: Request):
        order = await request.app.state.db.fetchrow("SELECT id, total FROM orders WHERE id = $1", order_id)
        if order is None:
            raise HTTPException(404)
        r = await request.app.state.moonvex.post(
            "/v1/payments",
            headers={"Idempotency-Key": f"order-{order_id}-1"},
            # numeric приходит как Decimal → str(): "25.00"
            json={"orderId": str(order_id), "amount": str(order["total"]), "currency": "USDT", "network": "TRON"},
        )
        payment = r.json()
        if r.status_code >= 400:
            raise HTTPException(502, payment["error"]["code"])
        return RedirectResponse(payment["paymentUrl"], status_code=303)
  3. 3.Отправьте клиента на paymentUrl

    paymentUrl — готовая страница оплаты: точная сумма с комиссией, адрес, QR-код, официальный контракт токена и таймер, статус обновляется сам. Открывайте её редиректом или в новой вкладке — встроить во фрейм нельзя. Возврат клиента на ваш сайт ещё не оплата: заказ отмечается оплаченным только по webhook или статусу из GET /v1/payments/:id. RedirectResponse(..., status_code=303) после POST откроет страницу оплаты.

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

    Moonvex шлёт POST с заголовком Moonvex-Signature: t=<unix-время>,v1=<подпись>. Подпись — HMAC-SHA256 секретом webhook от строки «t.сырое_тело» в hex. Считайте её по байтам тела до разбора JSON, сравнивайте за постоянное время, отклоняйте события старше 5 минут. После смены секрета 24 часа в заголовке два v1 — подходит любой. В FastAPI не объявляйте Pydantic-модель для тела webhook: возьмите Request и await request.body() — это исходные байты.

    # main.py (продолжение)
    import hashlib
    import hmac
    import json
    import time
    
    
    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")
    
    
    @app.post("/moonvex/webhook")
    async def moonvex_webhook(request: Request):
        # Request.body(), а не Pydantic-модель: нужны исходные байты
        raw = await request.body()
        if not verify_moonvex(raw, request.headers.get("moonvex-signature", ""), os.environ["MOONVEX_WEBHOOK_SECRET"]):
            raise HTTPException(400, "bad signature")
        event = json.loads(raw)
        if event["type"] in ("payment.confirmed", "payment.overpaid"):
            await mark_paid_once(request.app.state.db, event["id"], event["data"]["orderId"])
        return {"received": True}
  5. 5.Отметьте заказ оплаченным ровно один раз

    При повторной доставке id события тот же, поэтому запишите его в таблицу с уникальным ключом в одной транзакции с обновлением заказа: дубль ничего не изменит. Выдавайте товар по payment.confirmed и payment.overpaid, не по payment.detected. В asyncpg: INSERT … ON CONFLICT DO NOTHING RETURNING внутри conn.transaction(); None означает повторную доставку.

    # main.py (продолжение)
    # CREATE TABLE processed_events (event_id text PRIMARY KEY, created_at timestamptz NOT NULL DEFAULT now());
    
    
    async def mark_paid_once(db: asyncpg.Pool, event_id: str, order_id: str) -> None:
        async with db.acquire() as conn, conn.transaction():
            fresh = await conn.fetchval(
                "INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING event_id",
                event_id,
            )
            if fresh is None:
                return  # событие уже обработано
            await conn.execute("UPDATE orders SET status = 'paid' WHERE id = $1 AND status <> 'paid'", int(order_id))
  6. 6.Проверьте интеграцию

    Тестовой сети у Moonvex нет — всё работает в основных сетях. Кнопка «Отправить тест» в разделе «Интеграция» пришлёт событие webhook.test — так проверяется подпись. Затем оплатите небольшой счёт, например 1 USDT в TRON: это обычный платёж, деньги придут на баланс. Журнал доставок с кодами ответов и кнопкой «Повторить» — там же.

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

  • Pydantic-модель в параметре эндпоинта разбирает и валидирует тело раньше вас — подпись по ней не проверить. Нужен сырой Request.
  • Создавайте httpx.AsyncClient один раз (lifespan), а не на каждый запрос: так переиспользуются соединения.
  • Не используйте синхронный requests внутри async-эндпоинтов — он блокирует цикл событий.
  • Для долгих задач после webhook подойдут BackgroundTasks или очередь — ответ должен уйти за 10 секунд.

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

Нужно ли описывать схемы событий в Pydantic?

Можно, но после проверки подписи: сначала verify_moonvex по сырым байтам, затем Event.model_validate_json(raw). Структура события — id, type, apiVersion, createdAt и data с объектом платежа.

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

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

Есть ли тестовый режим или sandbox?

Нет, тестовой сети нет — Moonvex работает только в основных сетях. Подпись проверяется кнопкой «Отправить тест» (событие webhook.test), а весь путь оплаты — реальным платежом на небольшую сумму, например 1 USDT в TRON.

Другие гайды

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

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

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