# Trayek Bus Booking

> 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.

Sell every leg of every seat, exactly once.

- URL: https://weaveward.com/work/trayek-intercity-bus-booking
- Year: 2026
- Industry: Transportation
- Capabilities: Web apps, Business systems
- Technologies: Cloudflare Workers, Durable Objects, Cloudflare D1, Hono, TypeScript, React, Vite, TanStack Query, Tailwind CSS, Zod, Web Crypto API, Playwright

## 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
- **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

### 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.

### 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.

### 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.

### 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.

- **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
- **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
- **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
- **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
- **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.** 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](/services/web-apps) and [business systems](/services/business-systems), or [talk to us about your routes](/contact).
