---
title: "Idempotent Booking: How We Prevent Double-Charges"
description: AI agents retry on failure. Networks drop. Users double-tap. Our multi-layer idempotency guarantees no booking is ever duplicated or double-charged.
canonical: https://nowah.xyz/blog/idempotent-booking-prevent-double-charges
lastModified: "2026-08-07T03:48:26.833Z"
---

# Idempotent Booking: How We Prevent Double-Charges

AI agents retry on failure. Networks drop. Users double-tap. Our multi-layer idempotency guarantees no booking is ever duplicated or double-charged.

Imagine this: you tell the AI agent to book a flight. The payment processes. Then the network drops for a split second. The agent doesn't receive the confirmation, so it retries the booking request. Without [idempotency](/blog/idempotency-travel-booking), you've now been charged twice for the same flight.

Or this: you tap the "Confirm booking" button on your phone. Your thumb bounces and registers two taps. Two requests hit the server within 50 milliseconds of each other. Without idempotency, two bookings get created, two charges hit your card, and you own two seats on the same flight.

Or this: the AI agent calls the booking tool, gets a timeout error (the request actually succeeded on the provider's side but the response didn't make it back through the network), and retries with the same parameters. Without idempotency, you have two identical bookings and two charges.

These aren't hypothetical. They're the everyday reality of distributed systems, and AI agents make them more likely because agents retry automatically when tool calls fail. We built a multi-layer idempotency checks specifically to guarantee that no booking is ever duplicated or double-charged, regardless of what goes wrong in the network, the infrastructure, or the agent's retry logic.

## Why AI systems are uniquely prone to duplicates

