this one was for fun. booking a bus from Nairobi to Mombasa already works: you open three or four sites, compare by hand, and hope your seat survives to the payment screen. Twenzetu (from the Swahili "twende zetu", let's get going) is my excuse to build that properly, end to end: one search that fans out across multiple bus companies, dedupes the results, surfaces the best price, and takes M-Pesa payment for the seats we sell directly. building it turned up a few problems (row-level tenancy, search fan-out, payment idempotency) that needed real decisions, and those are what this post is about.
what the incumbents run
before writing a line, i pulled apart the two platforms i already use, from the outside only: public bundles, API responses, and headers, no login. what i found shaped what i built.
BuuPass is the serious build. python and Django microservices on Azure, separate services for payments, users, itinerary, and referrals, a streaming search, and a full online travel agency catalogue covering buses, hotels, trains, and flights. it ships strict transport security, a deep analytics stack, and answers in around 0.16 seconds. Travler took the other road: an Angular frontend on a third-party white-label bus platform (iabiri, PHP), one monolith API, buses only, spread across Kenya, Uganda, Tanzania, and Ethiopia, inheriting whatever the vendor shipped (including the placeholder seat code i show later).
that mapped the gap for me. BuuPass owns its stack but only sells its own inventory. Travler aggregates thinly and controls nothing underneath. Twenzetu wants both at once: a meta-search that unions many operators, and a marketplace for the seats we sell directly, with the payment path and the seat model firmly under my control.
the shape of the thing
the repo is a monorepo with four parts:
server/: a Django + Django REST Framework (DRF) API. no template frontend at all.client/: a Next.js app where travelers search, pick seats, and pay. runs on port 3000.admin/: a Next.js app where bus companies manage trips, buses, clerks, and money. runs on port 3001.api-client/: a TypeScript client generated from the OpenAPI schema. both apps consume it; nobody edits it by hand.
each bus company on the platform is a tenant. customers, however, search across all tenants at once. that tension (isolate everything, except for the one query that is the entire product) drove most of the architecture.
one database, guarded by django-scopes
the obvious multi-tenancy libraries give you schema-per-tenant. i went with row-level tenancy instead: one shared schema and a tenant foreign key on every tenant-owned model. the reason is the read path. customer search unions inventory across all companies, dedupes it, and picks the cheapest seat. with schema-per-tenant that becomes a loop over schemas; with row-level it stays a single query.
the risk with row-level tenancy is the forgotten filter: one missing .filter(tenant=...) and company A sees company B's bookings. django-scopes turns that mistake into a loud crash. scoped models get a ScopedManager, and any query outside an active scope raises instead of silently returning everything:
class Trip(UUIDModel):
tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, related_name="trips")
route = models.ForeignKey(Route, on_delete=models.PROTECT, related_name="trips")
bus = models.ForeignKey(Bus, on_delete=models.PROTECT, related_name="trips")
depart_at = models.DateTimeField()
price_kes = models.PositiveIntegerField() # whole KES, no cents
objects = ScopedManager(tenant="tenant")a small warning: django-scopes last shipped in 2023 and only declares support up to Django 4.0. i smoke-tested it on Django 6 and Python 3.14 before committing (it builds its filters through a custom manager, so the surface area is small), and every import in the codebase goes through a one-file shim (apps/utils/scoping.py) so the backend can be swapped for a vendored copy without touching call sites.
the scope itself is set by middleware, and this is where the three read contexts live:
class TenantScopeMiddleware:
def __call__(self, request):
if request.path.startswith("/api/operator/"):
return self._scoped_operator_request(request) # scope(tenant=...)
if request.path.startswith(("/admin/", "/api/platform/")):
with scopes_disabled(): # superuser, cross-tenant
return self.get_response(request)
return self.get_response(request) # NO scope: scoped queries raiseoperator dashboard requests resolve the active company from the user's memberships (an X-Tenant-ID header carrying a UUID or slug disambiguates when someone belongs to several companies) and run inside scope(tenant=...), so every scoped query auto-filters. platform and Django admin paths are superuser territory and run unscoped. everything else, including all customer endpoints, gets no scope at all: services that legitimately need cross-tenant reads opt in explicitly with scopes_disabled(), and anything that forgets simply blows up in development. the guardrail has already caught real bugs, including one where DRF built a unique-constraint validator at serializer definition time and tripped the scope check.
searching every bus company at once
inventory comes from providers behind a small interface. our own database is just another provider:
class BusProvider(ABC):
slug: ClassVar[str]
rate_limit_per_minute: ClassVar[int] = 30
@abstractmethod
def resolve_city(self, city_id: str) -> str | None: ...
@abstractmethod
def search(self, query: SearchQuery) -> list[SearchResult]: ...
@abstractmethod
def seatmap(self, source_trip_id: str) -> dict | None: ...
@abstractmethod
def deep_link(self, result: SearchResult) -> str | None: ...external providers are read-only meta-search: we normalize their results and hand off with a deep link, never touching their payments. NativeProvider reads our own trips (under scopes_disabled(), cross-tenant by design) and its deep link points at our checkout.
a search request fans out with a Celery chord: one task per provider, then a callback that merges. the one rule that keeps the whole thing alive is that header tasks never raise:
@shared_task
def run_provider_search(provider_slug: str, query_payload: dict) -> dict:
try:
provider = get_provider(provider_slug)
query = SearchQuery.from_payload(query_payload)
if (cached := cache.get(cache_key)) is not None:
return {"provider": provider_slug, "ok": True, "results": cached}
services.check_rate_limit(provider)
results = [dataclasses.asdict(r) for r in provider.search(query)]
cache.set(cache_key, results, timeout=settings.PROVIDER_SEARCH_CACHE_TTL)
return {"provider": provider_slug, "ok": True, "results": results}
except Exception as exc: # fail soft by design
logger.exception("provider %s search failed", provider_slug)
return {"provider": provider_slug, "ok": False, "error": str(exc)}a raising header task can stall a chord's callback in broker mode, and under eager Celery (local dev runs without Redis, tasks execute inline) it would propagate straight into the API request. returning error markers instead means one dead provider becomes an errors entry in the response while everyone else's buses still show up.
dedupe runs in the chord callback. the key is the physical departure: canonical operator (resolved through per-provider alias tables, seeded with difflib fuzzy matching and hand-reviewed below a threshold), departure time truncated to the minute, and the origin/destination pair. within each provider we keep the cheapest offer per key, then across providers each group collapses into one row where the cheapest offer wins and the rest are listed as sources. ties go to our native inventory, since our checkout beats a handoff.
the endpoint contract is submit-then-poll: POST /api/search/ returns a search_id with 202, and GET /api/search/{id}/ reads the merged result from cache. in production the chord runs on workers; in development eager Celery finishes the whole chord before the POST even returns, so the first poll already finds complete. same contract, two execution modes, no special cases in the frontend.
drawing the bus: how the seat layout is made
i studied how travler.africa renders its buses (a vehicle you can recognize, with a driver, a door, and seats that look like seats) before drawing my own, and pulling apart their bundle was instructive in two ways. their live seat map is a data-driven Angular grid, close in spirit to what i wanted. their /booking route, though, still ships dead placeholder code that generates seats with Math.random() and prices them in Indian rupees, a leftover from the white-label platform underneath (the real /search flow works fine in Kenyan shillings). that pushed me toward a seat model where the layout is data, generated once and then locked so it can never quietly drift. Twenzetu builds a structured grid where every cell of the coach is a row in the Seat table with a row, a col, and a kind:
class Seat(UUIDModel):
class Kind(models.TextChoices):
SEAT = "seat"
AISLE = "aisle"
DRIVER = "driver"
DOOR = "door"
EMPTY = "empty"
trip = models.ForeignKey(Trip, on_delete=models.CASCADE, related_name="seats")
label = models.CharField(max_length=8, blank=True) # "1A"; blank for non-seat cells
row = models.PositiveSmallIntegerField()
col = models.PositiveSmallIntegerField()
kind = models.CharField(max_length=10, choices=Kind.choices, default=Kind.SEAT)
seat_class = models.CharField(max_length=10, choices=SeatClass.choices, default=SeatClass.NORMAL)only kind=seat is sellable; the rest is furniture. the bus model holds the layout template (seat_rows, seat_cols, plus how many front rows are VIP and business), and a service generates the grid per trip:
def generate_seats_for_trip(trip: Trip) -> int:
cols = trip.bus.seat_cols
aisle_col = cols // 2
def class_for_row(row):
if row <= bus.vip_rows:
return Seat.SeatClass.VIP
if row <= bus.vip_rows + bus.business_rows:
return Seat.SeatClass.BUSINESS
return Seat.SeatClass.NORMAL
# row 0 is furniture: boarding door on the left, driver on the right
# (Kenyan buses are right-hand drive), empty floor between them.
cells = [cell(0, 0, Seat.Kind.DOOR)]
cells += [cell(0, col, Seat.Kind.EMPTY) for col in range(1, cols - 1)]
cells.append(cell(0, cols - 1, Seat.Kind.DRIVER))
# standard rows split by the middle aisle, labelled 1A, 1B, ... per row
for row in range(1, bus.seat_rows):
for col in range(cols):
kind = Seat.Kind.AISLE if col == aisle_col else Seat.Kind.SEAT
cells.append(cell(row, col, kind, label_for(row, col)))
# the rear bench spans the full width, aisle column included
bench = bus.seat_rows
cells += [cell(bench, col, Seat.Kind.SEAT, f"{bench}{LETTERS[col]}") for col in range(cols)]
Seat.objects.bulk_create(cells)so a 10-row, 5-column bus produces a door, a driver, nine rows of four seats around the aisle, and a five-seat bench at the back: 41 sellable seats. regeneration refuses to run once any seat has an active booking, so an operator cannot pull the grid out from under a paying customer.
the seatmap endpoint walks the grid and returns one cell per position, annotating each sellable seat with its live status (a seat is booked if a confirmed booking holds it, held if an unexpired hold does) and its class-resolved price. the frontend renders it with nothing fancier than CSS grid:
<div className="grid gap-1.5" style={{ gridTemplateColumns: `repeat(${seatmap.cols}, minmax(0, 2.5rem))` }}>
{seatmap.cells.map((cell) =>
cell.kind !== 'seat'
? <FurnitureCell cell={cell} /> // steering wheel svg, dashed DOOR box, empty floor
: <button disabled={!selectable} aria-pressed={isSelected} className={seatClasses(cell, isSelected)}>
{cell.label}
</button>
)}
</div>seat cells get a rounded top and a thick bottom border so they read as a backrest and cushion, and the fare class is encoded in the available-seat color, with the legend showing each class's price:
Figure: the generated coach: furniture row up front, VIP (gold) and business (blue) rows, and the 10A to 10E rear bench
selecting seats turns them maroon, adds removable chips to the passenger panel, and totals the actual per-seat prices, so one VIP seat and one normal seat price independently:
Figure: 1A (VIP, KES 2,000) plus 6C (normal, KES 1,300): the panel sums per-seat class prices
seats that cannot be sold twice
fare classes ride on the same grid. the bus declares how many front rows are VIP and business, the trip prices each class (falling back to the normal fare), and everything downstream just asks the trip:
def price_for_class(self, seat_class) -> int:
if seat_class == Seat.SeatClass.VIP and self.vip_price_kes is not None:
return self.vip_price_kes
if seat_class == Seat.SeatClass.BUSINESS and self.business_price_kes is not None:
return self.business_price_kes
return self.price_kesthe checkout hold is the part that has to be right. "one active booking per seat" cannot be a database constraint because it depends on booking status (a seat with an expired hold is free again). so the service does it in a transaction:
with scopes_disabled(), transaction.atomic():
trip = Trip.objects.select_for_update().get(id=trip_id, status=Trip.Status.SCHEDULED)
seats = list(Seat.objects.select_for_update()
.filter(trip=trip, id__in=unique_seat_ids, kind=Seat.Kind.SEAT))
conflict = BookingSeat.objects.filter(seat__in=seats).filter(
Q(booking__status=Booking.Status.CONFIRMED)
| Q(booking__status=Booking.Status.HELD, booking__expires_at__gt=now)
)
if conflict.exists():
raise SeatUnavailableError("One or more selected seats are no longer available.")
booking = Booking(..., amount_kes=sum(trip.price_for_class(s.seat_class) for s in seats),
expires_at=now + timedelta(minutes=settings.BOOKING_HOLD_MINUTES))the status predicate is the authoritative guard; select_for_update is defense in depth on Postgres (it is a silent no-op on the SQLite database that local development uses, which is worth knowing before you trust it with your life). holds last ten minutes, and a Celery beat task sweeps expired ones back to available every minute.
M-Pesa without a public callback URL
payments go through Safaricom's Daraja API using STK push (the prompt that pops up on the customer's phone). Daraja confirms payments by calling a webhook, which is a problem on localhost. the fix is one shared confirm path used by two callers: the webhook in production, and a status-poll endpoint in development that asks Daraja directly via the STK query API. both funnel into the same function:
def apply_daraja_result(*, checkout_request_id, result_code, receipt, raw):
with scopes_disabled(), transaction.atomic():
payment = Payment.objects.select_for_update().get(
checkout_request_id=checkout_request_id)
if payment.status != Payment.Status.PENDING:
return payment # duplicate callback: no-op
if result_code == 0:
payment.status = Payment.Status.SUCCESS
payment.mpesa_receipt = receipt or None
payment.save()
tenant_services.confirm_booking(payment.booking)
else:
payment.status = Payment.Status.FAILED
payment.save()
return paymentidempotency is layered: the row lock plus the PENDING short-circuit makes duplicate callbacks no-ops, partial unique constraints on checkout_request_id and mpesa_receipt make double-crediting structurally impossible, the ledger entry is a get_or_create behind a OneToOne, and the webhook itself never returns an error (Daraja retries on non-200, so a malformed payload gets logged and acknowledged). money amounts are whole Kenyan shillings in plain integer fields; the only decimal in the system is the commission percentage, and its rounding happens in exactly one place.
the frontends and their sharp edges
both Next.js apps call the API through the generated TypeScript client, so every endpoint and enum is typed end to end; make generate-api-client regenerates it from the DRF schema whenever the API changes. the operator app is path-tenant: company pages live under /{tenant-slug}/..., the slug in the path pins the active company and feeds the X-Tenant-ID header, and switching companies just swaps the path prefix, one session covering all of them.
three sharp edges cost me real time, so here they are for free:
- Turbopack does not resolve files outside the project root. the symlinked
api-clientpackage built fine withtscand failed innext builduntilturbopack.rootpointed at the monorepo root. the package also needed anexportsfield;mainalone was ignored. - proxying Django through Next rewrites redirect-loops on trailing slashes. Next 308s the slash away before rewrites run, Django's
APPEND_SLASHputs it back, forever. the fix isskipTrailingSlashRedirect: trueplus re-appending the slash in the rewrite destination. - browsers cache permanent redirects. anyone who visited during the loop had the 308/301 pair cached and kept looping entirely from cache, with the server fixed and innocent. API calls now set
cache: 'no-store', which is correct for dynamic data anyway and heals poisoned caches.
closing thoughts
the stack is boring on purpose: Django, DRF, Celery, Postgres, Next.js. the interesting parts are the seams. pick row-level tenancy when your product is a cross-tenant query, make the isolation failure mode a crash instead of a leak, never let a fan-out task raise, and give every payment confirmation exactly one door in. seventy-plus tests cover the scoping guardrail, the seat-lock races, the dedupe rules, and webhook idempotency, and the whole thing runs locally with nothing but uv and SQLite.
twende zetu.
