Django Marketplace
Build a multi-vendor marketplace with Django REST Framework and Wajub Sync — subaccount onboarding, split payments, and automatic payouts.
This recipe shows how to build a multi-vendor marketplace with Django REST Framework and Wajub Sync. You'll onboard vendors as subaccounts, process split payments at checkout, run automatic payouts via Celery, and handle webhook events — all production-ready.
Prerequisites
- A Wajub account with Sync enabled
- Python 3.10+ and a Django project with DRF
pip install django djangorestframework requests celery redisThere is no official Wajub Python SDK yet — this recipe calls the REST API directly with
requests.
No multi-vendor cart split today
There is no API feature to split a single payment's proceeds across multiple vendors
inline. This recipe charges the customer once to the platform, records each vendor's
computed share internally, and pays vendors out separately via POST /transfers (see
step 4) — rather than the fictional per-payment subaccount/transaction_charge split
this recipe used to describe.
Add to settings.py:
WAJUB_PUBLIC_KEY = "pk_test.xxxxxxxxxxxxxxxx"
WAJUB_SECRET_KEY = "sk_test.xxxxxxxxxxxxxxxx"
WAJUB_WEBHOOK_SECRET = "whsec_xxxxxxxxxxxxxxxx"
WAJUB_COMMISSION_RATE = 0.10 # 10% platform fee2. Subaccount onboarding
Create a model for vendor subaccounts in marketplace/models.py:
from django.db import models
from django.contrib.auth import get_user_model
User = get_user_model()
class VendorAccount(models.Model):
user = models.OneToOneField(
User,
on_delete=models.CASCADE,
related_name="vendor_account",
)
wajub_subaccount_id = models.CharField(
max_length=100,
unique=True,
null=True,
blank=True,
)
business_name = models.CharField(max_length=200)
business_email = models.EmailField()
mobile_money_number = models.CharField(max_length=20)
is_onboarded = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.business_name} ({self.user.email})"Create a small helper in marketplace/wajub_client.py:
import requests
from django.conf import settings
BASE_URL = "https://api.wajub.com"
def wajub_request(method, path, **kwargs):
response = requests.request(
method,
f"{BASE_URL}{path}",
headers={"Authorization": settings.WAJUB_SECRET_KEY},
timeout=30,
**kwargs,
)
response.raise_for_status()
return response.json()Create a view to onboard vendors in marketplace/views.py:
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from .models import VendorAccount
from .wajub_client import wajub_request
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def onboard_vendor(request):
data = request.data
if VendorAccount.objects.filter(user=request.user, is_onboarded=True).exists():
return Response(
{"error": "Vendor already onboarded."},
status=status.HTTP_400_BAD_REQUEST,
)
try:
result = wajub_request(
"POST",
"/accounts",
json={
"reference": f"vendor-{request.user.id}",
"capabilities": ["read", "payments", "withdrawals"],
"pricing": {"percentage_fee": 10.0, "currency": "XAF"},
},
)
account = result["account"]
except Exception as e:
return Response(
{"error": str(e)},
status=status.HTTP_400_BAD_REQUEST,
)
vendor_account, _ = VendorAccount.objects.update_or_create(
user=request.user,
defaults={
"wajub_subaccount_id": account["id"],
"business_name": data["business_name"],
"business_email": data.get("business_email", request.user.email),
"mobile_money_number": data["mobile_money_number"],
"is_onboarded": True,
},
)
return Response(
{
"message": "Vendor onboarded successfully.",
"account_id": account["id"],
"status": account["status"], # pending until fully verified
},
status=status.HTTP_201_CREATED,
)3. Checkout — single payment, vendor shares computed internally
The customer pays the platform once for the whole cart. Vendor payout amounts are computed
and stored on each OrderItem for the payout task in step 4 — they are not split by
Wajub at charge time.
from django.db import transaction as db_transaction
from rest_framework.decorators import api_view
from rest_framework.response import Response
from .models import Order, OrderItem, VendorAccount
from .wajub_client import wajub_request
from django.conf import settings
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def checkout(request):
cart_items = request.data.get("items", [])
if not cart_items:
return Response({"error": "Cart is empty."}, status=status.HTTP_400_BAD_REQUEST)
total_amount = 0
order_items = []
with db_transaction.atomic():
order = Order.objects.create(
user=request.user,
status="pending",
)
for item_data in cart_items:
vendor = VendorAccount.objects.select_related("user").get(
user_id=item_data["vendor_user_id"],
is_onboarded=True,
)
item_total = item_data["price"] * item_data["quantity"]
total_amount += item_total
order_item = OrderItem.objects.create(
order=order,
vendor=vendor,
product_id=item_data["product_id"],
quantity=item_data["quantity"],
unit_price=item_data["price"],
)
order_items.append(
{
"vendor_subaccount": vendor.wajub_subaccount_id,
"amount": item_total,
"item": order_item,
}
)
order.total_amount = total_amount
order.save()
# Record each vendor's share for later payout (step 4) — not sent to Wajub
for entry in order_items:
entry["item"].vendor_payout_amount = int(
entry["amount"] * (1 - settings.WAJUB_COMMISSION_RATE)
)
entry["item"].save()
idempotency_key = f"ORDER-{order.id}"
try:
response = wajub_request(
"POST",
"/payments",
headers={
"Authorization": settings.WAJUB_PUBLIC_KEY,
"Idempotency-Key": idempotency_key,
},
json={
"amount": total_amount,
"currency": "XAF",
"email": request.user.email,
"reference": idempotency_key,
"description": f"Order #{order.id} — Marketplace checkout",
"callback": f"{settings.FRONTEND_URL}/payment/return",
"metadata": {"order_id": order.id},
},
)
except Exception as e:
order.status = "failed"
order.save()
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
order.wajub_transaction_id = response["transaction"]["id"]
order.save()
return Response(
{
"authorization_url": response["authorization_url"],
"id": response["transaction"]["id"],
},
status=status.HTTP_200_OK,
)4. Automatic payouts via Celery
If your vendors need payouts on a schedule, use a Celery periodic task. Create marketplace/tasks.py:
from celery import shared_task
from django.db.models import Sum
from django.utils import timezone
from datetime import timedelta
import uuid
from .models import OrderItem, VendorAccount
from .wajub_client import wajub_request
@shared_task
def process_weekly_payouts():
"""Run every Monday to pay out vendors for last week's completed orders."""
one_week_ago = timezone.now() - timedelta(days=7)
vendors = VendorAccount.objects.filter(
is_onboarded=True,
wajub_subaccount_id__isnull=False,
)
for vendor in vendors:
completed_items = OrderItem.objects.filter(
vendor=vendor,
order__status="completed",
order__created_at__gte=one_week_ago,
paid_out=False,
)
if not completed_items.exists():
continue
total_earnings = sum(
item.vendor_payout_amount for item in completed_items
)
if total_earnings <= 0:
continue
idempotency_key = f"payout-{vendor.id}-{uuid.uuid4()}"
try:
transfer = wajub_request(
"POST",
"/transfers",
headers={"Idempotency-Key": idempotency_key},
json={
"amount": total_earnings,
"currency": "XAF",
"beneficiary": {
"name": vendor.business_name,
"channel": "cm.mtn", # resolve per vendor in production
"phone": vendor.mobile_money_number,
},
"description": f"Weekly payout — {vendor.business_name}",
},
)
completed_items.update(paid_out=True, transfer_id=transfer["transfer"]["id"])
except Exception as e:
print(f"Payout failed for {vendor.business_name}: {e}")Configure Celery Beat schedule in settings.py:
from celery.schedules import crontab
CELERY_BEAT_SCHEDULE = {
"weekly-payouts": {
"task": "marketplace.tasks.process_weekly_payouts",
"schedule": crontab(hour=2, minute=0, day_of_week=1), # Monday 2 AM
},
}Permission note
Your Wajub account must have the Transfers feature enabled. Contact support if the endpoint returns a 403.
5. Webhook receiver
Create marketplace/webhooks.py to handle order lifecycle events:
import json
import hmac
import hashlib
from django.conf import settings
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from .models import Order
@csrf_exempt
@require_POST
def wajub_webhook(request):
payload = request.body
signature = request.headers.get("X-Wajub-Signature", "")
timestamp = request.headers.get("X-Wajub-Timestamp", "")
if not signature or not timestamp:
return JsonResponse({"error": "Missing signature"}, status=400)
signed_payload = timestamp.encode() + b"." + payload
expected = "v1=" + hmac.new(
settings.WAJUB_WEBHOOK_SECRET.encode(),
signed_payload,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, signature):
return JsonResponse({"error": "Invalid signature"}, status=400)
event = json.loads(payload)
if event["type"] == "payment.succeeded":
data = event["data"]
try:
order = Order.objects.get(id=data["metadata"]["order_id"])
order.status = "completed"
order.save()
except Order.DoesNotExist:
pass
elif event["type"] == "payment.failed":
data = event["data"]
try:
order = Order.objects.get(id=data["metadata"]["order_id"])
order.status = "failed"
order.save()
except Order.DoesNotExist:
pass
return JsonResponse({"received": True}, status=200)Register the webhook URL in marketplace/urls.py:
from django.urls import path
from . import views
from .webhooks import wajub_webhook
urlpatterns = [
path("onboard/", views.onboard_vendor, name="vendor-onboard"),
path("checkout/", views.checkout, name="checkout"),
path("webhook/", wajub_webhook, name="wajub-webhook"),
]