Bonaventure OgetoBy Bonaventure Ogeto|

Build a Flight or Bus Ticket Booking System

Build a ticket booking system by modeling routes with scheduled departures, tracking seat inventory per departure, collecting payment via Paystack before confirming the booking, and issuing e-tickets after webhook confirmation. Use a seat lock with expiry during checkout to prevent double-booking under concurrent load.

Route and Seat Inventory

Tables: routes (origin, destination, operator, distance_km), departures (route_id, departs_at, vehicle_type, total_seats, available_seats, base_price), bookings (departure_id, passenger_name, passenger_email, passenger_phone, seat_number, amount_paid, status, paystack_ref, ticket_code).

async function searchRoutes(origin, destination, date) {
  return db.query(
    `SELECT d.*, r.origin, r.destination, r.operator
     FROM departures d
     JOIN routes r ON d.route_id = r.id
     WHERE r.origin = $1 AND r.destination = $2
     AND DATE(d.departs_at) = $3
     AND d.available_seats > 0
     ORDER BY d.departs_at`,
    [origin, destination, date]
  );
}

async function initiateBooking(departureId, passenger, seats) {
  var departure = await db.departures.findById(departureId);
  if (departure.available_seats < seats) throw new Error('Not enough seats');

  var amount = departure.base_price * seats;
  var ticketCode = 'TKT-' + Math.random().toString(36).slice(2, 10).toUpperCase();
  var reference = 'BUS-' + Date.now();

  var booking = await db.bookings.create({
    departure_id: departureId,
    passenger_name: passenger.name,
    passenger_email: passenger.email,
    passenger_phone: passenger.phone,
    seats_count: seats,
    amount_paid: amount,
    status: 'pending',
    paystack_ref: reference,
    ticket_code: ticketCode,
  });

  // Temporarily hold seats
  await db.departures.decrement(departureId, 'available_seats', seats);

  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: passenger.email,
      amount,
      currency: 'NGN',
      reference,
      metadata: { booking_id: booking.id, route: departure.route_id, departs_at: departure.departs_at },
    }),
  });
  return (await res.json()).data.authorization_url;
}

Seats are decremented immediately on booking initiation (temporary hold). If payment does not complete within 15 minutes, a cron job restores the seats and cancels the pending booking.

Webhook Confirmation and E-Ticket

if (event.event === 'charge.success') {
  var bookingId = event.data.metadata.booking_id;
  var booking = await db.bookings.findById(bookingId);
  if (booking.status !== 'pending') return res.sendStatus(200);

  await db.bookings.update(bookingId, { status: 'confirmed', paystack_txn_id: event.data.id });
  await sendEticketEmail(booking); // PDF attachment with ticket code + QR
}

// Cancellation and refund
async function cancelBooking(bookingId, reason) {
  var booking = await db.bookings.findById(bookingId);
  var departure = await db.departures.findById(booking.departure_id);
  var hoursUntilDeparture = (new Date(departure.departs_at) - new Date()) / 3600000;

  var refundAmount = 0;
  if (hoursUntilDeparture > 48) refundAmount = booking.amount_paid;
  else if (hoursUntilDeparture > 24) refundAmount = Math.floor(booking.amount_paid * 0.5);

  if (refundAmount > 0) {
    await fetch('https://api.paystack.co/refund', {
      method: 'POST',
      headers: { Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY, 'Content-Type': 'application/json' },
      body: JSON.stringify({ transaction: booking.paystack_ref, amount: refundAmount }),
    });
  }

  await db.bookings.update(bookingId, { status: 'cancelled', cancel_reason: reason });
  await db.departures.increment(booking.departure_id, 'available_seats', booking.seats_count);
}

Learn More

See build a cinema ticketing system for the same seat-locking and e-ticket pattern in a different context.

Sign up for the McTaba newsletter

Key Takeaways

  • Lock seats temporarily during checkout (10-15 min) and release them if payment does not complete.
  • Issue e-tickets only after the charge.success webhook — redirect confirmation is unreliable.
  • Store route, departure, origin, destination, and passenger name in Paystack metadata for clean audit trails.
  • For bus operators (multiple companies), use subaccounts to route each operator's revenue to their bank account.
  • Handle partial refunds for cancellations based on departure proximity — full refund 48+ hours out.

Frequently Asked Questions

How do I handle the 15-minute seat hold expiry?
Run a cron job every minute that queries for bookings with status "pending" and created_at older than 15 minutes. For each expired booking, restore the available_seats count on the departure and mark the booking as "expired". The customer sees a "session expired, please try again" message.
Can I support multiple bus operators under one platform?
Yes. Create a Paystack subaccount for each operator. When initializing the booking payment, include the operator's subaccount code. The operator gets their share at settlement automatically. Your platform keeps the booking fee as commission. Each operator can have a different commission rate based on your agreements.
How do I generate PDF e-tickets?
Use PDFKit or Puppeteer on the backend to generate a PDF with the passenger name, route, departure time, seat number, ticket code, and a QR code. Email it as an attachment after the charge.success webhook fires. Also store a copy in your database or a cloud storage bucket for customer re-download.

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