Case study
TicketForge: event ticketing where each seat sells once
TicketForge covers the whole flow of selling tickets: browsing and search, reserved seats or general admission, checkout, emailed PDF tickets, door scanning, refunds, and a dashboard for organizers. I built it around the parts of ticketing that break when many people buy at once.
- Python · Django · FastAPI · MySQL
- 241 tests
- 92% coverage
- MIT license
Live demo ↗API docs ↗Code on GitHub ↗
The demo is real, but payments are simulated and nothing is for sale.
What it does

An event page on the live demo. Outlined seats are free, grey ones are sold.
For fans
- Sign up, verify the email address, log in, reset a password. Five failed logins in 15 minutes lock the account for the rest of that window.
- Search events with typo tolerance and filters for city, category, date and price (Meilisearch, with a database fallback if it is down).
- Pick seats on a live seat map, or a quantity of general admission tickets. When someone else takes a seat, it shows as taken on every open map, without a refresh.
- Hold the selection for 10 minutes with a countdown, pay, and get the tickets by email as a PDF with a QR code.
- Request a refund, unless a ticket on the order was already scanned at the door.
For organizers
- Create venues, sections and seats (one by one or from a CSV file), events and ticket types through the organizer API.
- Publish or cancel events. Cancelling refunds every paid order.
- A dashboard with revenue, tickets sold, check-ins, a daily revenue chart and new sales arriving live.
- Approve or reject refund requests, add door staff, and scan tickets with a phone camera or a handheld scanner.
How it is built
Two Python services share one login. Each owns its own MySQL schema and is the only one that writes to it; the other reads it with plain SQL. Everything starts with one docker compose command.
- nginx
- TLS, rate limits (20 requests a second per address), and the WebSocket upgrade.
- Django
- Accounts, organizers, the admin and the server-rendered pages. It issues the JWT that both services accept, and owns the core MySQL schema (users, venues, events, ticket types).
- FastAPI
- The public catalogue, holds, checkout, Stripe webhooks and ticket scanning. It owns the checkout schema (seats, holds, orders, tickets, refunds) and migrates it with Alembic.
- Channels
- WebSockets for seat maps, hold countdowns and organizer sales.
- Celery
- Emails, PDF tickets, releasing expired holds every 30 seconds, and checking payments against Stripe every night.
A seat can only be sold once
Taking a seat is one conditional UPDATE: change it to held, but only where it is still available. If one row changed, the seat is yours. If none did, someone got there first, the whole hold is rolled back and the API answers 409 SEAT_UNAVAILABLE. Seats in one hold are taken in ascending id order, so two overlapping holds cannot deadlock.
for seat_id in seat_ids:
result = session.execute(
update(EventSeat)
.where(
EventSeat.event_id == event_id,
EventSeat.seat_id == seat_id,
EventSeat.status == "available",
)
.values(status="held", hold_id=hold.id, version=EventSeat.version + 1)
)
if affected(result) != 1:
session.rollback()
raise SeatUnavailable(
"Someone just took one of those seats.", {"seat_ids": [seat_id]}
)The test for it: 50 threads wait at a barrier, then all try to hold the same seat at the same moment, against a real MySQL database. Exactly one wins and 49 get a conflict.
def test_fifty_threads_one_seat_exactly_one_winner(seated_event):
buyers = [UserFactory() for _ in range(50)]
seat_id = seated_event.seats[0].id
barrier = threading.Barrier(len(buyers))
def attempt(user_id: int) -> str:
session = session_factory()()
try:
barrier.wait(timeout=10)
holds.create_hold(
session, user_id=user_id, event_id=seated_event.event.pk, seat_ids=[seat_id]
)
return "won"
except SeatUnavailable:
return "conflict"
finally:
session.close()
with ThreadPoolExecutor(max_workers=len(buyers)) as pool:
results = list(pool.map(attempt, [buyer.pk for buyer in buyers]))
assert results.count("won") == 1
assert results.count("conflict") == 49Payments that are safe to retry
Stripe can send the same webhook more than once, and out of order. Each event id goes into a ledger table with a UNIQUE index, in the same transaction as the order change. A repeat fails the insert and does nothing.
record = StripeEvent(stripe_event_id=stripe_event_id, type=event_type, payload=payload)
session.add(record)
try:
session.flush()
except IntegrityError:
session.rollback()
logger.info(
"duplicate stripe event ignored",
extra={"extra_fields": {"stripe_event_id": stripe_event_id}},
)
return {"status": "duplicate", "result": "already_handled"}Starting a payment takes an Idempotency-Key, and each hold can have only one order, so a double click or a retry never charges twice.
A payment that arrives after the hold ran out
Holds last 10 minutes. If the card is charged after that, the seats are taken again inside a savepoint. If they are still free, the order goes through. If someone else bought them, the payment is refunded automatically and the customer gets an email explaining why.
hold = session.scalars(select(Hold).where(Hold.id == order.hold_id).with_for_update()).one()
now = utcnow()
if hold.status == "active" and hold.expires_at > now:
_convert(session, order, hold)
return "fulfilled"
if hold.status == "active":
release_hold(session, hold, status="expired")
session.flush()
if _try_reacquire(session, hold):
hold.status = "active"
_convert(session, order, hold)
return "fulfilled_after_expiry"
_refund_late_payment(session, order, gateway)
return "refunded_late_payment"Tests and checks
The latest CI run (7 Nov 2026): 241 tests passed, with 92% coverage. The build fails under 85%. Tests run against real MySQL and Redis, because the locking being tested only exists in a real database. Every push also runs ruff, mypy, a check for missing migrations, pip-audit and a Docker build.
- 40 buyers racing for a general admission pool smaller than the crowd.
- A Hypothesis property test: random holds, cancels and expiries, then checks every seat is in exactly one state.
- Webhook replays, out-of-order events, and a payment after its hold expired.
- A second scan of the same ticket is rejected, with the time of the first scan.
- A Content Security Policy with no inline code, and a check that no page needs it.
Load test with Locust, 500 users browsing before a big on-sale, on an Apple M2 MacBook Air, in Docker: 379 requests a second, median 9 ms, 95th percentile 84 ms, 0 failures.