In this guide you'll add crypto deposits to a Django project with the Valtu Python SDK: a deposit address per user, deposits credited exactly once from signed webhooks, and a management command that reconciles every hour. Django's ORM and transactions do most of the work.
We ran this code with Django's test client: signed webhooks, redeliveries, tampered and stale requests, and the reconcile command.
Before you start
- A Valtu workspace (free on testnets) and an API key with the
operaterole: Developers → API keys → Create key. - A webhook endpoint at
https://your-site/payments/valtu/webhooks/with thedeposit.*events, and the workspace's Ed25519 public key from Developers → Webhooks. - Django 4.2 or later, Python 3.9 or later.
pip install valtu
python manage.py startapp payments
Add "payments" to INSTALLED_APPS, and to settings.py:
import os
VALTU_WEBHOOK_PUBLIC_KEY = os.environ["VALTU_WEBHOOK_PUBLIC_KEY"] # not a secret
VALTU_PRIVATE_KEY (or VALTU_KEY_FILE) is read from the environment by the SDK.
1. Models
from django.db import models
class ValtuEvent(models.Model):
"""Each webhook event once: a redelivery finds its id here and stops."""
id = models.CharField(primary_key=True, max_length=100)
type = models.CharField(max_length=64)
received_at = models.DateTimeField(auto_now_add=True)
class DepositCredit(models.Model):
"""One credit per deposit, ever. Amounts are whole numbers of the coin's smallest unit."""
deposit_id = models.CharField(primary_key=True, max_length=100)
customer_id = models.CharField(max_length=128, db_index=True)
asset = models.CharField(max_length=32) # e.g. USDT-TRC20
amount = models.DecimalField(max_digits=78, decimal_places=0) # 25.5 USDT on Tron is 25500000
tx_hash = models.CharField(max_length=128)
created_at = models.DateTimeField(auto_now_add=True)
Then python manage.py makemigrations payments && python manage.py migrate. Amounts are whole numbers of the coin's smallest unit, as the API sends them. See token decimals.
2. The client and one credit function
from valtu import Valtu
from .models import DepositCredit
valtu = Valtu.from_env() # VALTU_PRIVATE_KEY (or VALTU_KEY_FILE)
# Screening results that mean "don't credit yet" (when you've turned on deposit screening).
NOT_YET = {"pending", "held", "rejected"}
def credit(d: dict) -> None:
"""Credit a confirmed deposit to the customer whose wallet received it, once."""
# walletExternalId is the customer id you passed to wallets.for_customer(). Deposits to your
# own treasury or payout wallets have none: there's nobody to credit.
if d["status"] != "CONFIRMED" or not d.get("walletExternalId") or d.get("screening") in NOT_YET:
return
DepositCredit.objects.get_or_create(
deposit_id=d["id"],
defaults={"customer_id": d["walletExternalId"], "asset": d["asset"], "amount": d["amount"], "tx_hash": d["txHash"]},
)
The deposit's walletExternalId is the id you gave the wallet with wallets.for_customer(): here, the Django user's primary key. Deposits to your own treasury and payout wallets have none, so they're skipped. With deposit screening on, deposits still being reviewed wait until your team releases them.
3. Views
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.db import transaction
from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from valtu import WebhookVerificationError, construct_event, deposit_address
from .models import ValtuEvent
from .valtu_client import credit, valtu
NETWORKS = [
{"chain": "tron", "label": "USDT on Tron (TRC-20)"},
{"chain": "ethereum", "label": "USDT or USDC on Ethereum (ERC-20)"},
{"chain": "base", "label": "USDC on Base"},
]
@login_required
def deposit_addresses(request):
# The signed-in user's own wallet: your user id is the customer id Valtu keeps on it.
wallet = valtu.wallets.for_customer(str(request.user.pk))
return JsonResponse([{**n, "address": deposit_address(wallet, n["chain"])} for n in NETWORKS], safe=False)
@csrf_exempt # Valtu can't send your CSRF token; the signature check below takes its place
@require_POST
def valtu_webhook(request):
try:
event = construct_event(request.body, request.headers, public_key=settings.VALTU_WEBHOOK_PUBLIC_KEY)
except WebhookVerificationError:
return HttpResponseBadRequest("bad signature")
# The event id and the credit commit together: if crediting fails, the redelivery retries it.
with transaction.atomic():
_, first = ValtuEvent.objects.get_or_create(id=event["id"], defaults={"type": event["type"]})
if first and event["type"] == "deposit.confirmed":
credit(valtu.deposits.get(event["data"]["depositId"]))
return HttpResponse(status=200)
csrf_exemptis right here: Valtu can't send your CSRF token, and the Ed25519 signature check is the stronger proof of where the request came from.request.bodyis the raw bytes, exactly what was signed. Don't parse the JSON yourself first.transaction.atomic()saves the event id and the credit together. A redelivered event finds its id and stops. If anything fails, both roll back, the view answers 500, and Valtu delivers the event again.get_or_createon the deposit id means no second event about the same deposit can credit it twice. See idempotency.
4. URLs
from django.urls import path
from . import views
urlpatterns = [
path("deposit-addresses/", views.deposit_addresses),
path("valtu/webhooks/", views.valtu_webhook),
]
And in your project's urls.py: path("payments/", include("payments.urls")).
5. Reconcile every hour
payments/management/commands/reconcile_deposits.py:
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils import timezone
from payments.valtu_client import credit, valtu
class Command(BaseCommand):
help = "Credit any confirmed deposit whose webhook was missed. Run it every hour."
def handle(self, *args, **options):
since = (timezone.now() - timedelta(hours=48)).isoformat() # overlaps earlier runs on purpose
checked = 0
for d in valtu.deposits.iterate(status="CONFIRMED", after=since):
with transaction.atomic():
credit(d)
checked += 1
self.stdout.write(f"checked {checked} confirmed deposits since {since}")
Run python manage.py reconcile_deposits from cron or your scheduler. It's harmless to run often: credited deposits are skipped. See reconciliation.
6. Try it
- Expose your dev server with a tunnel and point the endpoint at it. Send test event should get a 200.
- Sign in, open
/payments/deposit-addresses/, send test USDT to the Tron address, and watch aDepositCreditappear afterdeposit.confirmed. - Resend the delivery from the delivery log: still one credit.
Going live
- A live workspace with its own API key, endpoint and public key.
- The network name next to every address, and only the networks you accept. The networks page lists them.
- Sweeps into your treasury, and a gas wallet for their fees.
- Show balances from
DepositCredit(and the rest of your ledger), never from on-chain balances: deposits are swept away from customer addresses.
Using FastAPI? See crypto deposits with FastAPI.