Ticketing Platform Development

Our company is engaged in the development, support and maintenance of sites of any complexity. From simple one-page sites to large-scale cluster systems built on micro services. Experience of developers is confirmed by certificates from vendors.
Development and maintenance of all types of websites:
Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Our competencies:
Development stages
Latest works
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1161
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    822
  • image_crm_chasseurs_493_0.webp
    CRM development for Chasseurs
    847
  • image_website-sbh_0.png
    Website development for SBH Partners
    999
  • image_website-_0.png
    Website development for Red Pear
    451

Ticketing Platform Development

Ticketing platform is a system with strict requirements for data consistency. Several users simultaneously buying the last ticket in row A. Race condition in this case means seats sold "twice" — and refunded money, plus damaged reputation. Correct architecture starts with choosing a locking mechanism.

Seat Model and Reservations

CREATE TABLE events (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organizer_id    UUID NOT NULL REFERENCES users(id),
    title           VARCHAR(300) NOT NULL,
    slug            VARCHAR(300) UNIQUE NOT NULL,
    venue_id        UUID REFERENCES venues(id),
    starts_at       TIMESTAMPTZ NOT NULL,
    ends_at         TIMESTAMPTZ,
    status          VARCHAR(20) NOT NULL DEFAULT 'draft'
                    CHECK (status IN ('draft','on_sale','sold_out','cancelled','completed')),
    timezone        VARCHAR(50) NOT NULL DEFAULT 'Europe/Moscow',
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE ticket_types (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    event_id        UUID NOT NULL REFERENCES events(id),
    name            VARCHAR(200) NOT NULL,   -- 'VIP', 'Standard', 'Student'
    price           NUMERIC(12,2) NOT NULL,
    currency        CHAR(3) NOT NULL DEFAULT 'RUB',
    total_qty       INTEGER NOT NULL,
    reserved_qty    INTEGER NOT NULL DEFAULT 0,
    sold_qty        INTEGER NOT NULL DEFAULT 0,
    sale_starts_at  TIMESTAMPTZ,
    sale_ends_at    TIMESTAMPTZ,
    max_per_order   INTEGER NOT NULL DEFAULT 10,
    CONSTRAINT qty_valid CHECK (sold_qty + reserved_qty <= total_qty)
);

CREATE TABLE seats (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    ticket_type_id  UUID NOT NULL REFERENCES ticket_types(id),
    section         VARCHAR(50),
    row             VARCHAR(10),
    number          VARCHAR(10),
    status          VARCHAR(20) NOT NULL DEFAULT 'available'
                    CHECK (status IN ('available','reserved','sold','blocked')),
    reserved_until  TIMESTAMPTZ,    -- when reservation expires
    order_id        UUID,
    UNIQUE (ticket_type_id, section, row, number)
);

CREATE INDEX idx_seats_available
    ON seats(ticket_type_id, status)
    WHERE status = 'available';

Optimistic Seat Locking

Naive SELECT → UPDATE will cause race condition. Correct approach — SELECT FOR UPDATE SKIP LOCKED:

from django.db import transaction
from django.utils import timezone
from datetime import timedelta


def reserve_seats(ticket_type_id: str, qty: int, session_id: str) -> list:
    """
    Reserve N seats for 15 minutes for purchase session.
    Returns list of seat_id or raises if insufficient.
    """
    with transaction.atomic():
        # Lock rows without waiting — other transactions skip locked rows
        seats = (
            Seat.objects
            .select_for_update(skip_locked=True)
            .filter(
                ticket_type_id=ticket_type_id,
                status='available'
            )
            .order_by('section', 'row', 'number')[:qty]
        )

        if len(seats) < qty:
            raise InsufficientSeatsError(
                f'Available {len(seats)} seats, requested {qty}'
            )

        seat_ids = [seat.id for seat in seats]
        reserved_until = timezone.now() + timedelta(minutes=15)

        Seat.objects.filter(id__in=seat_ids).update(
            status='reserved',
            reserved_until=reserved_until,
            order_id=None,  # will be filled after payment
        )

        TicketType.objects.filter(id=ticket_type_id).update(
            reserved_qty=F('reserved_qty') + qty
        )

        # Save in Redis for quick access
        redis_client.setex(
            f'reservation:{session_id}:{ticket_type_id}',
            900,  # 15 minutes in seconds
            json.dumps(seat_ids)
        )

        return seat_ids

Releasing Expired Reservations

@shared_task
def release_expired_reservations():
    """Celery beat: every 2 minutes"""
    now = timezone.now()
    expired_seats = Seat.objects.filter(
        status='reserved',
        reserved_until__lt=now,
        order_id__isnull=True
    )

    seat_ids = list(expired_seats.values_list('id', flat=True))
    if not seat_ids:
        return

    # Group by ticket_type to update counts
    type_counts = (
        Seat.objects
        .filter(id__in=seat_ids)
        .values('ticket_type_id')
        .annotate(cnt=Count('id'))
    )

    with transaction.atomic():
        Seat.objects.filter(id__in=seat_ids).update(
            status='available',
            reserved_until=None,
        )
        for row in type_counts:
            TicketType.objects.filter(id=row['ticket_type_id']).update(
                reserved_qty=F('reserved_qty') - row['cnt']
            )

    # Notify via WebSocket — tickets available again
    for row in type_counts:
        channel_layer.group_send(
            f'event_{row["ticket_type_id"]}',
            {'type': 'seats_released', 'count': row['cnt']}
        )

Ticket QR Code and Entry Verification

import qrcode
import hmac
import hashlib
from io import BytesIO


def generate_ticket_token(ticket_id: str, secret: str) -> str:
    """HMAC-signed token for QR code"""
    msg = f'ticket:{ticket_id}'.encode()
    signature = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
    return f'{ticket_id}:{signature[:16]}'


def verify_ticket_token(token: str, secret: str) -> str | None:
    """Returns ticket_id if token is valid, else None"""
    parts = token.split(':')
    if len(parts) != 2:
        return None

    ticket_id, provided_sig = parts
    expected_token = generate_ticket_token(ticket_id, secret)
    expected_sig = expected_token.split(':')[1]

    if hmac.compare_digest(provided_sig, expected_sig):
        return ticket_id
    return None


def generate_qr_image(ticket) -> bytes:
    token = generate_ticket_token(str(ticket.id), settings.TICKET_SECRET)
    qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_H)
    qr.add_data(f'https://tickets.site.com/verify/{token}')
    qr.make(fit=True)
    img = qr.make_image(fill_color='black', back_color='white')
    buf = BytesIO()
    img.save(buf, format='PNG')
    return buf.getvalue()

