Skip to content

Django marketplace

A multi-vendor marketplace on Sync, where money lands on the vendor, not on you.

This recipe builds a marketplace with Django REST Framework and Wajub Sync: vendors connect their own Wajub accounts, each payment settles on the vendor's account, and your commission is credited to you. The money never passes through your balance, so there is no payout job.

What you are building

1. Install and configure

Installation
pip install django djangorestframework wajub
settings.py
WAJUB_API_KEY = os.environ["WAJUB_API_KEY"]
WAJUB_WEBHOOK_SECRET = os.environ["WAJUB_WEBHOOK_SECRET"]
PLATFORM_COMMISSION_PERCENT = 10.0

One client for the whole project, built once at import time.

marketplace/wajub.py
from django.conf import settings
from wajub import Wajub

client = Wajub(
    api_key=settings.WAJUB_API_KEY,
    webhook_secret=settings.WAJUB_WEBHOOK_SECRET,
)

2. A vendor is not an account you create

This is where most marketplace integrations start wrong. You are not opening an account on the vendor's behalf. You are issuing an invitation that an existing Wajub merchant accepts.

Your model therefore mirrors two independent statuses rather than one boolean.

marketplace/models.py
from django.conf import settings
from django.db import models


class Vendor(models.Model):
    user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    business_name = models.CharField(max_length=200)
    payout_phone = models.CharField(max_length=20, blank=True)

    sync_account_id = models.CharField(max_length=64, unique=True, null=True, blank=True)
    status = models.CharField(max_length=16, default="pending")
    payment_status = models.CharField(max_length=16, default="inactive")

    created_at = models.DateTimeField(auto_now_add=True)

    @property
    def can_sell(self) -> bool:
        return self.status == "active" and self.payment_status == "active"


class SyncDelivery(models.Model):
    key = models.CharField(max_length=128, unique=True)
    received_at = models.DateTimeField(auto_now_add=True)

status says whether the vendor accepted. payment_status says whether the connection may move money. A connection can be active and still refuse every payment, and can_sell is the only question your checkout should ever ask.

payout_phone is the Mobile Money number the vendor wants their withdrawals sent to. You collect it in your own onboarding form: Wajub does not hand you the vendor's numbers. Step 7 pays out to it.

SyncDelivery remembers the Sync deliveries you already processed, so that step 4 handles each one once.

3. Onboarding a vendor

Creating the connection declares what it may do and what you retain on it. Once the vendor has accepted, changing either takes their consent, so get them right before you send the link. withdrawals is there for step 7: without it, a transfer sent on the vendor's behalf answers 403. callback is the page the vendor comes back to once they have accepted, not a webhook URL: events never go there.

marketplace/views.py
from django.conf import settings
from rest_framework import status as http
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from wajub import WajubError

from .models import Vendor
from .wajub import client


@api_view(["POST"])
@permission_classes([IsAuthenticated])
def onboard_vendor(request):
    vendor, _ = Vendor.objects.get_or_create(
        user=request.user,
        defaults={
            "business_name": request.data["business_name"],
            "payout_phone": request.data.get("payout_phone", ""),
        },
    )

    if vendor.sync_account_id:
        return Response({"error": "Already connected."}, status=http.HTTP_400_BAD_REQUEST)

    try:
        account = client.accounts.create({
            "reference": f"vendor-{vendor.id}",
            "capabilities": ["read", "payments", "refunds", "customers", "withdrawals"],
            "pricing": {
                "percentage_fee": settings.PLATFORM_COMMISSION_PERCENT,
                "currency": "XAF",
            },
            "callback": "https://marketplace.example.com/vendors/connected",
        })
    except WajubError as exc:
        return Response({"error": str(exc)}, status=http.HTTP_400_BAD_REQUEST)

    vendor.sync_account_id = account["id"]
    vendor.status = account["status"]
    vendor.save(update_fields=["sync_account_id", "status"])

    return Response(
        {"authorization_url": account["authorization_url"]},
        status=http.HTTP_201_CREATED,
    )

The response carries the link the vendor has to open.

Response · 201 Created
{
"code": 201,
"status": "Created",
"message": "Account created",
"account": {
"id": "acc_7Yh2MpL4tRb3nP8sZcXv",
"reference": "vendor-41",
"status": "pending",
"payment_status": "inactive",
"authorization_url": "https://sync.wajub.com/oauth/v2/authorize?access_token=…",
"pricing": {
"percentage_fee": 10,
"currency": "XAF"
}
}
}

