Приём криптоплатежей в FastAPI
Полностью асинхронный приём криптоплатежей в FastAPI: общий httpx.AsyncClient для API Moonvex, редирект на страницу оплаты и webhook, который проверяет подпись по сырому телу и отмечает заказ в PostgreSQL через asyncpg.
Пошагово
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/shop2.Создайте платёж
Клиент 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.Отправьте клиента на paymentUrl
paymentUrl — готовая страница оплаты: точная сумма с комиссией, адрес, QR-код, официальный контракт токена и таймер, статус обновляется сам. Открывайте её редиректом или в новой вкладке — встроить во фрейм нельзя. Возврат клиента на ваш сайт ещё не оплата: заказ отмечается оплаченным только по webhook или статусу из GET /v1/payments/:id. RedirectResponse(..., status_code=303) после POST откроет страницу оплаты.
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.Отметьте заказ оплаченным ровно один раз
При повторной доставке 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.Проверьте интеграцию
Тестовой сети у Moonvex нет — всё работает в основных сетях. Кнопка «Отправить тест» в разделе «Интеграция» пришлёт событие webhook.test — так проверяется подпись. Затем оплатите небольшой счёт, например 1 USDT в TRON: это обычный платёж, деньги придут на баланс. Журнал доставок с кодами ответов и кнопкой «Повторить» — там же.
Подводные камни
- Pydantic-модель в параметре эндпоинта разбирает и валидирует тело раньше вас — подпись по ней не проверить. Нужен сырой Request.
- Создавайте httpx.AsyncClient один раз (lifespan), а не на каждый запрос: так переиспользуются соединения.
- Не используйте синхронный requests внутри async-эндпоинтов — он блокирует цикл событий.
- Для долгих задач после webhook подойдут BackgroundTasks или очередь — ответ должен уйти за 10 секунд.