A customer selects the last seat and receives a five-minute reservation. They complete the payment flow, but confirmation arrives after the hold expires. Meanwhile, another customer has reserved the seat.

The first payment is real. So is the second reservation. Confirming both bookings would oversell the event, while ignoring the payment would leave a customer charged without a clear outcome.

This is a distributed workflow problem: the booking database and payment provider do not share one atomic transaction. A reliable solution needs explicit states, a policy for late success and durable work that brings incomplete cases to a known outcome.

Introduction

A reservation temporarily protects inventory while the customer completes another process. That process may involve a bank, authentication challenge, redirect or delayed payment notification. Its timing is not controlled entirely by the booking application.

A five-minute countdown is therefore a product promise backed by server-side rules, not a guarantee that every payment event will arrive within five minutes. Even a payment completed before the deadline can be reported to the application later.

The central distinction is between ownership of the seat and knowledge about the payment. A successful payment is evidence that money moved through the payment workflow. It does not establish that the customer still owns the seat.

This article uses one seat and a short hold as a worked example. The same reasoning applies to hotel rooms, limited stock and other scarce resources, although pooled inventory needs quantity-based constraints rather than one-seat ownership.

For this example, an expired reservation is not automatically revived. If payment succeeds after the application can no longer fulfil that reservation, the system records the payment and starts a refund workflow. Other business policies are possible, but they need equally explicit rules.

Define the Invariants and Late-Payment Policy

Keep inventory ownership authoritative

The primary inventory invariant is that a seat cannot belong to two confirmed bookings. During checkout, the system should also allow at most one active allocation according to its reservation rules.

Enforce ownership where all competing reservations meet: the authoritative inventory record or a database constraint representing active allocations. Protecting reservation A's row does not stop reservation B from independently claiming the same seat if the allocation model permits both.

A payment notification must go through that ownership check. It cannot directly update a booking to confirmed merely because the provider reports success.

For pooled stock, the equivalent invariant may be that confirmed units plus protected holds never exceed sellable capacity. Checking a displayed availability count and then inserting a reservation separately does not enforce that rule under concurrency.

Decide which deadline determines confirmation

One policy confirms only if the hold remains valid when the booking service makes its atomic finalisation decision. That is the policy used here.

Another policy could accept payments authorised before a provider-recorded deadline, but then inventory must remain protected while the system resolves delayed evidence. Releasing the seat immediately at expiry while later honouring any earlier provider timestamp is inconsistent.

A short grace period can be valid if it is part of the actual reservation contract and allocation lifecycle. A cleanup worker happening to run thirty seconds late is not an intentional grace period.

State what happens when the seat remains free after expiry. Our policy still treats the original hold as expired and refunds an unfulfillable payment. A different product could attempt a new atomic allocation, but should represent it as an explicit reacquisition decision rather than silently reviving stale ownership.

Choose the customer outcome in advance

The service needs a known destination for each meaningful case: confirmed booking, payment still being resolved, expired reservation without payment or refund in progress.

Avoid leaving late payments in an error log for support to discover. The compensation policy should be executable and observable, with manual intervention reserved for cases the automated workflow cannot resolve.

Also decide whether an alternative seat can be offered. An offer is a separate customer decision when the resource or price changes. A payment for one seat does not automatically authorise the application to assign any replacement it prefers.

Track Reservation, Payment and Compensation Separately

A single status field such as Paid cannot describe both a confirmed seat and a successful payment requiring refund. Model the dimensions explicitly.

An illustrative reservation lifecycle is:

HELD -> CONFIRMED
HELD -> EXPIRED
HELD -> CANCELLED

A payment can independently be pending, require further action, succeed or fail. Compensation can be not required, required, submitted, completed or needing intervention.

These names are application concepts, not a direct copy of every provider's API states. The payment adapter should map provider outcomes carefully while retaining the original identifiers and relevant status information.

Store durable relationships

A reservation record can include reservation ID, seat ID, customer ID, expiry, state and version. The seat allocation identifies its current owner and the generation of that allocation.

A payment-attempt record links the reservation to the provider account, payment object, expected amount, currency and attempt identity. Store amounts using the payment method's appropriate smallest currency unit or another exact representation, not floating-point arithmetic.

A compensation record identifies the successful payment, the refund amount, reason, stable operation identity and current outcome. Keep individual provider interactions as attempt history if retries need to be investigated.

Preserve expired reservations long enough to correlate delayed payment events and support recovery. Deleting the row at the end of the countdown destroys the evidence needed to explain an otherwise valid payment.

Separate a logical payment from transport attempts

A user refreshing the checkout page should not automatically create a new unrelated payment. Reuse the logical payment workflow when appropriate, and associate every transport retry with the same operation identity.

