Приём криптоплатежей в Django
Как принимать криптовалюту в проекте на Django: view создаёт платёж Moonvex и отправляет клиента на страницу оплаты, отдельный view принимает webhook, проверяет подпись по request.body и отмечает заказ оплаченным один раз.
Пошагово
1.Получите ключ API и секрет webhook
В кабинете Moonvex откройте «Интеграция» → «Создать ключ» → тип «Секретный» (sk_live_…). Ключ показывается один раз — сохраните его на сервере в MOONVEX_SECRET_KEY. Там же добавьте webhook на https-адрес вашего сервера и сохраните секрет подписи в MOONVEX_WEBHOOK_SECRET. В Django храните их в settings из переменных окружения. Модель ProcessedEvent с первичным ключом event_id защитит от двойной обработки.
# settings.py import os MOONVEX_SECRET_KEY = os.environ["MOONVEX_SECRET_KEY"] # sk_live_... MOONVEX_WEBHOOK_SECRET = os.environ["MOONVEX_WEBHOOK_SECRET"] # whsec_... # shop/models.py from django.db import models class Order(models.Model): class Status(models.TextChoices): NEW = "new" PAID = "paid" user = models.ForeignKey("auth.User", on_delete=models.PROTECT) total = models.DecimalField(max_digits=18, decimal_places=2) status = models.CharField(max_length=16, choices=Status.choices, default=Status.NEW) moonvex_payment_id = models.CharField(max_length=64, blank=True) class ProcessedEvent(models.Model): # id события Moonvex — первичный ключ, дубль невозможен event_id = models.CharField(max_length=64, primary_key=True) created_at = models.DateTimeField(auto_now_add=True)2.Создайте платёж во view
View pay принимает только POST от авторизованного пользователя, создаёт платёж с суммой str(order.total) — DecimalField даёт строку вида 25.00 без потери точности — и сохраняет id платежа в заказе.
# shop/views.py import requests from django.conf import settings from django.contrib.auth.decorators import login_required from django.shortcuts import get_object_or_404, redirect from django.views.decorators.http import require_POST from .models import Order @login_required @require_POST def pay(request, order_id: int): order = get_object_or_404(Order, pk=order_id, user=request.user, status=Order.Status.NEW) r = requests.post( "https://api.moonvex.org/v1/payments", headers={ "Authorization": "Bearer " + settings.MOONVEX_SECRET_KEY, "Idempotency-Key": f"order-{order.pk}-1", }, json={ "orderId": str(order.pk), "amount": str(order.total), # Decimal → "25.00", без float "currency": "USDT", "network": "TRON", }, timeout=15, ) payment = r.json() if r.status_code >= 400: raise RuntimeError(payment["error"]["code"] + ": " + payment["error"]["message"]) Order.objects.filter(pk=order.pk).update(moonvex_payment_id=payment["id"]) return redirect(payment["paymentUrl"])3.Отправьте клиента на paymentUrl
paymentUrl — готовая страница оплаты: точная сумма с комиссией, адрес, QR-код, официальный контракт токена и таймер, статус обновляется сам. Открывайте её редиректом или в новой вкладке — встроить во фрейм нельзя. Возврат клиента на ваш сайт ещё не оплата: заказ отмечается оплаченным только по webhook или статусу из GET /v1/payments/:id. В Django это redirect(payment["paymentUrl"]).
4.Примите webhook и проверьте подпись
Moonvex шлёт POST с заголовком Moonvex-Signature: t=<unix-время>,v1=<подпись>. Подпись — HMAC-SHA256 секретом webhook от строки «t.сырое_тело» в hex. Считайте её по байтам тела до разбора JSON, сравнивайте за постоянное время, отклоняйте события старше 5 минут. После смены секрета 24 часа в заголовке два v1 — подходит любой. View webhook помечен @csrf_exempt: Moonvex не присылает CSRF-токен, защиту обеспечивает подпись. Сырое тело — request.body.
# shop/webhooks.py import hashlib import hmac import json import time from django.conf import settings from django.http import HttpResponse, HttpResponseBadRequest from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST from .payments import mark_paid_once 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") @csrf_exempt # у Moonvex нет CSRF-токена; защита — подпись HMAC @require_POST def moonvex_webhook(request): raw = request.body # сырые байты; не читайте request.POST до этого if not verify_moonvex(raw, request.headers.get("Moonvex-Signature", ""), settings.MOONVEX_WEBHOOK_SECRET): return HttpResponseBadRequest("bad signature") event = json.loads(raw) if event["type"] in ("payment.confirmed", "payment.overpaid"): mark_paid_once(event["id"], event["data"]["orderId"]) return HttpResponse("ok") # urls.py # path("orders/<int:order_id>/pay/", views.pay), # path("moonvex/webhook/", webhooks.moonvex_webhook),5.Отметьте заказ оплаченным ровно один раз
При повторной доставке id события тот же, поэтому запишите его в таблицу с уникальным ключом в одной транзакции с обновлением заказа: дубль ничего не изменит. Выдавайте товар по payment.confirmed и payment.overpaid, не по payment.detected. get_or_create внутри transaction.atomic: если запись уже есть, это повторная доставка и заказ не трогаем.
# shop/payments.py from django.db import transaction from .models import Order, ProcessedEvent def mark_paid_once(event_id: str, order_id: str) -> None: with transaction.atomic(): _, created = ProcessedEvent.objects.get_or_create(event_id=event_id) if not created: return # повторная доставка — ничего не делаем Order.objects.filter(pk=int(order_id)).exclude(status=Order.Status.PAID).update(status=Order.Status.PAID)6.Проверьте интеграцию
Тестовой сети у Moonvex нет — всё работает в основных сетях. Кнопка «Отправить тест» в разделе «Интеграция» пришлёт событие webhook.test — так проверяется подпись. Затем оплатите небольшой счёт, например 1 USDT в TRON: это обычный платёж, деньги придут на баланс. Журнал доставок с кодами ответов и кнопкой «Повторить» — там же.
Подводные камни
- Без @csrf_exempt Django ответит 403 на каждый webhook, а 4xx Moonvex повторяет только 3 раза.
- Не обращайтесь к request.POST до чтения request.body — для application/json он не нужен, а порядок чтения потока важен.
- Тяжёлую обработку (письма, выдачу доступа) отправляйте в Celery после ответа 200 — на ответ есть 10 секунд.
- Используйте DecimalField для цен и str() при отправке. float из FloatField даёт суммы вроде 24.999999.