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

Другие гайды

Начните принимать оплату сегодня

Аккаунт и первый проект за минуту, без абонентской платы.

Создать аккаунт