Introduction
Once a guest finishes entering their card details on the payment provider's screen, they're sent back to a "complete" page on our site. If you treat "they came back" as "the payment succeeded," that assumption is trivially easy to break.
This article covers how we manage payment state, and where we choose to trust versus where we choose to doubt. The place where booking state and payment state intertwine is the part of a lodging system that demands the most careful design.
"They came back" proves nothing
Anyone can open the completion page
The URL of the completion page guests land on after payment can be opened by anyone who types it into their browser's address bar. So the fact that the page rendered is not evidence that a payment succeeded.
What's on screen is not proof
If simply opening the completion page confirms the booking, someone can get a reservation without paying a thing. What's displayed on screen and what actually happened inside the system always have to be reasoned about separately.
So before the completion page renders, our server queries the payment provider to check whether the card really was saved. Only once that's confirmed do we display "Your booking is complete."
When you can't confirm, don't assert
Sometimes the query comes back saying the request is still processing, or the situation just isn't clear. In those moments you must not write "Your booking is complete."
The completion page has three states — confirmed, processing, and unable to confirm — each with its own wording. When we can't confirm, we say so honestly: "We're still verifying your booking. If you don't receive a confirmation email, please get in touch." Don't assert things you aren't sure of is a principle that applies well beyond payments.
With card-on-file, the status stays "unpaid"
Because we only save the card and don't charge, the payment status on the provider's side stays "unpaid" indefinitely. Judge by that field and you'll never confirm a booking.
The field we actually needed to look at was a separate one: whether the card was successfully saved. Which field matters depends on which feature you're using — there's no shortcut here other than reading the documentation closely.
Two routes for the result
You don't know which arrives first
The payment result reaches our site by two routes: the guest's browser returning to our site, and a notification (a webhook) sent directly from the payment provider to our server.
Card successfully saved
Whenever the guest comes back. Sometimes slow, sometimes never
Straight from the payment provider. Reliable, but can lag by a few seconds
Push the confirmation to the reservation system and send the emails
The problem is that you can't know which will arrive first. If the guest comes straight back, the browser wins; if they close the tab, only the webhook arrives. Either way, the same confirmation routine may be invoked twice.
Run the confirmation exactly once
So we put a first-come-first-served gate at the entrance to the confirmation routine. We do a conditional database update, and only proceed to confirmation when the record is still in the unconfirmed state.
If the condition doesn't match, no update happens and that invocation simply exits without doing anything. Even when both routes fire at once, only one of them actually confirms the booking — which also prevents the classic accident of two confirmation emails landing in the guest's inbox.
Not "check then update" — "update conditionally"
Write it in two steps — read the current state, then confirm if it's unconfirmed — and you leave a window for another process to slip in between. Write it as a single operation — "set it to confirmed if it is unconfirmed" — and the database guarantees the ordering for you.
Reject duplicate notifications by ID
The payment provider sometimes delivers the same notification more than once. That isn't a malfunction; it's normal retry behaviour in case the first delivery didn't land.
So we record the unique event ID attached to each notification in the database, and when the same ID shows up again we immediately return "already processed." Because the database has a constraint that forbids storing the same ID twice, even simultaneous deliveries mean only one gets through.
If processing fails, return an error
If something goes wrong while handling a notification and you respond "success anyway," the payment provider will never retry it. So on failure we return a clear error and invite the retry.
Since the duplicate protection is in place, a retry won't cause double processing. It's precisely because we handle duplicates that we can return an error with confidence.
How we model booking state
Break state into fine-grained values
"Unconfirmed" and "confirmed" aren't nearly enough to describe a booking. In practice we defined nine states.
Fine-grained states make it obvious at a glance what's happening with any given booking, and they make it far easier to act from the admin screen. Coarse states, by contrast, let "confirmed but something's off" bookings quietly disappear into the pile.
Log unhandled notifications rather than ignoring them
The payment provider also sends notification types our site doesn't handle. Throw those away without a trace and you can never go back and answer "did that notification actually arrive?"
So we still write unhandled notifications to the database, marked as "recorded but out of scope." Being able to trace the arrival history as data — without digging through logs — makes troubleshooting dramatically easier.
Wrapping up
Three things mattered most in managing payment flow state.
- Never treat the screen as evidence — even on the completion page, the server goes and asks the payment provider
- Confirm exactly once, via a conditional update — called from two routes, executed by one
- Deduplicate notifications by ID, and report failures honestly — duplicate protection is what lets you welcome retries
Protecting amounts is covered in "Payment Security Against Price Tampering and Double Charges."
Payment Security Against Price Tampering and Double Charges
Why we recompute amounts on the server, and how we separate environments in practice.
Building Sync That Doesn't Break, with Webhooks and Hourly Reconciliation
How we applied the same thinking on the reservation management side.
Stripe Payment Design for Lodging — Choosing Not to Charge at Booking
For the full picture of the payment design, start with the hub article.