Приём криптоплатежей на Python
Минимальная интеграция Moonvex на Python с Flask и requests: создаём счёт, отправляем клиента на страницу оплаты, принимаем webhook и проверяем подпись HMAC-SHA256 стандартной библиотекой. Подходит для любого WSGI-приложения.
Пошагово
1.Получите ключ API и секрет webhook
В кабинете Moonvex откройте «Интеграция» → «Создать ключ» → тип «Секретный» (sk_live_…). Ключ показывается один раз — сохраните его на сервере в MOONVEX_SECRET_KEY. Там же добавьте webhook на https-адрес вашего сервера и сохраните секрет подписи в MOONVEX_WEBHOOK_SECRET.
pip install flask requests # переменные окружения на сервере export MOONVEX_SECRET_KEY=sk_live_... export MOONVEX_WEBHOOK_SECRET=whsec_...2.Создайте платёж
POST /v1/payments через requests: в заголовках Authorization: Bearer и Idempotency-Key, в теле orderId, amount строкой, currency и network. Ответ 201 — объект платежа с id и paymentUrl. Сохраните id у себя рядом с заказом.
# app.py import os import sqlite3 from contextlib import closing import requests from flask import Flask, abort, redirect app = Flask(__name__) API = "https://api.moonvex.org" def db() -> sqlite3.Connection: conn = sqlite3.connect("shop.db") conn.row_factory = sqlite3.Row return conn def create_payment(order_id: int, amount: str, attempt: int = 1) -> dict: r = requests.post( API + "/v1/payments", headers={ "Authorization": "Bearer " + os.environ["MOONVEX_SECRET_KEY"], # повтор с тем же ключом не создаст второй счёт "Idempotency-Key": f"order-{order_id}-{attempt}", }, json={"orderId": str(order_id), "amount": amount, "currency": "USDT", "network": "TRON"}, timeout=15, ) payment = r.json() if r.status_code >= 400: raise RuntimeError(payment["error"]["code"] + ": " + payment["error"]["message"]) return payment @app.post("/orders/<int:order_id>/pay") def pay(order_id: int): with closing(db()) as conn: order = conn.execute("SELECT id, total FROM orders WHERE id = ?", (order_id,)).fetchone() if order is None: abort(404) # total хранится как TEXT "25.00" — сумма уходит строкой payment = create_payment(order["id"], str(order["total"])) return redirect(payment["paymentUrl"], code=303)3.Отправьте клиента на paymentUrl
paymentUrl — готовая страница оплаты: точная сумма с комиссией, адрес, QR-код, официальный контракт токена и таймер, статус обновляется сам. Открывайте её редиректом или в новой вкладке — встроить во фрейм нельзя. Возврат клиента на ваш сайт ещё не оплата: заказ отмечается оплаченным только по webhook или статусу из GET /v1/payments/:id. Во Flask — redirect(payment["paymentUrl"], code=303).
4.Примите webhook и проверьте подпись
Moonvex шлёт POST с заголовком Moonvex-Signature: t=<unix-время>,v1=<подпись>. Подпись — HMAC-SHA256 секретом webhook от строки «t.сырое_тело» в hex. Считайте её по байтам тела до разбора JSON, сравнивайте за постоянное время, отклоняйте события старше 5 минут. После смены секрета 24 часа в заголовке два v1 — подходит любой. Во Flask сырое тело — request.get_data(), а hmac.compare_digest сравнивает подписи за постоянное время.
# moonvex_signature.py import hashlib import hmac import time def verify_moonvex(raw: bytes, header: str, secret: str, tolerance: int = 300) -> bool: """Проверяет Moonvex-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, t + "." + raw)>""" 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() # compare_digest — сравнение за постоянное время; при смене секрета v1 два return any(hmac.compare_digest(expected, v) for k, v in parts if k == "v1") # app.py (продолжение) import json from flask import request from moonvex_signature import verify_moonvex @app.post("/moonvex/webhook") def moonvex_webhook(): raw = request.get_data() # сырые байты, до разбора JSON if not verify_moonvex(raw, request.headers.get("Moonvex-Signature", ""), os.environ["MOONVEX_WEBHOOK_SECRET"]): return "bad signature", 400 event = json.loads(raw) if event["type"] in ("payment.confirmed", "payment.overpaid"): mark_paid_once(event["id"], event["data"]["orderId"]) return "", 2005.Отметьте заказ оплаченным ровно один раз
При повторной доставке id события тот же, поэтому запишите его в таблицу с уникальным ключом в одной транзакции с обновлением заказа: дубль ничего не изменит. Выдавайте товар по payment.confirmed и payment.overpaid, не по payment.detected. В SQLite это INSERT OR IGNORE и проверка cursor.rowcount в одной транзакции.
# app.py (продолжение) # CREATE TABLE processed_events (event_id TEXT PRIMARY KEY, created_at TEXT DEFAULT CURRENT_TIMESTAMP); def mark_paid_once(event_id: str, order_id: str) -> None: # второй "conn" — транзакция: commit при успехе, rollback при ошибке with closing(db()) as conn, conn: cur = conn.execute("INSERT OR IGNORE INTO processed_events (event_id) VALUES (?)", (event_id,)) if cur.rowcount == 1: # первая доставка события conn.execute("UPDATE orders SET status = 'paid' WHERE id = ? AND status <> 'paid'", (int(order_id),))6.Проверьте интеграцию
Тестовой сети у Moonvex нет — всё работает в основных сетях. Кнопка «Отправить тест» в разделе «Интеграция» пришлёт событие webhook.test — так проверяется подпись. Затем оплатите небольшой счёт, например 1 USDT в TRON: это обычный платёж, деньги придут на баланс. Журнал доставок с кодами ответов и кнопкой «Повторить» — там же.
Подводные камни
- Используйте request.get_data(), а не request.json: подпись считается по исходным байтам, повторная сериализация их меняет.
- Суммы держите в Decimal или строках. 0.1 + 0.2 во float — не 0.3, а в платежах это ошибка в центах.
- Всегда задавайте timeout в requests.post — без него зависший запрос держит воркер бесконечно.
- Функция verify_moonvex принимает все значения v1 из заголовка — так ротация секрета пройдёт без простоя.