Cashback Platform Development
Cashback platform connects customers, partner stores and payment processing. Customer makes purchase via partner link or card, platform receives commission from store and returns part to customer. Technically it's a system of click/transaction tracking, reward calculation and payout management.
Tracking Models
There are two fundamentally different purchase tracking mechanisms:
Affiliate-tracking — user follows special link, makes purchase, store notifies platform via postback or pixel.
Card-linked — payment card binding, transactions tracked via bank APIs (Visa/Mastercard CLO, SBP). Requires bank partnership.
Most platforms start with affiliate.
Data Schema
CREATE TABLE partners (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL,
slug VARCHAR(200) UNIQUE NOT NULL,
website VARCHAR(500) NOT NULL,
logo_url VARCHAR(500),
cashback_rate NUMERIC(5,2) NOT NULL, -- % of purchase amount
platform_rate NUMERIC(5,2) NOT NULL, -- full commission from partner
status VARCHAR(20) NOT NULL DEFAULT 'active'
CHECK (status IN ('active','paused','terminated')),
tracking_url VARCHAR(500), -- link template with {click_id}
network VARCHAR(50), -- admitad, cityads, proprietary
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE clicks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
partner_id UUID NOT NULL REFERENCES partners(id),
click_id VARCHAR(100) UNIQUE NOT NULL, -- passed to partner network
ip INET,
user_agent TEXT,
referrer VARCHAR(500),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
click_id UUID REFERENCES clicks(id),
user_id UUID REFERENCES users(id),
partner_id UUID NOT NULL REFERENCES partners(id),
order_id VARCHAR(200), -- order ID in store
purchase_amount NUMERIC(15,2),
commission NUMERIC(15,2), -- received from partner
cashback_amount NUMERIC(15,2), -- credited to user
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','confirmed','cancelled','paid')),
hold_until DATE, -- date when can be paid out
source VARCHAR(50), -- 'postback','pixel','api'
raw_data JSONB, -- raw data from partner
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE cashback_accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID UNIQUE NOT NULL REFERENCES users(id),
balance NUMERIC(15,2) NOT NULL DEFAULT 0, -- available for withdrawal
pending NUMERIC(15,2) NOT NULL DEFAULT 0, -- on hold
total_earned NUMERIC(15,2) NOT NULL DEFAULT 0,
total_withdrawn NUMERIC(15,2) NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE withdrawals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
amount NUMERIC(15,2) NOT NULL,
method VARCHAR(30) NOT NULL CHECK (method IN ('card','sbp','wallet','phone')),
destination VARCHAR(200) NOT NULL, -- card number/phone
status VARCHAR(20) NOT NULL DEFAULT 'pending',
processed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Tracking Links
import hashlib
import base64
from django.conf import settings
def generate_click_id(user_id: str, partner_id: str) -> str:
"""Unique click_id for tracking"""
raw = f'{user_id}:{partner_id}:{timezone.now().timestamp()}'
return base64.urlsafe_b64encode(
hashlib.sha256(raw.encode()).digest()[:12]
).decode().rstrip('=')
def build_tracking_url(user, partner) -> str:
click_id = generate_click_id(str(user.id), str(partner.id))
# Save click
Click.objects.create(
user=user,
partner=partner,
click_id=click_id,
)
# Build link with click_id
tracking_url = partner.tracking_url.replace('{click_id}', click_id)
# Example: https://ad.admitad.com/g/abc123/?subid={click_id}
# → https://ad.admitad.com/g/abc123/?subid=xK9mNpQr
return tracking_url
Redirect endpoint:
def click_redirect(request, partner_slug):
partner = get_object_or_404(Partner, slug=partner_slug, status='active')
user = request.user
if not user.is_authenticated:
# Save intent, redirect to login
request.session['pending_cashback_partner'] = partner_slug
return redirect('/login/?next=' + request.path)
url = build_tracking_url(user, partner)
# Analytics
track_event.delay('cashback_click', {
'user_id': str(user.id),
'partner_id': str(partner.id),
})
return HttpResponseRedirect(url)
Postback from Partner Networks
Admitad, CityAds and most CPA networks notify via GET request (postback):
# GET /postback/admitad/?click_id={click_id}&order_id={order_id}&
# sale_amount={sale_amount}&commission={commission}&status={status}
def admitad_postback(request):
# Check signature (each network has its own algorithm)
provided_sig = request.GET.get('sig')
click_id = request.GET.get('click_id')
expected_sig = hmac.new(
settings.ADMITAD_SECRET.encode(),
click_id.encode(),
hashlib.md5
).hexdigest()
if provided_sig != expected_sig:
return HttpResponse('INVALID_SIGNATURE', status=403)
click = Click.objects.filter(click_id=click_id).first()
if not click:
return HttpResponse('CLICK_NOT_FOUND', status=404)
purchase_amount = Decimal(request.GET.get('sale_amount', '0'))
commission = Decimal(request.GET.get('commission', '0'))
cashback_amount = commission * (click.partner.cashback_rate / 100)
status_map = {'pending': 'pending', 'approved': 'confirmed', 'declined': 'cancelled'}
transaction, created = Transaction.objects.get_or_create(
order_id=request.GET.get('order_id'),
partner=click.partner,
defaults={
'click': click,
'user': click.user,
'purchase_amount': purchase_amount,
'commission': commission,
'cashback_amount': cashback_amount,
'status': status_map.get(request.GET.get('status'), 'pending'),
'hold_until': date.today() + timedelta(days=click.partner.hold_days),
'source': 'postback',
'raw_data': dict(request.GET),
}
)
if not created:
# Update status (approved → cancelled)
transaction.status = status_map.get(request.GET.get('status'), transaction.status)
transaction.save()
if transaction.status == 'confirmed':
credit_cashback.delay(str(transaction.id))
return HttpResponse('OK')
Crediting and Paying Out Cashback
@shared_task
def credit_cashback(transaction_id: str):
"""Credit cashback after transaction confirmation"""
with transaction_lock(transaction_id):
txn = Transaction.objects.select_for_update().get(id=transaction_id)
if txn.status != 'confirmed':
return
account, _ = CashbackAccount.objects.select_for_update().get_or_create(
user=txn.user
)
account.pending += txn.cashback_amount
account.total_earned += txn.cashback_amount
account.save()
txn.status = 'credited'
txn.save()
notify_cashback_credited.delay(str(txn.user_id), float(txn.cashback_amount))
@shared_task
def release_held_cashback():
"""Daily: move confirmed cashback from pending to balance"""
today = date.today()
ready = Transaction.objects.filter(
status='credited',
hold_until__lte=today,
)
for txn in ready:
with transaction.atomic():
account = CashbackAccount.objects.select_for_update().get(user=txn.user)
account.pending -= txn.cashback_amount
account.balance += txn.cashback_amount
account.save()
txn.status = 'available'
txn.save()
Withdrawal via SBP
def request_withdrawal_sbp(user, amount: Decimal, phone: str):
account = CashbackAccount.objects.select_for_update().get(user=user)
if account.balance < amount:
raise InsufficientBalanceError()
if amount < Decimal('100'):
raise ValidationError('Minimum withdrawal amount 100 RUB')
account.balance -= amount
account.total_withdrawn += amount
account.save()
withdrawal = Withdrawal.objects.create(
user=user,
amount=amount,
method='sbp',
destination=phone,
status='pending',
)
# Send to payment gateway (YooKassa, Tinkoff, etc.)
process_sbp_payout.delay(str(withdrawal.id))
return withdrawal
Timeline
MVP with affiliate-tracking, Admitad postback, personal account and card withdrawal: 6–8 weeks. Full platform with multiple networks, card-linked offers, referral program and partner analytics dashboard: 4–5 months.







