PROJECT STUDY

Trayek Bus Booking

Sell every leg of every seat, exactly once.

A booking system for Indonesian intercity bus and shuttle operators: one seat inventory per trip, sold leg by leg, with expiring holds, signed payment webhooks and QR boarding.

Explore the build
Illustration of the booking flow: a phone seat map for Bandung to Cikampek holding seat 3C under a countdown, a route bar that highlights the rider's leg of a Bandung to Jakarta trip, and a QR e-ticket for seat 3C.
Original interface illustration · Synthetic demo data.View larger

At a glance

Industry
Transportation
Capabilities
6
Technologies
12
Year
2026

The challenge

Many intercity bus and shuttle operators in Indonesia still sell seats by phone, WhatsApp and through agents.

The same seat can be promised twice, and the passenger manifest is often written by hand.

Multi-stop routes leak capacity as well. A seat sold from the first stop to the second usually rides empty for the rest of the trip, because nobody can safely resell just the remaining legs, and around Lebaran and long weekends every channel competes for the same seats at once.

What we built

Trayek, Indonesian for a bus route, is one booking system for riders and operators, with every seat sold leg by leg from a single inventory per trip.

Leg-aware seat inventory

One Durable Object per trip keeps every seat as a bitmask of the legs between stops, so a seat sold from Jakarta to Cikampek stays on sale from Cikampek onwards. Every booking goes through the same API and records its sales channel (web, agent, counter or WhatsApp), so all channels draw on one inventory

Leg-aware seat inventory

One Durable Object per trip keeps every seat as a bitmask of the legs between stops, so a seat sold from Jakarta to Cikampek stays on sale from Cikampek onwards. Every booking goes through the same API and records its sales channel (web, agent, counter or WhatsApp), so all channels draw on one inventory

Race-safe holds that expire

A rider's seats are held for 10 minutes while they pay, and two holds over overlapping legs can never both succeed. A lapsed hold frees its seats at once, and a Durable Object alarm marks it expired and updates the booking

Payments with signed webhooks

QRIS and virtual account charges are created through a provider adapter. Webhooks are HMAC-SHA256 signed with a timestamp window, processed once per event and applied through a payment state machine that reaches the same result in any arrival order

Signed e-tickets and boarding

Each seat gets an Ed25519-signed QR ticket. The boarding desk accepts it once and only at the passenger's boarding stop, and a repeat scan reports when the passenger first boarded

Operator console

Trips with the load on every leg, a seat map per leg, a seat-by-leg matrix that flags resold seats, a manifest for each stop showing who boards and who alights, a boarding desk and revenue by route, payment method and sales channel

Passenger app in Bahasa Indonesia

Search by city and date, results priced for the chosen segment, a seat map with a hold countdown, passenger details, payment and e-tickets, designed for phones first

How it works

  1. Search a segment

    The Worker finds trips whose route visits the origin city before the destination on the chosen date in Western Indonesian Time, asks each trip's Durable Object for the free seats on those legs and prices the segment. A fare adds up the leg fares of the segment and rises with the busiest leg's load, with a weekend uplift and a cap, and trips whose boarding stop is less than 15 minutes away are left out.

  2. Hold seats while paying

    The trip's Durable Object checks and writes the hold in one synchronous step and returns the leg loads it saw, which lock the fare into the booking. The booking is stored in D1 with its expiry and a SHA-256 hash of an access token that only the rider receives, and the payment request expires 60 seconds before the hold.

  3. Confirm from the webhook

    A verified paid event confirms the seats in the trip's Durable Object first, then writes the booking, passengers, tickets, payment status and the processed event in one D1 batch. Both steps are idempotent, so a provider's retry repeats them safely.

  4. Board stop by stop

    At the boarding desk, the crew picks the stop and enters the code from the rider's QR ticket. The server verifies the signature, the trip and the stop before marking the passenger boarded, and the manifest for each stop shows who boards and alights there, with each passenger's boarded status.

Engineering insights

Trayek runs as one Cloudflare Worker: a Hono API that also serves the React app as static assets, one SQLite-backed Durable Object per trip as the only writer of that trip's seats, holds and sales, and D1 for the catalogue, bookings, passengers, tickets, payments and the webhook event log.

Stack by layer

