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.
Sync needs the Scale plan
Connections are a Scale and Enterprise feature. On a lower plan the account routes are not available to you. Sync covers the requirements and the kill switch.
What you are building
A single payment cannot be split between vendors
There is no split array naming several connections on one payment, on any plan and through any
endpoint. A payment carries exactly one X-Sync header. A basket with two sellers is two payments,
which step 5 handles explicitly. Split payments covers the whole boundary.
1. Install and configure
pip install django djangorestframework wajubWAJUB_API_KEY = os.environ["WAJUB_API_KEY"]
WAJUB_WEBHOOK_SECRET = os.environ["WAJUB_WEBHOOK_SECRET"]
PLATFORM_COMMISSION_PERCENT = 10.0One client for the whole project, built once at import time.
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.
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.
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.
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.
That link is a credential
Anyone holding the URL can attach an account to your platform. Send it over a channel you control
and treat it like a secret. If it leaks or you lost it, POST /accounts/{id}/token revokes every
link already issued and returns a replacement, valid for 30 days again. It is refused with 400
as soon as a vendor has claimed the connection, even if they disconnected since.
Capabilities and pricing need the vendor's consent once accepted
Once the vendor has accepted, PUT /accounts/{id} on either field answers 202 with the change in
pending_changes, and account.change_requested goes to your endpoints. The vendor accepts or
declines it in their own dashboard, and the connection keeps its current terms until then.
Deciding the rate before step 3 runs saves that round trip.
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.
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.
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.
The buyer authorises once per vendor
Three vendors means three prompts on the customer's phone, and any of them can fail on its own. Treat each payment as its own order line rather than one atomic basket, and say so in your checkout interface before the first prompt goes out.
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.
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 expect | What Sync actually does |
|---|---|
| Funds land on the platform, you pay vendors weekly | Funds land on the vendor immediately |
| You run a payout job with your own balance at risk | There is no payout job |
| Your commission is computed and transferred by you | It 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.
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
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.
| Step | How to do it in sandbox |
|---|---|
| Vendor accepts | Open authorization_url signed in as the second account |
| Payments activated | Nothing to do, acceptance activates them |
| A basket succeeds | Pay each prompt with +237670000000 |
| One vendor fails | Pay 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.
Related pages
- SyncWhat a connection is, and where it stops.
- Account capabilitiesThe seven capabilities and how your commission is computed.
- OnboardingThe lifecycle, the authorisation link and the two statuses.
- Split paymentsWhy one payment cannot be shared between two vendors.
- Transfers & PayoutsMoving money out of a balance, connected or your own.