How it is built
Architecture overview
A corporate travel platform lets employees book flights, hotels and trains from live supplier inventory while the employer's rules decide what is allowed, who must approve, and which cost centre pays. It is a thin, fast layer in front of slow, unreliable suppliers (airline and hotel systems that speak XML or JSON over the internet), plus a slow, careful back office that turns each booking into money: charges, invoices, refunds and statements. The idea that makes it work is to keep these two speeds apart. Search is read-only, cached and hedged, so a slow supplier only costs a slower answer. Booking is a persisted saga with idempotency keys, a payment state machine and a double-entry ledger, so a timeout never becomes a double charge or a ticket nobody paid for. The company's policy sits between them as one engine, asked the same question at search time (is this offer in policy?) and at booking time (does this need approval?).
Parts:
- Supplier gateway — fans a search out to airline, hotel and rail sources, parses their answers, shields them from overload — Stale-while-revalidate (no page yet), Hedged requests (no page yet), Circuit breaker, Token bucket, XML Streaming Parser
- Pricing — exact amounts, currencies, trip dates — Money representation, currency-cross-rate-triangulation (no page yet), calendar-date-arithmetic (no page yet)
- Ranking and geography — orders results and answers "near the meeting", "inside the alert zone" — Learning to rank, Geospatial cell index, Geodesic distance, point-in-polygon (no page yet)
- Policy and approval — in-policy flags, approver routing, second pair of eyes for exceptions — Policy engines, dual-control-approval (no page yet)
- Booking — turns a chosen offer into tickets and rooms exactly once — Idempotency keys, Saga pattern, Payment state machine, authorization-holds (no page yet)
- Accounting — books every charge, matches supplier and card statements — Double-entry ledger, Reconciliation
- Notifications and identity — tells people and customer systems what changed, signs employees in — Webhook delivery, Mobile push delivery, saml (no page yet)
- Operations — keeps searches and bookings running, recoverable and measured — Primary-backup replication, Point-in-time recovery, Process supervision, Graceful draining, Mutual TLS, hdr-histogram (no page yet), Deterministic simulation testing, Coverage-guided fuzzing
employee / agent ──► web + app ──► SAML single sign-on ──► traveller profile, cost centre, manager
│
search ───┤ ┌──────────────┐
▼ │ policy engine│◄── rules per company
┌─────────── supplier gateway ───────────┐ offers + flags │ (PDP) │
│ cache (SWR) ─► fan-out, hedge, breaker ├────────────────► └──────┬───────┘
│ airline XML/JSON · hotel feeds · rail │ │ decision + reasons
└───────────────────┬────────────────────┘ ▼
│ normalised offers ranking ─► results page
│ │ select
book ──► approval (if out of policy) ──► booking saga ──► supplier order
│ ▲ │
payment state machine ticket / voucher
│ │
double-entry ledger ◄──────────────┘
│
reconciliation ◄── airline settlement, card and bank statements
events ──► webhooks to customer systems · push to travellers
How the parts fit together
Supplier gateway
Every search fans out to several sources and waits for the slowest one unless told otherwise. Four blocks keep that survivable.
Stale-while-revalidate (no page yet) serves a cached result immediately and refreshes it
in the background; HTTP standardised the idea as the stale-while-revalidate and stale-if-error cache-control
extensions (RFC 5861). It suits browsing (price calendars, hotel lists), where a price a few minutes old still helps choosing, but never the moment of purchase, which re-prices live: Duffel's flight API says offers go stale quickly and that the client should fetch the single offer
again before asking for passenger and payment details.
Hedged requests (no page yet) cut the slow tail: if a supplier call has not answered after roughly the 95th-percentile latency for its class, send a second copy to another replica or route and take the first answer. Dean and Barroso report that this adds about 5 % load for a large cut in tail latency. Hedge only reads. A booking call is never hedged unless it carries an idempotency key.
Circuit breaker stops calling a supplier that is failing, so searches return the other suppliers' results in time rather than all waiting for one timeout. Token bucket enforces the supplier's own rate limits and each customer's share of them, so one company's bulk search cannot starve the rest.
XML Streaming Parser matters because the airline side speaks XML: IATA's NDC standard is an XML-based data transmission standard for airline offers and orders, and the OpenTravel Alliance's message standards for air, hospitality and car rental come from the same XML world (the industry is now moving parts of them to JSON). A streaming parser pulls out the fields needed per offer without building a tree of a large response, and rejects DOCTYPE declarations from untrusted senders.
The gateway's contract: normalised offers (one shape for all suppliers, integer minor units with an ISO 4217 currency, times with their zone) plus an expires_at; a supplier failure becomes a "source missing" marker, never an error page.
Pricing and dates
Money representation fixes the shape of every amount: integer minor units and a currency code. Different suppliers quote in different currencies, and a company wants one reporting currency. currency-cross-rate-triangulation (no page yet) converts A to B through a base currency; the European Central Bank publishes reference rates as units of each currency per one euro, usually updated around 16:00 CET on working days, so the rate from A to B is the euro-to-B rate divided by the euro-to-A rate. Convert once, with a named rounding mode, and store the original amount, the converted amount and the rate, because the supplier invoice is in the original currency.
calendar-date-arithmetic (no page yet) is where many travel bugs live. A hotel night is a difference of local calendar dates, a flight is an instant with two zones (it can land "yesterday" in local time), and an advance-purchase rule such as "book at least 14 days ahead" must count days in the traveller's zone. The IANA time zone database is the source for offsets and daylight-saving rules; store instants in UTC plus the zone name, never a bare offset.
Ranking and geography
A short list that puts the best offer first matters more than a long list, so Learning to rank scores each offer with a model trained on what travellers in that company picked (price, duration, stops, cabin, in-policy flag, distance to the meeting). The in-policy flag is both a feature and a visible badge or filter; a ranker that ignores policy shows results nobody may book.
Geodesic distance gives "4.2 km from the office" on a sphere or ellipsoid, and Geospatial cell index makes "hotels within 3 km of this address" an integer range scan in the ordinary database. Duty of care needs the opposite question: which of our travellers are inside this alert zone right now? point-in-polygon (no page yet) tests each traveller's last known position (a booked hotel, a flight leg) against a polygon, with cell ids used first to discard the many that are far away.
Policy and approval
Policy engines answer one question in one place: may this traveller book this offer in this
context? The input is the traveller's grade, cost centre and country, the trip purpose, the offer's price, cabin
and dates; the output is allow, deny or "allow with approval", plus the rules that decided, so the page can say
"over the 180 EUR nightly cap in Zurich". Default deny and a logged decision make this auditable. The same engine
runs at search time (to label results) and at checkout (to enforce), so the two cannot drift apart. Hand-coded if cabin == business checks are the alternative; they work until the second customer has a different rule.
dual-control-approval (no page yet) is the approval half. Perk (formerly TravelPerk) documents the shape: a
travel approval process is either "line manager" based or approver based, and each approver has a condition, one
of always, out_of_policy, or out_of_policy_and_notify_in_policy. The contract: the policy decision
produces who must approve, the approval service creates a pending request that holds the offer (see below),
and the traveller can never approve their own trip. Approver data (manager, delegate, cost centre owner) arrives
from the customer's identity provider through provisioning (SCIM is the standard; Perk documents it for Okta and
Microsoft Entra ID).
saml (no page yet) is how the employee reaches all of this without a new password: the company's identity
provider sends a signed assertion with a validity window (NotBefore and NotOnOrAfter in the SAML 2.0 core
specification), and the platform maps it to a traveller profile.
Booking
A booking changes several systems that share no transaction: the airline order, a card hold or charge, the hotel, the ledger. Saga pattern gives each step an undo (cancel the order, void the hold, release the room) and runs the undos in reverse if a later step fails; a two-phase commit is impossible because the suppliers will not join one. Payment state machine models the money side as persisted states (authorised, captured, voided, refunded) in which a timeout means "unknown", not "failed". Idempotency keys make every retry safe: the saga step id is the key sent to the supplier and to the payment provider, so the same retry returns the first result.
authorization-holds (no page yet) is the travel-specific twist. Travel is paid later than it is chosen: an unapproved trip is held, an airline fare is guaranteed only for a short time, and a hotel often authorises the card at booking and captures at check-out (Stripe's documentation describes exactly this hotel pattern, and notes that card authorisations typically last about 7 days for online payments and then expire and release the funds). The platform therefore stores each hold with its expiry, re-authorises before it lapses, and captures only when the supplier confirms. Duffel's order cancellation shows the other side: cancelling is a two-step flow, first a quote that says how much is refunded and to what (original payment or airline credit), then a confirmation.
Accounting
Double-entry ledger records each booking as postings that sum to zero: traveller's
cost centre, supplier payable, service fee, tax. A cancel or exchange is a reversing entry, never an edit, which makes the invoice defensible: Perk, for example, documents that it issues invoices as merchant of
record to an invoice profile (the legal entity a trip is invoiced to), and sends an invoice.issued event when a
billing period closes.
Reconciliation compares the ledger with what others say happened. For airline tickets the reference is the industry settlement system: IATA's Billing and Settlement Plan gives each accredited agent one sales report and one remittance per period instead of a payment to every airline. Card statements and bank files form the second and third side. Every unmatched item becomes an exception someone owns.
Notifications
Webhook delivery sends events to customer systems (expense tools, HR systems). Documented platforms agree on the contract: HTTPS POST, 2xx means received, signed payloads (Perk uses an HMAC-SHA256 header), retries with exponential backoff (Duffel for 72 hours), and at-least-once delivery in no guaranteed order, so receivers deduplicate on the event id. Mobile push delivery reaches the traveller on the road (gate change, cancellation) through APNs and FCM; collapse keys let a rebooking replace the old alert.
Operations
Primary-backup replication. The booking database holds saga state, stored holds with their expiries, supplier order ids and the ledger. Losing a step of a booking saga would leave a supplier order or a card hold that nobody knows about, so the database has a backup that holds each saga step before the call to the supplier is made or acknowledged (Primary-backup replication). After a failover the saga runner resumes from the last stored step and the step's idempotency key makes the repeated supplier call return the first result.
Point-in-time recovery. A faulty migration, a wrong bulk policy change or a mistaken invoice run can corrupt bookings and ledger entries after the fact. Point-in-time recovery restores the booking and accounting tables to the moment before from base backups plus the log. Because suppliers hold the real orders, the restored state is then reconciled against supplier orders and card statements (Reconciliation) before the month close runs.
Process supervision. Supplier adapters (one per airline, hotel or rail source), the saga runner, the hold-renewal job, the webhook senders and the month-close jobs run as supervised processes (Process supervision). A crashing adapter for one supplier is restarted and its circuit breaker keeps the other suppliers' results flowing; a restarted saga runner picks up unfinished sagas from the stored step.
Graceful draining. A booking in progress is a chain of slow remote calls (supplier order, card network). On deploy a node stops starting new sagas, lets running ones reach a stored step boundary or finish, and hands any saga it cannot finish to another node, so a deploy never leaves an order without a ledger entry (Graceful draining). Search nodes drain their in-flight supplier fan-outs and let the cache serve in the meantime.
Mutual TLS. The internal services (search, policy, booking, accounting, notification senders) authenticate each other with certificates, as do the private links to suppliers and the card or payment provider that offer them (Mutual TLS). Booking accepts a "place hold" or "capture" call only from the saga runner's identity, so a compromised search node cannot move money.
Latency percentiles. Travel is dominated by remote latency, so tails matter. hdr-histogram (no page yet) records per supplier the time of search responses (which decides when a hedged request fires and when the circuit breaker opens), the time to first results page, and the booking saga's step times (hold, supplier order, capture). Slow suppliers then show up as their own percentile curve instead of dragging a shared average.
Deterministic simulation testing. A saga across a fake supplier, a fake card network and the ledger has failure orderings no human lists: timeout after the supplier committed, hold expiring during approval, a retry crossing an airline-initiated change. Deterministic simulation testing runs these with a seeded clock and injected supplier failures, and checks that every booking ends confirmed with a ledger entry or fully undone with every hold released.
Coverage-guided fuzzing. Coverage-guided fuzzing targets the parsers that take supplier bytes: the streaming parsers that turn airline, hotel and rail responses into offers, the bank and card statement files read by reconciliation, and the inbound supplier event webhooks.
Data and control flow
One search and booking:
- The employee signs in through SAML and the platform loads the traveller profile, cost centre and policy set.
- The gateway answers cache hits at once and refreshes them in the background; misses fan out to suppliers through token buckets and circuit breakers, with hedged retries on slow reads.
- Streaming parsers turn each response into normalised offers; money is converted into the reporting currency at one stored rate.
- The policy engine labels every offer (in policy, over cap, needs approval) with reasons; the ranker orders them.
- The employee selects an offer. The gateway re-prices it live; if the price moved, the page says so before anything is charged.
- If the policy says approval is needed, a pending request is created with an expiring hold; the approver sees the offer, the reason and the cost; the traveller cannot approve their own trip.
- The saga starts with one idempotency key per step: place a hold, create the supplier order, capture, write the ledger entries, issue the document. A failure at step k runs the undos for steps k-1 to 1.
- Events go out: webhook to customer systems, push to the traveller, itinerary by email.
Time goes into step 2 (supplier latency) and step 7 (supplier order and card network, each a remote call).
One airline-initiated change:
- The airline changes a schedule; the supplier sends an event (Duffel has an
order.airline_initiated_change_detectedevent) or the platform polls. - The event handler deduplicates on the event id and loads the booking.
- The change is applied to the order through the state machine, forward transitions only.
- Push notification to the traveller and webhook to the customer; if the new times break policy or a connection, a new approval or a change flow starts.
One month close: close the period and write one invoice per invoice profile from ledger postings; pull
settlement files, card statements and bank files; match by reference, then by amount and date window; review the
exceptions before the books lock; then send invoice.issued.
Scaling
In the order they bite:
- Supplier latency and rate limits. A search is as slow as its slowest source. Levers: cache with stale-while-revalidate for browsing, hedged requests for the tail, per-supplier token buckets, tight timeouts with partial results, circuit breakers.
- Fan-out cost. Each search triggers many billed or rate-limited supplier calls. Levers: query only the suppliers that can win, coalesce identical in-flight searches, cache by normalised query.
- Parsing and ranking CPU. Large XML answers dominate. Levers: streaming parse, a cheap first-pass score and a heavier ranker on the top few hundred.
- Booking correctness under concurrency. Row-level concurrency control per booking, partitioned by company.
- Month-close volume. Hash joins on references and daily incremental matching instead of one monthly batch.
Failure modes
| Failure | Symptom | Cause | Fix |
|---|---|---|---|
| Stale price at purchase | traveller sees a price, checkout fails or shows a higher one | cache or offer lifetime shorter than the user's think time | live re-price before payment; show expires_at; keep cache for browsing only |
| Double ticket or double charge | two orders for one trip | retry after a timeout without a key | idempotency key per step; status query before retry; unknown state until resolved |
| Paid but not ticketed | card captured, no itinerary | saga stopped between capture and order, or the order call timed out | payment state machine with "unknown", resume the saga, void or refund automatically |
| Hold expires first | approved trip cannot be booked, card authorisation lapsed | approval slower than the hold window | store hold expiry, re-authorise or re-quote on approval, escalate before expiry |
| Duplicate or out-of-order webhook | customer system books an expense twice | at-least-once delivery without receiver dedupe | event id as key; process forward transitions only | | Policy drift | search shows "in policy", checkout asks for approval | two code paths evaluate rules | one engine, one call shape, same inputs | | SAML failure | everyone locked out after a certificate change | clock skew outside the assertion window, rotated signing certificate | allow small skew, accept both certificates during rotation |
Real implementations
| name | what it is | repo | licence | notes |
|---|---|---|---|---|
| Duffel API | flight and stays booking API with offers, orders, webhooks, cancellation quotes | duffelhq/duffel-api-javascript (client only) | MIT | service is hosted and closed; docs are public and describe the offer to order flow |
| Amadeus for Developers | GDS self-service APIs (flight offers search, price, create orders) | amadeus4dev/amadeus-python (SDK) | MIT | service is hosted and closed; see developers.amadeus.com |
| Perk (formerly TravelPerk) | corporate travel platform; public API for trips, invoices, cost centres, SCIM users, webhooks | — | — | closed source; developers.perk.com documents approval processes and webhook behaviour |
| SAP Concur | travel and expense platform with itinerary, travel profile, exchange rate and hotel direct-connect APIs | — | — | closed source; developer.concur.com |
| Open Policy Agent | general policy engine usable for the in-policy decision | open-policy-agent/opa | Apache-2.0 | not travel specific; see Policy engines |
| Cedar | policy language and engine with default deny | cedar-policy/cedar | Apache-2.0 | same role as OPA |
The alternative: a managed travel desk that books by hand through a GDS, with the platform only a workflow around email. No supplier gateway is needed, but policy, approval and reporting then depend on people.
Sources
- IATA, Distribution with Offers & Orders (NDC): XML-based standard between airlines and sellers. page
- IATA, Billing and Settlement Plan: one sales report and one remittance per agent. page
- Duffel documentation: getting started with flights, cancelling an order, webhooks.
- Perk developer documentation: webhooks, events, approval processes through SCIM, index.
- SAP Concur Developer Center, API reference.
- J. Dean, L. A. Barroso, The Tail at Scale, Communications of the ACM 56(2), 2013. doi, PDF
- M. Nottingham, HTTP Cache-Control Extensions for Stale Content, RFC 5861, 2010. rfc
- OASIS, Assertions and Protocols for SAML V2.0, 2005. spec
- European Central Bank, Euro foreign exchange reference rates. page
- IANA, Time Zone Database.
- Stripe, Place a hold on a payment method: hotel-style authorise now, capture later. docs
- Amadeus for Developers: portal.
- OpenTravel Alliance: opentravel.org.