Stripe's Payment Intents model represents a payment lifecycle that may involve authentication and changing states. The integration should follow the payment object's actual lifecycle rather than treating a browser redirect as a final payment outcome. Payment Intents.

If the business allows several genuinely distinct payment attempts for one reservation, record them separately. Only one successful payment should fulfil the booking price under the chosen policy; another success needs an explicit duplicate-payment resolution.

Walk Through the Ordinary Successful Path

The customer first requests a reservation. In a local transaction, the booking service checks current inventory ownership, creates the hold and assigns the seat to that reservation with its expiry.

The response contains the reservation identifier, authoritative expiry and the information needed to continue checkout. The browser can display a countdown, but server-side state remains authoritative.

The service creates or retrieves the associated provider payment using a stable creation identity. It records the provider reference so later webhooks can be matched to the reservation even if the browser disappears.

Once payment success is verified, the booking service attempts finalisation. The transaction verifies the payment relationship, the hold's state, its deadline and current seat allocation. If all remain valid, it confirms the booking and records any outbound confirmation intent.

Sending the confirmation email happens after that durable transition through a retryable notification workflow. A slow email provider should not keep a seat-allocation transaction open.

If the finalisation response is lost, the client can query the reservation status using its existing identifier. Repeating the query or finalisation request should return the established outcome rather than create another booking.

Make Expiry and Confirmation Compete Atomically

Use the same authoritative allocation

The expiry worker and payment handler may run at the same time. Both need to coordinate through the reservation and seat allocation in one database transaction.

A simplified finalisation algorithm is:

begin transaction
lock or conditionally protect the seat allocation
load the linked reservation and payment attempt
establish the current authoritative decision time

if reservation is already confirmed by this payment:
return the existing result
else if hold is valid
and seat allocation belongs to this reservation
and payment matches the expected successful payment:
mark reservation confirmed
preserve seat ownership as confirmed
record confirmation notification intent
else:
record successful payment without granting inventory
record compensation required when appropriate
insert a unique refund instruction
commit

The actual implementation may use row locks, serialisable transactions or conditional updates with version checks. The required property is that no competing allocation can slip between the ownership check and final state change.

A consistent lock order reduces deadlock risk when several related records are touched. Deadlocks can still occur, so the application should retry the complete local transaction within a bounded policy rather than partially repeating its operations.

Check expiry at the decision point

The validity condition is based on server-side time at the authoritative decision, not on the browser's countdown. A delayed cleanup worker must not extend a hold merely because its state still says HELD.

Be explicit about equality. For example, define a hold as valid while decision time is strictly earlier than expiry, and expired at or after expiry. Using inconsistent comparisons in different handlers creates a boundary where both believe they may act.

Choose a database time function whose semantics match the decision. Some functions represent transaction start rather than the instant a statement executes. If the transaction waits for a lock, an old timestamp may no longer represent the intended expiry check.

The system's serialisation point determines which operation wins. It need not be the exact moment the transaction's response reaches the client, but it must be a clearly defined point protected against competing ownership changes.

Work through the race

Suppose reservation A expires at 12:05:00. A confirmation handler reaches the protected decision at 12:04:59 and validly changes A to confirmed. When the expiry worker later examines A, it sees confirmation and leaves the allocation alone.

In another execution, the decision occurs at 12:05:01. Even if the cleanup worker has not run, A is no longer a valid hold under our policy. Payment success is recorded, but the seat is not confirmed for A.

If reservation B has already acquired the released seat, A's handler must also fail the current-allocation ownership check. A stale reservation row or earlier payment timestamp cannot overwrite B's allocation.

These outcomes need deterministic rules. Thread scheduling and webhook delivery order should not decide whether the application permits two owners.

Release Inventory Without Damaging a New Hold

Expiry cleanup should release the allocation only if it still belongs to the reservation being expired. A worker holding old data must not issue an unconditional command to mark the seat available.

For example, the transition can require both the seat ID and expected reservation ID or allocation generation. If a newer allocation exists, the cleanup operation does nothing to that ownership and records the old reservation's appropriate terminal state.

The generation is useful when identifiers or workflows can be reused. A worker acting for an old allocation of a resource must not accidentally operate on a later allocation that happens to share a coarse key.

Do not rely on deleting a cache key to release authoritative inventory. A cache can show availability, but database ownership determines whether another customer can acquire the seat.

If expiry runs in batches, use bounded transactions and make each transition safe to repeat. The batch checkpoint is an efficiency aid; it should not be the sole protection against expiring a confirmed booking after a retry.

Receive Payment Notifications Reliably

Treat the browser as a status client

The customer may close the tab after paying or lose connectivity during a redirect. A success page is useful feedback, but cannot be the only durable notification mechanism.

The browser should ask the server for the reservation outcome. It can display payment processing while the server waits for verified provider state, then show a confirmed booking or a compensation outcome.

