Idempotency in Travel Booking: Why One Check Is Not Enough
How multi-layer idempotency prevents the nightmare scenario of double-booking a flight — application keys, payment dedup, and provider confirmation.

A double-booked flight means two tickets charged to the traveler's card, two seats reserved, and one very angry person who now has to deal with getting a refund while standing in an airport. I have seen this happen with other platforms. It's the kind of failure that destroys trust instantly and permanently.
We designed booking with layered idempotency specifically because double-booking is not a "rare edge case we'll fix later" kind of problem. It's an existential risk. Zero tolerance. Zero occurrences. That's the target, and you don't hit zero with one layer of defense.
How double bookings actually happen

Before getting into the solution, let me explain why this is hard.
Network retries are the most common cause. A traveler taps "Book," the request goes to our server, the booking succeeds, but the response gets lost on the way back. The client didn't get a confirmation, so it retries. Now the server sees what looks like a new booking request for the same flight. Without idempotency, it processes it again. Two bookings. Two charges.
Stream reconnections cause a similar problem. The traveler is on a streaming connection, the booking confirmation event is mid-delivery, the network drops, the client reconnects and re-issues the booking request.
Webhook replays are another vector. The payment processor sends a webhook saying "payment succeeded." Our webhook handler processes it and updates the booking. The payment processor doesn't get our acknowledgment (maybe our response was slow), so it resends the webhook. Without deduplication, we process the same event twice.
Each of these is a normal, expected occurrence on the internet. They're not bugs. They're physics. The network is unreliable, and any system that moves money needs to handle unreliability without creating duplicate transactions.
Application-level attempt ids
The first layer lives in our application code. When the client initiates a booking, it generates a unique client-generated attempt id. This ID gets sent with the booking request and stored in a booking-attempt record table before any processing begins.
When a booking request arrives, the first thing we do is check: does a booking-attempt record with this ID already exist? If yes, we return the result of the previous attempt instead of processing a new one. If the previous attempt succeeded, we return the success. If it failed, we return the failure (and the client can generate a new attempt ID to try again).
This catches the most common case: client retries due to network timeouts. The client sends the same attempt ID both times, and the second request gets the result of the first.
The booking-attempt record table also serves as an audit trail. We can see every attempt, successful or not, with timestamps and outcome details. This is invaluable for debugging and for customer support when someone asks "what happened to my booking?"
Payment-processor deduplication

The second layer lives at the payment processor. When we create a payment intent, we include an idempotency key derived from the client-generated attempt id. The payment processor guarantees that if it receives two requests with the same idempotency key, it will process the charge exactly once.
This catches cases that slip past the application layer. If our server crashes after creating the payment but before recording the booking-attempt record result, a retry would bypass our application check (because the first attempt was never marked complete) but hit the payment processor's deduplication. The charge still happens only once.
Payment processor idempotency keys typically have a TTL, usually 24 to 48 hours. This means the same booking can be retried within that window without risk of double-charging. After the window expires, the key is forgotten and a new request with the same key would be treated as new. This is fine because if 48 hours have passed, the original booking context is long gone.
Provider-side confirmation checks
travel data provider deduplication
The third layer is at the travel data provider level. When we confirm a booking (reserve a seat on a specific flight), we include the offer ID and passenger details. The provider has its own deduplication logic: same offer, same passenger, same flight = same booking.
This is our last line of defense. If somehow both the application layer and the payment layer fail to catch a duplicate (which would require a very specific sequence of failures), the travel data provider won't create a duplicate reservation.
Each provider implements this differently, and the guarantees vary. Some providers deduplicate aggressively. Others are looser. We don't rely on this layer as our primary defense, but having it as a safety net means the failure window is extremely narrow.
How the three layers work together
Let me trace a concrete retry scenario through all three layers.
The traveler taps "Book." The client generates a client-generated attempt id and sends the booking request. Our server receives it, checks for an existing booking-attempt record with that ID, finds none, and creates one in "pending" state.
The server creates a payment intent with idempotency key derived from that attempt id. The payment processor charges the card. The server receives the charge confirmation and updates the booking-attempt record to "payment_captured."
The server calls the travel data provider to confirm the booking. The confirmation succeeds. The server updates the booking-attempt record to "confirmed" and sends the response back to the client.
But the response gets lost. The client times out and retries with the same a client-generated attempt id.
Our server receives the retry, checks for an existing booking-attempt record with that attempt id, finds it in "confirmed" state, and immediately returns the confirmation. No payment processing. No provider calls. The traveler sees their confirmation. One booking. One charge.
Now consider a harder case: the server crashes after the payment but before the provider confirmation. On restart, the booking-attempt record exists in "payment_captured" state. A retry would pick up from where it left off, calling the provider to confirm without re-charging. Even if the server starts from scratch, the payment processor returns the existing intent (idempotency key match) and the provider deduplicates the confirmation request.
Testing idempotency
You can't just build idempotency and trust that it works. You have to test it aggressively.
We test network retries by deliberately injecting timeout errors after successful processing. The client retries, and we verify that no duplicate side effects occurred.
We test stream reconnections by killing the streaming connection mid-booking-confirmation and reconnecting. The booking should complete exactly once.
We test webhook replays by sending the same webhook event multiple times and verifying that the booking state changes only once.
We also run chaos tests where we randomly inject failures at each layer to verify that the other layers catch duplicates. What happens if the application layer check is bypassed? The payment layer catches it. What if both are bypassed? The provider layer catches it.
These tests run in our staging environment regularly. They're not optional, and they block deploys if they fail.
Monitoring for idempotency failures
We track "duplicate attempts caught" at each layer. This isn't a failure metric. Catching duplicates is the system working correctly. We want to see this number be non-zero because it means the system is encountering real-world retries and handling them.
What we alert on is "duplicate side effects detected." If our reconciliation process finds two payment charges for the same booking attempt, or two confirmed reservations for the same passenger and flight, that's a critical alert. It means all three layers failed.
In production, this alert has never fired. I say that not to be smug but to emphasize that three layers with independent failure modes create a very small probability of simultaneous failure. Any one layer alone would catch most duplicates. All three together make the window negligibly small.
Build your own multi-layer idempotency
If you're building a booking system that handles real money, here is the pattern.
Layer 1 is yours to build. Create a booking-attempt record table (or equivalent) keyed by a client-generated attempt ID. Check it before processing. Update it after each stage. Return cached results on duplicate requests.
Layer 2 comes from your payment processor. Use their idempotency key feature. Derive the key from your application attempt ID so the layers are correlated.
Layer 3 depends on your provider. Understand their deduplication behavior. If they don't deduplicate natively, add your own check: query for existing bookings with the same parameters before creating a new one.
Test all three layers independently and together. Run chaos tests. Monitor for duplicates at every stage. The goal is zero. Not low. Zero.
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.