Lulan is an open-source, API-first reservation platform for airlines, buses, ferries, rail, and any operator that sells capacity instead of products. It runs in two modes behind one API: a complete standalone booking engine — inventory, pricing, payments, QR ticketing, offline validation, end to end — or an orchestration layer that owns the customer-facing reservation experience and synchronizes confirmed bookings into the operational systems you already run (airline PSS, ferry manifest backend, bus dispatch) through sync connectors.
Built in Rust: segment-aware seat inventory, race-free reservations under high concurrency, event-sourced order lifecycle, a sandboxed WebAssembly pricing engine, and cryptographically signed QR tickets that validate fully offline.
Think of commerce tools, but for seats, cabins, vehicle slots, and cargo holds" — inventory that exists in space and time, not on a shelf.
- Why an open-source transit reservation system?
- Key features
- How it works
- Quick start
- API overview
- Bring your own providers
- Architecture
- Benchmarks
- Project status & roadmap
- Use cases
- License
Most transportation operators still run on legacy reservation software: expensive licenses, proprietary lock-in, monolithic deployments, and booking conflicts the moment demand spikes. General-purpose e-commerce platforms don't help — they assume inventory is static. Transit inventory isn't:
A ─── B ─── C ─── D Seat 12A on one departure:
A → B Reserved the same physical seat is sold inventory
B → C Available on one journey segment and open capacity
C → D Reserved on the next.
Selling a seat means atomically claiming a span of segments on a specific departure, while hundreds of other buyers race you for the same span. Lulan is a reservation engine built for exactly that problem — and it is verified, not just claimed: the invariant harness fires 10,000 simultaneous contenders at 52 seats and records zero double-sells, including with Redis killed mid-run.
| Capability | What it means |
|---|---|
| Segment-aware inventory | Seats, cabins, vehicle decks, and cargo pools tracked per journey segment with bitmask occupancy — the PRD's "12A is free B→C but taken A→B" answered in one query. |
| Race-free booking at scale | Redis-backed soft holds + guarded PostgreSQL claims. Zero double-sells at 10k concurrent contenders (chaos-tested). |
| Event-sourced orders | Every order is an append-only event stream (order_created → … → passenger_boarded) with a transactional outbox; replaying events reproduces the read model exactly. |
| Multi-passenger itineraries | One order, N passengers; per-passenger seats and fares, including regulated concession fares (child, senior, disability) that many markets mandate by law. |
| Pluggable pricing (WASM) | Deterministic integer-only fare rules, plus operator-supplied pricing modules run in a fuel-metered, memory-capped WebAssembly sandbox with no host imports — measured at p95 ≈ 443 µs per quote. Native and WASM engines are property-tested to be bit-identical. |
| Signed quotes | Short-TTL HMAC quote tokens: the price shown is the price charged, tamper-proof. |
| Offline ticket validation | Ed25519-signed CBOR QR tickets (~245 bytes). The MIT lulan-validate crate verifies them with no server, no clock assumptions, and compiles to WebAssembly for browser and mobile boarding apps. |
| Offline boarding sync | Gate and crew devices journal scans locally and sync idempotent batches when connectivity returns; duplicate and cloned-QR scans are detected and flagged. |
| Headless & self-hostable | JSON REST APIs only — bring your own storefront, kiosk, or POS. One Docker image, PostgreSQL + Redis, no per-booking fees. |
A booking flows through seven stages — two optional, all independently verifiable:
- Search & availability —
GET /v1/trips/searchreturns candidate trips per leg (one-way or round-trip), each with operator, service number, vehicle, schedule, and span-aware seat/pool availability.GET /v1/trips/{id}/availabilitydrills into the seat map, including which seats other sessions currently hold. - Hold (optional) —
POST /v1/holdssoft-holds the selected seats across every leg as one itinerary hold with a countdown (expires_at, operator-configurable). Expired holds auto-release with zero cleanup; buying with an expired hold is a deterministic 409. Holds never gate the sale — claims at order time are the source of truth. - Add-ons (optional) —
GET /v1/ancillarieslists everything the operator sells alongside the fare (baggage, meals, insurance, priority boarding), per-passenger or per-order, tied to one leg or the whole itinerary. - Quote —
POST /v1/quotesprices the itinerary + add-ons (per passenger type, occupancy, peak day, round-trip and promo discounts) and returns a signed, short-lived quote token locking the full total. - Order —
POST /v1/ordersatomically claims every item on every leg for N passengers; a conflict on any leg rolls back everything. One order, one payment — fares and add-ons together. - Pay & ticket — a payment-provider webhook captures payment and auto-issues one Ed25519-signed QR ticket per passenger per leg.
- Board — even offline — gate devices verify tickets locally against cached public keys (
GET /v1/ticket-keys), then sync their scan journal (POST /v1/scans); the order aggregates to Boarded when the last passenger scans in.
Prerequisites: Rust (edition 2024), Docker, just.
git clone https://github.com/thinkgrid-labs/lulan.git
cd lulan
just up # PostgreSQL 16 + Redis 7 (Docker)
just serve # migrate, seed a sample multi-stop network, serve on :8080Book a ticket end to end:
# 1. Find a departure (sample dataset: a 4-stop ferry line, BTG → … → CEB)
curl "localhost:8080/v1/trips/search?origin=BTG&destination=CEB&departure_date=$(date +%F)"
# 2. Quote a senior + child itinerary (concession fares applied automatically)
curl -X POST localhost:8080/v1/quotes -H 'content-type: application/json' -d '{
"trip_id": "<trip>",
"items": [
{"unit_code": "12C", "origin": "BTG", "destination": "CEB", "passenger_type": "senior"},
{"unit_code": "12D", "origin": "BTG", "destination": "CEB", "passenger_type": "child"}
]}'
# 3. Order with the quote token, pay (fake provider), fetch signed QR tickets
curl -X POST localhost:8080/v1/orders ...
curl -X POST localhost:8080/v1/orders/<id>/payment
curl localhost:8080/v1/orders/<id>/ticketsRun the test suite and the concurrency harness:
just check # fmt + clippy + full test suite
just loadgen 10000 0.5 # 10k contenders, 50% via holds — expect 0 double-sells
just loadgen-paced 200 30 # open-loop 200 req/s — honest seat-lock latencies| Endpoint | Purpose |
|---|---|
GET /v1/trips/search |
One-way / round-trip search: candidate trips per leg with schedule + availability |
GET /v1/trips/{id}/availability |
Per-seat / per-pool availability for a journey span |
POST /v1/holds |
Soft-hold a one-way or round-trip seat selection as one itinerary hold (TTL, auto-release) |
GET /v1/ancillaries |
Add-on catalog: baggage, meals, insurance — whatever the operator sells |
POST /v1/quotes |
Itemised quote for fares + add-ons, locked by a signed token |
POST /v1/orders |
Atomic multi-passenger booking (live-priced or quote-token) |
POST /v1/orders/{id}/payment |
Create payment intent (provider port) |
POST /v1/payments/fake/webhook |
Idempotent capture webhook → auto-issues tickets |
GET /v1/orders/{id}/tickets |
Ed25519-signed QR ticket tokens |
GET /v1/ticket-keys |
Public keys for offline validators |
POST /v1/scans |
Batched, idempotent boarding-scan sync (validator key) |
GET /v1/customers/me/orders |
Authenticated customer's bookings (IdP JWT) |
POST /v1/webhooks |
Register HMAC-signed webhook endpoints (admin key) |
GET /metrics |
Prometheus metrics |
The full surface is documented in the OpenAPI spec — committed at crates/lulan-api/openapi.json and served live at GET /openapi.json. A typed TypeScript client ships as @lulan/storefront-sdk.
Lulan never owns accounts and never touches card data — identity and payments are ports: the core defines the contract, an adapter plugs your provider in. (Reservation Sync Connectors, planned, follow the same pattern for operational systems.)
The core verifies a bearer JWT from your IdP and keeps only an
(issuer, subject) reference. No passwords, resets, sessions, or MFA in
Lulan — ever. One port serves both principals: a plain verified JWT is a
customer; a JWT that matches an enrolled staff row gains an
operator role (admin / ops / support).
# Ships today: HS256 shared-secret JWT (first-party storefront backends)
LULAN_IDP_ISSUER=https://auth.example.com
LULAN_IDP_HS256_SECRET=... # your IdP's signing secret
LULAN_BOOTSTRAP_ADMIN_STAFF='https://auth.example.com|user-id-of-admin'| Use case | How it maps |
|---|---|
| Ferry line with a Next.js storefront using Supabase/Firebase auth | Storefront session JWT goes straight to Lulan as the customer token — bookings attach to the customer, GET /v1/customers/me/orders lists them |
| Bus company on Auth0 / Clerk / Keycloak | Same trait, JWKS (RS256) adapter — planned; one adapter covers all JWKS-publishing IdPs |
| Walk-up / kiosk sales, no accounts at all | Skip the IdP entirely: guest checkout is first-class — guest_contact + an HMAC retrieval token (magic link) per order |
| Back-office staff signing into the admin app | Same IdP login; an admin enrols their identity via POST /v1/admin/staff with a role — every action they take is audited by name |
The engine reconciles provider webhooks against the order state machine; the provider port is two calls:
pub trait PaymentProvider {
/// Charge amount_minor for an order → provider intent (e.g. pi_…).
async fn create_intent(&self, order_id, amount_minor, currency) -> PaymentIntent;
/// Full refund of a captured intent (admin refunds, trip cancellation).
async fn refund(&self, payment_intent_id, amount_minor) -> Result<()>;
}Ships today: FakeProvider (drives dev, tests, and every demo — including
the async webhook flow, exactly like a real provider). Real adapters are
adapter-sized work, not engine work:
| Use case | How it maps |
|---|---|
| Global card payments (Stripe) | create_intent → PaymentIntent; Stripe's payment_intent.succeeded webhook hits Lulan's capture endpoint → order Paid → tickets auto-issue |
| PH market e-wallets — GCash/Maya via Xendit or PayMongo | Same flow; the intent id is the provider's charge/checkout id |
| Cash at a counter / agent network | A trusted integration API key confirms payment through the same webhook path — the state machine doesn't care who captured |
| Trip cancelled by ops, or support refunds a booking | Lulan calls refund() before releasing seats and voiding tickets — money moves first, inventory second |
Payment capture is idempotent (duplicate and out-of-order webhooks are
acknowledged, never re-applied), and Idempotency-Key on order creation
makes client retries double-booking-proof end to end.
Your storefront / kiosk / POS / boarding app
│
JSON REST · @lulan/storefront-sdk (TS)
│
┌─────────▼──────────┐
│ lulan-api │ Axum · HTTP/2
├────────────────────┤
│ lulan-engine │ inventory · orders · tickets
│ lulan-pricing │ native + WASM sandbox (wasmtime)
└─┬───────┬────────┬─┘
│ │ │
PostgreSQL Redis *.wasm pricing modules
(truth + (holds, (operator-supplied,
events) cache) no host imports)
Offline edge: lulan-validate (MIT, wasm32) verifies tickets
with zero server dependency.
Real numbers, adversarial shapes, published in docs/benchmarks.md:
- 0 double-sells across 10,000 simultaneous contenders on one 52-seat vehicle — repeated with Redis killed mid-run (chaos test).
- WASM pricing: p50 347 µs / p95 443 µs per quote including per-call instantiation (PRD target < 5 ms), enforced by a CI assertion.
- Ticket QR payload: ~245 bytes signed (target < 400 bytes for low-error-correction QR).
⚠️ Pre-1.0, active development. APIs may change until the first stable release. Verified working today — the checked items below are implemented and covered by the test suite.
- Segment-aware inventory engine (seats, pools, span claims)
- Soft holds + race-free claims (0 double-sells @ 10k contenders)
- Event-sourced order lifecycle with payment-provider port
- Pricing engine — native + sandboxed WASM modules, signed quotes
- Multi-passenger orders with passenger-type fares
- Ed25519 QR ticketing + offline validation (
lulan-validate) - Offline boarding-scan sync with replay/clone detection
- Webhooks: HMAC-signed deliveries with durable retries
- Authentication: API keys + roles, identity-provider port, guest checkout with retrieval tokens
- Idempotent booking retries + per-caller rate limiting
- OpenAPI spec (served at
/openapi.json) + TypeScript SDK (@lulan/storefront-sdk) - Prometheus
/metrics(OTLP traces planned) - Itineraries: one-way, round-trip & multi-city (one atomic order across legs, round-trip fares)
- Itinerary holds: one hold across all legs, TTL auto-release, live held-seat map
- Ancillaries: operator add-on catalog (baggage, meals, insurance) priced into quotes & orders
- Production deploys: Compose (external or bundled databases, auto-TLS) + Helm chart
- GTFS importer — bring your existing schedule feed
- Open-loop benchmark mode (published seat-lock latencies vs the <20 ms target)
- Admin operations API: staff RBAC (IdP-backed), network & schedule management, fare publishing with rollback, manifests, refunds — with
@lulan/admin-sdk - Reference Next.js storefront + React Native boarding-crew app
-
@lulan/validatenpm package (WASM build of the validator) - Reservation Sync Connectors — first-class orchestrated mode (PSS / manifest / dispatch sync, external-ref mapping)
Lulan models any business that reserves capacity over space and time: regional and low-cost airlines, intercity and commuter bus lines, ferries and RoRo vessels, rail and metro networks, shuttle and van fleets, cargo and parcel space and vehicle-deck slots
Standalone mode fits operators without a sophisticated backend — provincial bus lines, ferry and tourism operators, shuttles, charters, small regional airlines: Lulan is the whole system, from search to boarding. Orchestrated mode fits enterprises with existing operational platforms: Lulan owns discovery → pricing → cart → payment → confirmed reservation, then a Reservation Sync Connector pushes it into the PSS / manifest system / dispatch backend (planned; today's HMAC-signed webhooks already enable the same integration DIY). Same API and domain model either way — only the connector changes.
Lulan is developed in the open and welcomes issues, design discussions, and pull requests. Contribution guidelines and architecture decision records will be published as the project approaches its first release.
| Package | License | Why |
|---|---|---|
lulan-engine, lulan-api, lulan-pricing (host) |
AGPL-3.0 | The core stays open — improvements to hosted deployments flow back. |
lulan-validate (offline ticket verification) |
MIT | Embed it in proprietary boarding and kiosk apps freely. |
lulan-pricing-guest (reference WASM pricing module) |
MIT | Copy it as the starting point for your own fare engine. |
| SDKs, UI components, examples | MIT | Build commercial storefronts without restriction. |
Keywords: open-source reservation system · headless booking engine · reservation orchestration platform · airline reservation system · bus booking system · ferry reservation software · rail ticketing · seat reservation API · segment inventory · Rust booking engine · offline ticket validation · QR ticketing · WebAssembly pricing
Lulan aims to be the open-source foundation for capacity reservation worldwide — bringing modern developer tooling to an industry still dominated by legacy software. The name comes from the Filipino word for "to board, to load." Transportation is only the beginning.