Bonaventure OgetoBy Bonaventure Ogeto|

Build a Cinema Ticketing System

Build a cinema ticketing system by managing seats with a reserved state that times out unpaid reservations, collecting payment via Paystack before confirming the ticket, and generating a QR code with the booking reference for door scanning. Use webhooks to confirm payment before marking tickets as confirmed.

Seat Reservation with Timeout

Tables: movies, showtimes (movie_id, hall, date, time, seat_map), seats (showtime_id, row, number, category, price, status, reserved_by, reserved_until), bookings (customer_email, showtime_id, seat_ids[], total, status, paystack_ref), tickets (booking_id, seat_id, qr_code).

async function reserveSeats(customerId, showtimeId, seatIds) {
  // Check all seats are available
  var seats = await db.seats.findMany(seatIds);
  var unavailable = seats.filter(s => s.status !== 'available');
  if (unavailable.length > 0) throw new Error('Some seats are no longer available');

  var reservedUntil = new Date(Date.now() + 10 * 60 * 1000); // 10 min

  // Atomic update using transaction
  await db.transaction(async (trx) => {
    for (var seatId of seatIds) {
      await trx.seats.update(seatId, {
        status: 'reserved',
        reserved_by: customerId,
        reserved_until: reservedUntil,
      });
    }
  });

  var total = seats.reduce((sum, s) => sum + s.price, 0);
  return { total, reservedUntil };
}

// Cron job: release expired reservations every minute
async function releaseExpiredReservations() {
  await db.seats.updateWhere(
    { status: 'reserved', reserved_until_lt: new Date() },
    { status: 'available', reserved_by: null, reserved_until: null }
  );
}

Payment and Ticket Generation

var QRCode = require('qrcode');

async function initiatePayment(customerId, customerEmail, showtimeId, seatIds) {
  var seats = await db.seats.findMany(seatIds);
  var total = seats.reduce((sum, s) => sum + s.price, 0);
  var reference = 'TKT-' + Date.now();

  var booking = await db.bookings.create({
    customer_email: customerEmail, showtime_id: showtimeId,
    seat_ids: seatIds, total, status: 'pending', paystack_ref: reference,
  });

  var res = await fetch('https://api.paystack.co/transaction/initialize', {
    method: 'POST',
    headers: { Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      email: customerEmail, amount: total, currency: 'NGN',
      reference, metadata: { booking_id: booking.id },
    }),
  });
  return (await res.json()).data.authorization_url;
}

// Webhook: confirm booking and generate tickets
if (event.event === 'charge.success') {
  var bookingId = event.data.metadata.booking_id;
  var booking = await db.bookings.findById(bookingId);

  await db.bookings.update(bookingId, { status: 'confirmed' });
  await db.seats.updateMany(booking.seat_ids, { status: 'sold' });

  // Generate one QR code per seat
  for (var seatId of booking.seat_ids) {
    var ticketRef = bookingId + '-' + seatId;
    var qrCode = await QRCode.toDataURL(ticketRef); // base64 PNG
    await db.tickets.create({ booking_id: bookingId, seat_id: seatId, qr_code: qrCode, ref: ticketRef });
  }

  await sendTicketEmail(booking.customer_email, bookingId);
}

Learn More

See build a ticketed webinar platform for the same payment-before-access pattern applied to online events.

Sign up for the McTaba newsletter

Key Takeaways

  • Reserve seats for 10 minutes when a customer starts checkout — release them if payment does not complete.
  • Never confirm a ticket until the payment webhook fires — the redirect can be manipulated.
  • Generate a unique QR code per ticket that encodes the booking reference for door validation.
  • Store seat state as available, reserved (with timeout), or sold — update atomically to prevent double-booking.
  • Support group bookings: one payment, multiple seats, each getting its own ticket QR code.

Frequently Asked Questions

How do I scan QR codes at the cinema door?
Build a simple door-validation app (mobile or tablet) that scans the QR code and calls your backend endpoint /tickets/validate with the ticket reference. Your backend checks if the ticket exists, is confirmed, has not been used, and marks it as used (scanned_at timestamp). Display a green or red result to the attendant.
What happens if a payment fails but the seats are still reserved?
The seats remain reserved for the 10-minute window, then auto-release. The customer can try again. If the payment fails mid-checkout (card declined), the customer is returned to the seat selection page where they can retry or choose different seats. The reservation timer is not reset on a failed payment.
Can I offer different seat category prices (VIP, standard, economy)?
Yes. Each seat row has a category and price stored in the seats table. When a customer selects seats across categories, your backend sums the individual prices. The Paystack transaction amount is the sum of all selected seat prices. The total is displayed to the customer before they proceed to payment.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.

Apply to the McTaba Marathon