API for entry scanners:

# POST /api/v1/tickets/verify
def verify_ticket_view(request):
    token = request.data.get('token')
    ticket_id = verify_ticket_token(token, settings.TICKET_SECRET)

    if not ticket_id:
        return Response({'valid': False, 'reason': 'invalid_token'}, status=400)

    ticket = Ticket.objects.select_related('event', 'seat').get(id=ticket_id)

    if ticket.scanned_at:
        return Response({
            'valid': False,
            'reason': 'already_used',
            'scanned_at': ticket.scanned_at.isoformat(),
        }, status=400)

    if ticket.event.starts_at.date() != date.today():
        return Response({'valid': False, 'reason': 'wrong_date'}, status=400)

    ticket.scanned_at = timezone.now()
    ticket.save()

    return Response({
        'valid': True,
        'holder': ticket.holder_name,
        'seat': f'{ticket.seat.section} row {ticket.seat.row} seat {ticket.seat.number}',
        'ticket_type': ticket.ticket_type.name,
    })

Dynamic Pricing

Price increases as hall fills — standard practice for concerts:

def get_current_price(ticket_type) -> Decimal:
    """Dynamic price based on % of sold tickets"""
    sold_ratio = ticket_type.sold_qty / ticket_type.total_qty

    # Fill thresholds
    tiers = [
        (0.0,  0.0),   # 0–50%: base price
        (0.5,  0.10),  # 50–70%: +10%
        (0.7,  0.25),  # 70–85%: +25%
        (0.85, 0.50),  # 85–95%: +50%
        (0.95, 1.00),  # 95–100%: +100%
    ]

    multiplier = Decimal('1.0')
    for threshold, increase in reversed(tiers):
        if sold_ratio >= threshold:
            multiplier = Decimal(str(1 + increase))
            break

    return (ticket_type.base_price * multiplier).quantize(Decimal('1'))

Payment Flow Schema

  1. User selects seats → reserve_seats() → 15-minute hold
  2. Proceed to payment → create Order with pending status
  3. Payment via YooKassa or Stripe → webhook confirms
  4. Order.status = 'paid' → seats move to sold → generate PDF tickets
  5. Email with tickets → PDF attachment + QR code

Timeline

Basic ticketing platform without seating map (only ticket types, quantity): 4–6 weeks. Full system with interactive seating map (SVG), dynamic pricing, scanner and organizer dashboard: 3–4 months.