Skip to content
Back to Blog
July 24, 2026

Building a Booking State Machine

A travel booking moves through search, offer, intent, payment, and confirmation. Model it as a state machine and you eliminate stuck bookings and duplicate charges.

Building a Booking State Machine
M

We had a booking that was simultaneously confirmed and failed. The database showed `status: "confirmed"`. The payment provider showed a successful charge. But the travel provider showed no ticket. The traveler had paid. The airline had no record. And our status field was lying.

This happened because we were using a simple string field for booking status. Code could set it to any value at any time. There were no constraints on which transitions were valid. A race condition between the payment webhook and the provider callback wrote conflicting states, and the last write won.

The fix was not more careful coding around the status field. The fix was replacing the field with a state machine that enforces valid transitions and makes impossible states unrepresentable.

Defining the states

Illustration for this section

A travel booking has five meaningful states:

Search is where it starts. The traveler or agent has found flights or hotels and received offers. No commitment has been made.

Offer selected means a specific offer has been chosen. The offer has a time-limited validity window. If the window expires without progressing to the next state, the booking returns to search.

Intent created means the client has provided traveler details and created a booking intent. The offer is locked at the quoted price (for the duration of the validity window). Payment has not yet been processed.

Payment collected means the payment was successful. The money has moved. But the booking is not yet confirmed with the travel provider — the ticket has not been issued.

Confirmed or Failed is the terminal state. Either the provider confirmed the booking and issued the ticket, or something went wrong and the booking failed (with an automatic refund if payment was collected).

These five states cover the complete lifecycle. Every booking is in exactly one of these states at any moment. There is no ambiguity about what has happened and what needs to happen next.

Valid transitions and guards

The state machine enforces that you can only move forward through specific transitions:

  • Search to Offer Selected (selecting an offer)
  • Offer Selected to Intent Created (providing traveler details)
  • Intent Created to Payment Collected (processing payment)
  • Payment Collected to Confirmed (provider confirms ticket)
  • Payment Collected to Failed (provider rejects or times out, refund initiated)
  • Offer Selected to Search (offer expires)
  • Intent Created to Failed (validation error, offer expiry)

You cannot jump from Search directly to Confirmed. You cannot move from Payment Collected back to Intent Created. Every transition has a guard that validates the preconditions before allowing the state change.

The guards check things like: is the offer still valid? Has the traveler information passed validation? Did the payment succeed? Is the provider confirmation authentic? If any guard fails, the transition is rejected and the booking stays in its current state.

This eliminates the race condition that caused our original bug. Two concurrent state updates cannot both succeed. The state machine uses optimistic locking — each state update includes the expected current state, and the update fails if the state has changed since it was read. The first update wins, and the second must re-read the current state before trying again.

Handling stuck states

Supporting diagram

In a perfect world, every booking moves from search to confirmed in one smooth path. In reality, things get stuck.

A payment processes successfully but the provider confirmation request times out. The booking is now in "payment collected" and cannot move to "confirmed" or "failed" without a response from the provider. It is stuck.

We handle this with reconciliation jobs. Background workers run on a schedule and look for bookings that have been in a transitional state longer than expected. A booking in "payment collected" for more than five minutes triggers a reconciliation attempt: the job retries the provider confirmation, checks for an existing booking at the provider, or initiates a refund if the booking cannot be confirmed.

The reconciliation jobs are themselves idempotent. Running them multiple times on the same stuck booking produces the same result. They use the same state machine transitions as the normal booking flow, so they cannot create inconsistent states.

We also expose stuck booking detection through our webhook system. If a booking remains in a transitional state past a configurable threshold, we emit a `booking.stuck` webhook event. Developers who subscribe to this event can implement their own alerting or recovery logic.

Reconciliation with upstream providers

Sometimes our state and the provider's state disagree. We think the booking is "payment collected" (waiting for confirmation). The provider thinks the booking is already confirmed. This happens when a confirmation response is lost in transit.

The reconciliation job handles this by querying the provider for the booking's actual status using the unique reference we included with the original request. If the provider shows the booking as confirmed, we update our state to match and emit the confirmation webhook. If the provider shows no booking, we initiate a refund.

This provider-check is the safety net under the entire system. Even if our internal state tracking fails, the reconciliation job catches the discrepancy by checking ground truth at the provider level.

Exposing state to developers

Developers integrating our API need to track booking state in their own systems. We provide three mechanisms:

The `status` field on every booking resource shows the current state. It is one of the five defined states, always consistent, always meaningful.

Transition webhooks fire on every state change. When a booking moves from "intent created" to "payment collected," a webhook delivers the event with the old state, the new state, and a timestamp. Developers can maintain a synchronized state in their own database by processing these webhooks.

The history endpoint returns the complete state transition history for a booking: every state it has been in, when each transition happened, and what triggered it. This is invaluable for debugging. If a developer asks "why is this booking in failed state?", the history shows the exact sequence of events that led there.

{
 "bookingId": "bkg_abc123",
 "currentStatus": "failed",
 "history": [
 {"state": "offer_selected", "at": "2026-03-15T14:00:00Z", "trigger": "user_selection"},
 {"state": "intent_created", "at": "2026-03-15T14:01:00Z", "trigger": "traveler_details_submitted"},
 {"state": "payment_collected", "at": "2026-03-15T14:02:00Z", "trigger": "payment_success"},
 {"state": "failed", "at": "2026-03-15T14:07:00Z", "trigger": "provider_timeout_refunded"}
 ]
}

Implementation pattern

If you are implementing a booking state machine, the core pattern in your booking handler looks like this:

Read the current state. Validate the requested transition. Check the guard conditions. Apply the new state with optimistic locking. If the lock fails (concurrent update), re-read and retry.

The state machine definition is a configuration object mapping current states to valid transitions with their guard functions. The transition function takes the current booking, the requested next state, and the context data (payment result, provider confirmation, etc.), validates everything, and either applies the transition or returns an error explaining why it cannot.

Keep the state machine definition separate from the business logic. The state machine knows which transitions are valid. The business logic knows how to process payments, confirm with providers, and handle failures. They compose but do not interleave.

A well-built booking state machine is the backbone of a trustworthy travel platform. It prevents impossible states, handles failures gracefully, keeps developers informed, and makes debugging deterministic instead of forensic. It is more work upfront than a simple status field. It saves vastly more work over the lifetime of the system.


Nowah is an AI travel agent that searches and books real flights and hotels through conversation — no filters, no thirty open tabs. Plan your next trip.

Share this article

Ready to Plan with Nowah?

Bring the idea. Nowah will help turn it into a trip.

Try Nowah