Do not trust a query-string parameter such as payment=success to confirm inventory. The server must correlate and verify the payment with the expected provider account, amount, currency and reservation attempt.

Verify and store incoming events

Stripe requires signature verification using the raw request body and documents both duplicate delivery and the absence of guaranteed event ordering. Verify the event before accepting it and avoid depending on a particular delivery sequence. Stripe webhook guidance.

A practical endpoint verifies the signature, validates the expected account context and durably inserts the event into an inbox. It returns promptly, while a worker performs the booking transition.

If durable acceptance fails, do not acknowledge as though the event is safely recorded. If the event is already stored, a repeated delivery can receive success without duplicating the business work.

Deduplicate provider event identities, but also make the reservation and payment transition idempotent. Distinct event objects can describe the same underlying payment outcome, and a reconciliation worker may discover that outcome without using the webhook event at all.

Handle late and conflicting information

A late failure notification should not blindly reverse a booking whose payment has already succeeded. Apply state transitions according to the actual provider lifecycle and retrieve current provider state when needed.

Retain the event's occurrence time and our receipt time for explanation. They answer different questions. Neither by itself grants present inventory ownership.

If an event references an unknown payment, store it for investigation or later correlation under a bounded policy. Deleting it immediately can lose the only notification for a payment reference whose local creation response was lost.

Turn Late Success into Durable Compensation

When the service verifies successful payment for an expired, unfulfillable reservation, the local transaction should record the successful payment, mark compensation required and insert a unique refund instruction.

Those actions belong together. Recording success and planning to enqueue a refund later leaves another crash window: the process can stop after the customer is known to be charged but before recoverable work exists.

The refund instruction is an outbox record or equivalent durable job. It points to the original payment and the amount to return. A worker executes it outside the inventory transaction.

Keep the refund identity stable

Use a stable logical identity such as the payment ID plus compensation reason and occurrence. Each retry of the same intended refund uses the same provider idempotency key where supported.

Stripe documents that idempotency keys allow retries to reuse a request outcome, and that keys may be removed after their documented retention period. A long-delayed replay should therefore check stored provider results and current refund state rather than assume an old key is remembered forever. Stripe idempotent requests.

Do not change the amount under an existing logical refund key. If the business decision changes, reconcile the original operation first and create a separately identified adjustment when necessary.

Internal uniqueness should also prevent two workers from independently creating the same compensation instruction. Provider idempotency is a useful boundary defence, not a replacement for coherent local state.

Preserve uncertain refund outcomes

A refund request can succeed at the provider while the worker times out. Retrying with the stable identity may resolve that uncertainty where the provider supports it. The worker should not mark the refund failed merely because the response was lost.

Store the provider refund identifier as soon as it is known. Reconciliation can then query that object and apply later events without guessing which refund belongs to the instruction.

A requested refund is not necessarily complete. Stripe documents pending and failed refund outcomes that require follow-up. Keep those states visible and distinguish them from a completed refund. Stripe refund guidance.

If automated recovery cannot complete the refund, move the case to an operational queue with its full history. The customer still needs an outcome; a retry limit protects infrastructure but does not discharge the business obligation to resolve the case.

Coordinate manual and automated refunds

Support staff may use a provider dashboard while an automated worker is also retrying. Without coordination, the local system can attempt another refund after a manual action already resolved the problem.

Reconcile provider refund totals and store externally initiated adjustments. Local records should distinguish the original payment amount, successful refunds and outstanding compensation.

A manual resolution should update the application's workflow through an audited action, or be discovered through provider events and reconciliation. Support notes alone do not stop a worker from following its durable instruction.

Consider Authorisation Before Capture

Some payment methods support authorising funds separately from capture. The application can obtain an authorisation, secure inventory under a protected state and then capture payment.

This can reduce situations where collected money needs to be refunded because the seat is unavailable. It does not eliminate distributed uncertainty.

Stripe explains that manual capture is supported for particular payment methods and that authorisations have time limits. Use the provider's actual supported lifecycle and expiry information rather than assuming every payment method behaves like a card authorisation. Authorisation and manual capture.

Introduce a protected capture state

Once a valid hold moves into CAPTURE_PENDING, the allocation needs to remain protected while capture is resolved. Otherwise a timed-out capture could succeed after the seat was released to another customer.

Create the capture instruction durably in the same local transaction that establishes that protected state. The capture worker calls the provider with a stable operation identity and records the result.

On confirmed capture success, move to confirmed booking. On a confirmed failure that cannot be recovered under policy, release allocation through an explicit transition and cancel or release the authorisation where applicable.

A capture timeout is unknown, not confirmed failure. Query the provider and process later notifications before making a release decision that could conflict with collected money.

Bound intermediate states honestly

A protected capture state cannot remain forever without operational attention. Define a reconciliation interval, an escalation deadline and a policy for cases that remain uncertain.