id is what you will send as X-Sync. authorization_url is returned once, in this response: reading the account later never returns it, claimed or not. Store it, or send it straight away.

4. Letting the webhooks move the vendor forward

Nothing polls. A connection's life reaches you by webhook, on an endpoint you register in the Dashboard or with POST /webhooks, with the account.* events selected one by one: the events raised from the Dashboard ignore a * subscription.

Two formats land on that endpoint. Events that follow your own calls on /accounts carry the usual envelope, X-Wajub-Signature and X-Wajub-Timestamp. Events raised in the Dashboard, account.connected when the vendor accepts or account.payment_activated once their compliance is verified, carry an X-Sync-Event header and a flat body that names the connection in account, the key the API's events use too. They are signed the same way, with the same secret, in X-Sync-Signature-V1 and X-Sync-Timestamp. So construct_event verifies both: the view picks the pair of headers by the presence of X-Sync-Event, and processes each Sync delivery once, by the id in its X-Idempotency-Key header. It then re-reads the connection rather than trusting either payload.

In the usual envelope, every account.* event names its connection in data.account, the acc_… id. Read it there, not in data.id: on account.commission_receivable_recorded and account.commission_receivable_collected, data.id is the crv_… id of the commission owed, and account.change_requested has no data.id at all.

marketplace/webhooks.py
from django.db import transaction
from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from wajub import WebhookSignatureVerificationError

from .models import SyncDelivery, Vendor
from .tasks import apply_paid_payment
from .wajub import client


def apply_account_event(name, account_id):
    if name == "account.deauthorized":
        Vendor.objects.filter(sync_account_id=account_id).update(
            status="cancelled",
            payment_status="inactive",
        )
        return

    account = client.accounts.retrieve(account_id)

    Vendor.objects.filter(sync_account_id=account_id).update(
        status=account["status"],
        payment_status=account["payment_status"],
    )


@csrf_exempt
@require_POST
def wajub_webhook(request):
    sync = "X-Sync-Event" in request.headers
    signature, timestamp = (
        ("X-Sync-Signature-V1", "X-Sync-Timestamp")
        if sync
        else ("X-Wajub-Signature", "X-Wajub-Timestamp")
    )

    try:
        event = client.webhooks.construct_event(
            request.body,
            request.headers.get(signature, ""),
            request.headers.get(timestamp, ""),
        )
    except WebhookSignatureVerificationError:
        return HttpResponseBadRequest()

    name = event["event"]

    if sync:
        delivery = request.headers.get("X-Idempotency-Key")

        if not delivery:
            return HttpResponseBadRequest()

        with transaction.atomic():
            _, first_time = SyncDelivery.objects.get_or_create(key=delivery)

            if first_time:
                apply_account_event(name, event["account"])

        return HttpResponse(status=200)

    data = event["data"]

    if name.startswith("account."):
        apply_account_event(name, data["account"])

    elif name == "payment.succeeded":
        apply_paid_payment.delay(data["id"])

    return HttpResponse(status=200)

Give construct_event request.body, the raw bytes, and never a parsed payload.

Sandbox activates payments on acceptance

Compliance verification does not run in sandbox, so a sandbox connection has payment_status set to active the moment the vendor accepts, and no account.payment_activated follows. In live, that event arrives once the vendor's compliance is verified.

5. Checkout, one payment per vendor

A basket touching three vendors is three payments. Group the cart by vendor, create a payment per group, and keep a parent record so you can reconcile the basket afterwards.

marketplace/checkout.py
from collections import defaultdict

from django.db import transaction as db_transaction
from rest_framework import status as http
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from wajub import RequestOptions

from .models import Basket, Order, Vendor
from .wajub import client