Frontend
  • React
  • TypeScript
  • Vite
  • React Router
  • TanStack Query
  • Tailwind CSS
  • qrcode.react
Backend
  • Hono
  • TypeScript
  • Durable Objects
  • Zod
  • Web Crypto API
Data
  • Cloudflare D1
  • SQLite-backed Durable Objects
Infrastructure
  • Cloudflare Workers
  • Workers Static Assets
  • Wrangler
  • Vitest
  • Playwright
  1. One trip object, every seat a leg bitmask

    Each trip's Durable Object stores every sale and live hold as a bitmask of the legs it covers, so an overlap check is a single AND and resale after a passenger alights needs no special case. The check and the write run in one synchronous transaction with no await in between, and integration tests fire concurrent holds to show that holds over overlapping legs never both succeed

  2. Hold expiry enforced on read

    Every availability read counts only holds whose expiry is still in the future, so a lapsed hold frees its seats the moment it lapses. The object's single alarm is re-armed to the earliest pending expiry and only tidies up, marking holds and their D1 bookings expired idempotently, so a late or retried alarm can never double-sell a seat

  3. Payment events converge in any order

    Webhooks are verified with a timestamped HMAC-SHA256 through Web Crypto's constant-time verify, recorded once per event ID and checked against the charged amount. Statuses follow a precedence (pending, then failed or expired, then paid, with refunded only from paid), and the SQL update is guarded by the same table, so redelivered, reordered or concurrent events end in one state

  4. Late payments re-seat riders or flag refunds

    When money lands after a hold has lapsed, confirmation first tries the same seats, then free seats on the same legs, preferring one row, and otherwise marks the booking for a refund. The payment window closes 60 seconds before the hold, so this path stays the exception

  5. Public-key tickets that board once

    Each e-ticket token is signed with Ed25519, so checking it needs only the public key the API publishes, and a scanning device never has to hold a secret that could mint tickets. Check-in rejects tampered, expired or other-trip tokens, names the right stop for a passenger at the wrong one and boards with a conditional update, so a repeat scan reports the first boarding time

Design system

The Trayek design language is warm, trustworthy and mobile first, made for riders booking on their phones at terminals and roadside stops. Deep teal carries trust and navigation, sunrise orange marks only the step that moves a booking forward, and a cream page keeps a dense seat map calm.

Colour palette

  • Deep teal#0f4c5cHeaders, navigation, links, primary buttons and the rider's own seat
  • Sunrise orange#f28f3bCalls to action only, always with ink text
  • Cream#fff8f0Page background
  • Ink#1b2a33Body text, seat outlines and text on orange and grey
  • Sold grey#9aa5abSold seats, always with an ink hatch, an ink outline and a cross glyph
  • Held sand#f1e3cfSeats held by other riders, with an ink dot pattern and a dashed outline
  • Colour

    Deep teal for headers, navigation, primary buttons and the rider's own seat, sunrise orange only for calls to action such as Tahan kursi (hold seat), and cream pages under white cards. Sold seats are grey with an ink hatch, and seats held by other riders are sand with ink dots

  • Typography

    Plus Jakarta Sans, a typeface designed for Jakarta's city identity, bundled as a variable font so the app makes no third-party font request. Extra bold sets prices, times and the hold countdown, and tabular figures keep digits from shifting as they change

  • Components

    In-house React components on native elements, styled with Tailwind CSS v4 tokens: a seat map laid out like the vehicle, a leg stepper that shows which part of the route the seats are for, a sticky hold countdown and, for operators, leg load bars, a seat-by-leg matrix and a manifest per stop

  • Accessibility

    Every seat state pairs colour with a pattern, a glyph and a label, and each seat button is named for screen readers with its seat, state and segment. Text on orange is always ink, which passes WCAG AA where white would not. Touch targets are at least 44 px, and the hold warning is announced once through a polite live region

Where this fits

Operators who sell the same seats through several channels, or run multi-stop routes, can build on this inventory model, and the same pattern suits any booking where one resource is sold in slices. Explore our web applications and business systems, or talk to us about your routes.

talk to us about your routes

Let’s connect the dots

Let's put these ideas to work.

Share a process, product, or problem you want to improve. We will work through the possibilities together.

Discuss your project