Simply applying the original five-minute expiry to every state is unsafe if capture has already begun. Extending ownership indefinitely without visibility is also unacceptable. The workflow needs a distinct time budget for resolving payment uncertainty.

The customer-facing countdown should represent the state they are in. Once the application has accepted finalisation and is resolving capture, show processing rather than an expired timer that invites the customer to pay again.

Reconcile Anything That Gets Stuck

A periodic reconciler finds old pending payments, unmatched provider references, capture instructions and refunds whose outcome is still unknown. It queries the provider and feeds verified outcomes through the same idempotent business transition logic as webhooks.

Use age thresholds appropriate to the payment method and state. Some methods legitimately settle more slowly than an interactive card flow. The reservation policy must account for that before accepting them for short-lived scarce inventory.

The reconciler should claim work in bounded batches, respect provider limits and back off on outages. A provider incident is not helped by every unresolved record being queried continuously.

Keep reconciliation independent of a single webhook delivery attempt. Webhooks provide timely notification; reconciliation provides a second way to discover outcomes when delivery or local processing failed.

Recover lost creation responses

If creating a payment times out, the provider may have created it even though the application has no returned identifier. Reusing the stable creation identity within the provider's supported contract can recover the original response.

If automatic recovery is no longer conclusive, use stored request identity and provider metadata to investigate carefully. Do not create a fresh charge merely because the local payment-reference field is empty.

Preserve enough local evidence before the call to know which logical operation was attempted. The network response should add information to a durable attempt, not be the first record that an attempt existed.

Reuse one transition path

Webhook workers, browser-triggered status refreshes and reconciliation jobs can discover success at nearly the same time. They should invoke the same transactional finalisation logic.

Duplicating the business rules in separate handlers invites divergence. One path may check expiry while another checks only payment status, recreating the oversell race through an apparently helpful recovery feature.

The transition should return an existing result when already resolved and create compensation only once when necessary. Repeated discovery becomes routine rather than exceptional.

Give Customers and Support a Truthful Status

Useful customer states include reservation held, payment processing, booking confirmed, reservation expired and refund processing. The wording should describe what the system actually knows.

Do not show booking confirmed because the provider returned success while inventory finalisation is still pending. Do not show refund completed merely because a refund job was queued.

Provide a stable status page so the customer can leave and return without restarting payment. A refresh should not create another reservation or attempt unless the customer explicitly starts a new purchase.

Support should see a timeline linking reservation ownership, expiry decisions, payment events and compensation attempts. Record both the reason for the outcome and the evidence used, including relevant provider identifiers.

Avoid exposing raw payment secrets or unnecessary personal details in that timeline. Operational traceability needs correlation and state transitions, not unrestricted access to sensitive payment payloads.

Test the Difficult Sequences Deliberately

Test payment success before expiry with prompt notification, before expiry with delayed notification and after expiry. Expected outcomes should follow the stated finalisation policy rather than depend on convenient timing.

Run confirmation and expiry concurrently for the same reservation. Also let another reservation acquire the seat while the first payment handler is delayed. Verify that the authoritative allocation never has two owners.

Deliver the same event twice at once, then deliver different events describing the same payment success. Confirm that only one booking or compensation instruction results.

Crash after the provider accepts payment, after the database records compensation and after the refund provider succeeds but before local completion is saved. Each point should recover from durable evidence.

Test a pending refund, a failed refund and a refund initiated manually outside the application. Verify that customer status remains accurate and that automated workers do not create an unintended second business operation.

Finally, test slow locks and a delayed expiry worker. Time calculations and affected-row checks should continue enforcing the policy even when normal background scheduling is disrupted.

Monitor Business Outcomes, Not Just Exceptions

Track the number and age of payment-pending, capture-pending and refund-pending cases. A low error rate can coexist with customers stuck in an intermediate state for hours.

Measure late-success frequency, finalisation conflicts, duplicate event suppression and reconciliation recoveries. These can reveal a hold duration that is too short, a slow notification path or a payment method poorly matched to the reservation model.

Alert on a successful payment without either fulfilment or a tracked compensation path. That is a more meaningful invariant than counting generic webhook exceptions.

Keep the system's retry and compensation queues visible during incidents. Restoring the API does not complete recovery if old successful payments still lack an outcome.

Summary

Late payment success requires a business policy backed by atomic inventory transitions and durable follow-up work. Payment state and ownership must remain separate because neither the browser nor the payment provider can grant a seat that the database has already allocated elsewhere.

Preserve the original reservation, verify and deduplicate notifications, and record compensation in the same local transaction that discovers it is needed. Refunds and captures have intermediate and uncertain outcomes that require reconciliation.

A dependable system can explain whether the customer has a booking, payment still being resolved or a refund in progress. That clarity comes from explicit states and recovery paths, especially when the events arrive at the worst possible time.