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