Приймання криптоплатежів у Django

Як приймати криптовалюту в проєкті на Django: view створює платіж Moonvex і надсилає клієнта на сторінку оплати, окремий view приймає webhook, перевіряє підпис за request.body і позначає замовлення оплаченим один раз.

Покроково

  1. 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. 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. 3.Надішліть клієнта на paymentUrl

    paymentUrl — готова сторінка оплати: точна сума з комісією, адреса, QR-код, офіційний контракт токена й таймер, статус оновлюється сам. Відкривайте її редиректом або в новій вкладці — вбудувати у фрейм не можна. Повернення клієнта на ваш сайт ще не оплата: замовлення позначається оплаченим лише за webhook або статусом із GET /v1/payments/:id. У Django це redirect(payment["paymentUrl"]).

  4. 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. 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. 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.

Часті запитання

Чи підійде Django REST Framework?

Так, але для webhook візьміть request.body до звернення до request.data, вимкніть автентифікацію і CSRF для цього view (authentication_classes = [] і permission_classes = [AllowAny]).

Чому amount у відповіді більший за ціну замовлення?

Комісію Moonvex клієнт сплачує зверху, тому amount — підсумок до сплати. Ваша ціна залишається в pricing.amount, ваш дохід за повної оплати — у merchantAmount. Звіряйте замовлення за orderId і pricing.amount, а клієнтові показуйте amount.

Що робити, якщо клієнт не встиг оплатити?

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

Інші гайди

Почніть приймати оплату вже сьогодні

Обліковий запис і перший проєкт за хвилину, без абонентської плати.

Створити обліковий запис