An Unbreakable Payment Flow with Webhooks and Status Management

Never trust "the guest came back from the payment page" — state transitions and duplicate-event protection

webhookstatus managementidempotencybooking confirmationstate transitions
7 min read

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.

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.

The two routes a payment result travels
Payment provider

Card successfully saved

Browser returns to the completion page

Whenever the guest comes back. Sometimes slow, sometimes never

Notification to the server

Straight from the payment provider. Reliable, but can lag by a few seconds

Same outcome no matter which arrives first
Booking confirmation

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.

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.

  1. Never treat the screen as evidence — even on the completion page, the server goes and asks the payment provider
  2. Confirm exactly once, via a conditional update — called from two routes, executed by one
  3. 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."