Accept crypto payments in Django

How to accept crypto in a Django project: a view creates the Moonvex payment and sends the customer to the payment page, and a separate view receives the webhook, verifies the signature over request.body and marks the order paid once.

Step by step

  1. 1.Get an API key and a webhook secret

    In the Moonvex dashboard go to “Integration” → “Create key” → type “Secret” (sk_live_…). The key is shown only once: store it on your server as MOONVEX_SECRET_KEY. On the same page add a webhook pointing to an https URL on your server and store its signing secret as MOONVEX_WEBHOOK_SECRET. In Django keep them in settings, read from environment variables. A ProcessedEvent model keyed by event_id protects against double processing.

    # 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):
        # the Moonvex event id is the primary key, so a duplicate is impossible
        event_id = models.CharField(max_length=64, primary_key=True)
        created_at = models.DateTimeField(auto_now_add=True)
  2. 2.Create a payment in a view

    The pay view accepts only POST from a signed-in user, creates a payment with str(order.total) as the amount (a DecimalField gives a string like 25.00 with no precision loss) and saves the payment id on the order.

    # 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", no 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.Send the customer to paymentUrl

    paymentUrl is a hosted payment page: the exact amount including the fee, the address, a QR code, the official token contract and a timer, with a status that updates by itself. Open it with a redirect or in a new tab; it cannot be embedded in an iframe. A customer returning to your site is not proof of payment: mark the order paid only from a webhook or the status from GET /v1/payments/:id. In Django: redirect(payment["paymentUrl"]).

  4. 4.Receive the webhook and verify the signature

    Moonvex sends a POST with a Moonvex-Signature header: t=<unix time>,v1=<signature>. The signature is a hex HMAC-SHA256 of the string “t.raw_body” keyed with the webhook secret. Compute it over the body bytes before parsing JSON, compare in constant time and reject events older than 5 minutes. For 24 hours after a secret rotation the header carries two v1 values; either one is valid. The webhook view is decorated with @csrf_exempt: Moonvex sends no CSRF token, the signature is the protection. The raw body is 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 has no CSRF token; the HMAC signature is the protection
    @require_POST
    def moonvex_webhook(request):
        raw = request.body  # raw bytes; do not touch request.POST before this
        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.Mark the order paid exactly once

    A redelivered event keeps the same id, so store it in a table with a unique key in the same transaction as the order update: a duplicate then changes nothing. Fulfil on payment.confirmed and payment.overpaid, never on payment.detected. get_or_create inside transaction.atomic: if the row already exists, this is a redelivery and the order is left alone.

    # 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  # a redelivery: do nothing
            Order.objects.filter(pk=int(order_id)).exclude(status=Order.Status.PAID).update(status=Order.Status.PAID)
  6. 6.Test the integration

    Moonvex has no test network: everything runs on mainnets. The “Send test” button under “Integration” sends a webhook.test event, which is how you check the signature. Then pay a small invoice, for example 1 USDT on TRON: it is a regular payment and the money lands on your balance. The delivery log with response codes and a “Retry” button is on the same page.

Pitfalls

  • Without @csrf_exempt Django answers every webhook with 403, and Moonvex retries a 4xx only 3 times.
  • Do not touch request.POST before reading request.body: you do not need it for application/json, and the stream read order matters.
  • Send heavy work (emails, granting access) to Celery after responding 200; you have 10 seconds to respond.
  • Use DecimalField for prices and str() when sending. A float from FloatField gives amounts like 24.999999.

FAQ

Does this work with Django REST Framework?

Yes, but in the webhook read request.body before touching request.data, and disable authentication and CSRF for that view (authentication_classes = [] and permission_classes = [AllowAny]).

Why is amount in the response higher than the order price?

The customer pays the Moonvex fee on top, so amount is the total due. Your price stays in pricing.amount, and what you receive on a full payment is in merchantAmount. Reconcile orders by orderId and pricing.amount, and show the customer amount.

What if the customer does not pay in time?

The payment moves to EXPIRED and you receive payment.expired. You can create a new payment for the same orderId with a new Idempotency-Key (for example, bump the attempt number at the end of the key). The payment window is set with expiresInMinutes: 5 to 1440 minutes, 30 by default.

More guides

Start accepting payments today

An account and your first project in a minute, no monthly fee.

Create account