![Illustration for this section](https://pics.nowah.xyz/website-media/engineering-017-img-1.webp)

Traditional e-commerce has duplicate risks too. Users double-click. Networks retry at the TCP level. Load balancers route the same request to two servers. But AI agents introduce additional risks that are specific to how agent systems work, and these risks are more likely to produce duplicates than traditional web interactions.

**Automatic retries are a core feature.** AI agents are designed to retry failed tool calls. This is correct behavior for most tools. If a flight search times out, retrying is the right thing to do. If a weather check fails, retry. The agent doesn't need to bother the user with transient infrastructure hiccups. But this same retry behavior, when applied to a payment or booking tool, is dangerous. A naive retry of a successful-but-timed-out payment creates a duplicate charge. The retry behavior that makes the agent resilient for read operations makes it risky for write operations.

**Stream reconnection creates replay risk.** In a streaming architecture, if the streaming connection drops, the client reconnects. Depending on where in the processing pipeline the drop occurred, the agent might re-process the last action. If that action was "execute booking," the re-processing could trigger a duplicate. The agent thinks its previous tool call didn't complete because it never received the result, so it tries again.

**Context window truncation causes replay.** In long conversations, older messages get summarized or dropped from the context window to stay within token limits. In rare cases, a tool call result might get truncated, and the model, seeing an incomplete tool call in its history, might re-invoke the tool. If the original call succeeded but the result was truncated, this re-invocation is a duplicate.

**No visual confirmation loop.** In a traditional checkout UI, the user sees a confirmation page and knows the booking worked. They don't click "Buy" again because the page changed. In a conversational interface, the confirmation is a chat message. If that message doesn't render due to a stream error, rendering bug, or the user's attention being elsewhere, they might say "Did that go through?" or "Try again," triggering a new booking attempt.

**Multi-step reasoning creates compound risk.** An AI agent might decide to book a flight, then book a hotel, then add airport transfer. Each step is a separate tool call. If the flight booking succeeds but the hotel booking fails and the agent retries the entire sequence, the flight booking might be duplicated. The agent needs to distinguish between "retry the failed step" and "retry everything."

Each of these scenarios needs to be handled. Our three-layer approach catches all of them.

## Layer 1: application idempotency

Every booking flow starts with generating a unique idempotency key. This key is created when the user selects a flight option and the agent prepares to book, before any payment or provider calls happen.

The key is a deterministic hash of: user ID, flight option identifier (which encodes the specific flight, fare class, and price), and a session-specific nonce that distinguishes intentional re-bookings from accidental duplicates. The same user booking the same flight in the same session always produces the same key.

When a booking request arrives at our server, the first thing we do is check this key against our idempotency store.

**Key exists, result available:** Return the stored result immediately. No new processing happens. No payment is initiated. No booking is created. The caller gets the exact same response they would have gotten from the original request. From the client's perspective, the request succeeded normally; they don't even know it was a duplicate.

**Key exists, result pending:** The original request is still in progress. This happens when two requests arrive nearly simultaneously. We return a "processing" status and the caller polls or waits for the original to complete. This prevents the dangerous window where two requests both see "no existing key" and both proceed to payment.

**Key doesn't exist:** This is a new request. We atomically insert the key with a "processing" status and proceed with execution. The atomicity of the insert is critical: it uses a database-level unique constraint, so even if two requests execute the check at the exact same nanosecond, the database ensures only one insert succeeds.

Keys expire after a configurable window, currently several hours. After expiration, the same logical booking could be executed again, which is correct: if a user intentionally books the same flight the next day, that's a new booking, not a duplicate. The expiration window is long enough to cover any reasonable retry scenario but short enough that legitimate repeat bookings aren't blocked.

## Payment-processor deduplication

![Supporting diagram](https://pics.nowah.xyz/website-media/engineering-017-img-2.webp)

Our payment processor provides native idempotency. We pass our application-level key to the payment processor as its idempotency key. If the processor receives two payment requests with the same key, it executes the first and returns the first's result for the second.

This layer catches cases where our application-level check fails. This can happen in real distributed systems. Network partition between our server and the idempotency store. A brief database outage that causes the key lookup to return an error instead of a result. A race condition at the boundary between two server instances during a deployment.

The payment processor is a separate system with its own idempotency implementation. It's an independent check. Even if our entire application layer were temporarily compromised, the payment processor would still prevent duplicate charges.

We monitor both layers. If the payment processor catches a duplicate that our application layer should have caught, that's an alert. It means our application-level idempotency had a gap that needs investigation.

## Provider-side confirmation checks

## Note: travel provider deduplication

Before creating a new booking with the travel provider, we check for existing bookings that match the same parameters: same passengers, same flight, same dates. If a matching booking exists and was created within a recent time window, we return the existing booking instead of creating a new one.

This layer is the coarsest and slowest (it requires querying the provider's API), but it catches the scenario where both previous layers somehow fail. It also catches a different class of duplicate that the other layers can't: the user who genuinely books the same flight twice in separate sessions. Maybe they forgot they already booked. Maybe their partner booked it without telling them. In this case, we detect the duplicate and ask: "You already have a booking for this same flight on March 15th. Did you mean to book a second seat, or were you looking for this existing booking?"

This human-in-the-loop check at layer 3 handles the ambiguous case that the other layers can't resolve automatically. Sometimes a duplicate booking is intentional (booking for a second traveler). The provider layer check gives us the information needed to ask the right question.

## Handling concurrent requests: the distributed lock

The trickiest idempotency scenario is two identical requests arriving simultaneously. The database insert for the idempotency key handles most of this through its uniqueness constraint, but the full booking flow needs more coordination.

When a booking request comes in and passes the idempotency key check (the key is new), we acquire a distributed lock scoped to that idempotency key. The lock has two purposes: prevent a concurrent request from proceeding with the same booking, and prevent the same request from being processed on two different server instances behind a load balancer.

If we get the lock, we proceed with the full booking flow: payment authorization, booking creation, payment capture. The lock is held for the duration of the operation and released on completion (success or failure).

If we can't get the lock (another instance has it), we wait briefly for the lock to release. When it does, we check whether the original execution completed and return its result. This turns a concurrent duplicate into a sequential retrieval of the existing result.

The lock has a timeout. If the lock holder crashes mid-operation, the lock expires after a configured duration. But before proceeding after a timeout, we check the booking state. If the original operation completed (the lock holder crashed after finishing but before releasing), we return the existing result. If it didn't complete, we check whether partial state exists (payment authorized but booking not created) and handle compensation before proceeding.

Distributed locks with timeouts in the presence of server crashes and network partitions are one of the genuinely hard problems in systems engineering. We've invested heavily in getting this right because the alternative, explaining to a customer why they were charged twice, is much more expensive than any amount of engineering time.

## Recovery flows when the safety net itself fails

What if the idempotency store goes down? What if we can't check whether a key exists because our database is temporarily unreachable?

We fail closed. If we can't verify idempotency, we don't proceed with the booking. The user gets a message from the agent: "I'm having trouble processing your booking right now. Want me to try again in a moment?" This is annoying but safe. An annoyed user who retries successfully in thirty seconds is much better than a double-charged user who needs a support call and a refund.

This fail-closed behavior extends to each layer independently. If the application-layer idempotency check fails (database error), we don't proceed. We don't say "well, the payment processor has idempotency too, so it's probably fine." Each layer must independently succeed for the booking to proceed.

When the store comes back, the retry succeeds normally because the key is fresh. The brief interruption is a small price for the guarantee of no duplicates.

## Testing idempotency under chaos

We test our idempotency system under conditions designed to break it. These aren't theoretical exercises; they're automated tests that run regularly against a test environment.

**Simultaneous requests.** We fire ten identical booking requests at the same time from different clients. Exactly one booking should be created, and all ten clients should receive the same result.

**Network partitions.** We simulate the [payment processing](/blog/launching-payment-processing-ai-handles-money) succeeding but the response never reaching our server. The idempotency key should be stored, and the retry should return the original result.

**Server crashes.** We kill the server mid-transaction at different points in the saga (after authorization, after booking, after capture) and restart it. The booking should be either completed consistently or rolled back cleanly. No partial states should be visible to the user.

**Race conditions.** We introduce artificial latency at specific points to create timing windows where race conditions are most likely. The distributed lock should hold under all timing conditions.

**Idempotency store failures.** We make the store unreachable and verify that the system fails closed rather than proceeding without checks.

**Cross-deployment consistency.** During a rolling deployment where old and new server instances coexist, we verify that idempotency state is shared correctly and that a request started on an old instance and retried on a new instance still behaves correctly.

Booking abandonment rates on [traditional OTAs](/blog/ai-travel-booking-vs-traditional-otas) hit 80 to 90 percent. Part of that abandonment comes from users who don't trust the system to handle their money correctly. "Was I charged? Did it go through? Should I try again? What if I get charged twice?" These are real anxieties that real users have. Our idempotency guarantees are part of how we earn trust: the system handles retries, network issues, and double-taps correctly, every time, so the user doesn't have to worry about them.

Zero-downtime deployment requires idempotency to work across deployment boundaries. When we ship a new version, requests in flight need to complete or compensate correctly. Old and new server instances must agree on the state of every in-progress booking. Our idempotency keys and distributed locks are stored in infrastructure that persists across deployments, not in server-local memory.

The goal is simple even if the engineering is complex: one tap, one booking, one charge. Every time, regardless of what goes wrong between the tap and the confirmation.

---

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](https://app.nowah.xyz).