@api_view(["POST"])
@permission_classes([IsAuthenticated])
def checkout(request):
    by_vendor = defaultdict(list)

    for line in request.data["items"]:
        by_vendor[line["vendor_id"]].append(line)

    with db_transaction.atomic():
        basket = Basket.objects.create(user=request.user)
        orders = []

        for vendor_id, lines in by_vendor.items():
            vendor = Vendor.objects.get(pk=vendor_id)

            if not vendor.can_sell:
                return Response(
                    {"error": f"{vendor.business_name} cannot take payments yet."},
                    status=http.HTTP_409_CONFLICT,
                )

            orders.append(Order.objects.create(
                basket=basket,
                vendor=vendor,
                amount=price_of(lines),
                currency="XAF",
            ))

    payments = []

    for order in orders:
        payment = client.payments.create(
            {
                "amount": float(order.amount),
                "currency": order.currency,
                "customer": {"email": request.user.email},
                "description": f"{order.vendor.business_name}, basket {basket.id}",
                "reference": f"order-{order.id}",
                "callback": f"https://marketplace.example.com/baskets/{basket.id}/return",
                "metadata": {"basket_id": str(basket.id), "order_id": str(order.id)},
            },
            RequestOptions(
                sync=order.vendor.sync_account_id,
                idempotency_key=f"order-{order.id}",
            ),
        )

        order.payment_id = payment.id
        order.save(update_fields=["payment_id"])

        payments.append({
            "vendor": order.vendor.business_name,
            "amount": order.amount,
            "authorization_url": payment.authorization_url,
        })

    return Response({"basket_id": basket.id, "payments": payments})

Three things in there deserve a sentence each.

price_of(lines) reads your own catalogue. An amount that arrives in the request body is a suggestion from a browser, not an instruction.

RequestOptions(sync=...) sends the X-Sync header, and that header does more than apply a tariff: the whole request runs as the vendor. The payment, the customer record and any later refund belong to them, not to you.

metadata carries your basket id into every payment. It is the only thing that lets you reconstruct a multi-vendor basket afterwards, because nothing on the Wajub side links the payments together.

6. Your commission arrives on its own

When a connected payment succeeds, your commission is credited to your platform balance. You are told by webhook and you call nothing.

fee.received
{
"event": "fee.received",
"data": {
"id": "9d1c7a52-4b1e-4f0a-9a3e-2f6b8c1d7e40",
"amount": 2500,
"currency": "XAF",
"percentage": 10,
"fixed_amount": null,
"applied_to": "transaction",
"account": {
"id": "acc_7Yh2MpL4tRb3nP8sZcXv",
"reference": "acc_7Yh2MpL4tRb3nP8sZcXv"
}
}
}

A fee does not name the payment it came from: total your commission per vendor with account.id. Compute the commission of a given order from the pricing you set at step 3.

The vendor receives the mirror of it, fee.charged, on their own endpoints. Between the two, both sides of the commission are auditable without either of you asking the other.

On top of that, every payment, refund and transfer on a connected account fires its normal webhook to the vendor and a copy to your platform. That is why step 4 can update a basket from payment.succeeded without ever polling.

7. There is no payout job

This is the part that surprises people arriving from a platform where the marketplace holds the funds. A connected payment settles into the vendor's own Wajub balance. You never held it, so there is nothing to send.

What you might expectWhat Sync actually does
Funds land on the platform, you pay vendors weeklyFunds land on the vendor immediately
You run a payout job with your own balance at riskThere is no payout job
Your commission is computed and transferred by youIt is credited automatically on each payment

If a vendor wants money out of their Wajub balance, that is an ordinary transfer sent with the X-Sync header, which draws on their balance rather than yours, and needs the withdrawals capability on the connection: the one requested at step 3. The recipient is the payout_phone you stored on the vendor at step 2.

A payout on behalf of a vendor
client.transfers.create(
    {
        "amount": 45000,
        "currency": "XAF",
        "beneficiary": {
            "name": vendor.business_name,
            "channel": "cm.mtn",
            "phone": vendor.payout_phone,
        },
        "description": "Withdrawal",
    },
    RequestOptions(sync=vendor.sync_account_id),
)

8. Wire it up and test it

marketplace/urls.py
from django.urls import path

from . import checkout, views
from .webhooks import wajub_webhook

urlpatterns = [
    path("vendors/onboard/", views.onboard_vendor, name="vendor-onboard"),
    path("checkout/", checkout.checkout, name="checkout"),
    path("webhooks/wajub/", wajub_webhook, name="wajub-webhook"),
]

Testing a marketplace end to end needs a second Wajub account to play the vendor, because a connection joins two real accounts. Open one in sandbox and accept the invitation from it. Payments are active from that moment, since compliance does not run there.

StepHow to do it in sandbox
Vendor acceptsOpen authorization_url signed in as the second account
Payments activatedNothing to do, acceptance activates them
A basket succeedsPay each prompt with +237670000000
One vendor failsPay that one with +237670000002 and leave the others succeeding

That last row is the one worth building for. A partially paid basket is the normal failure mode of a marketplace, and it is much easier to handle deliberately than to discover.

What did you think of this content?