Letting a guest spend loyalty points like cash at checkout sounds like a UI feature. It's actually a concurrency problem, solved with database holds, not subtraction.

A loyalty program for hotels that lets members "pay" with points sounds like a UI feature: show a balance, let the guest apply some of it, subtract the points, done. That's roughly how most hotel rewards program building blocks get built, and it works fine right up until two requests hit the same balance at once. A guest taps redeem twice because a kiosk screen lagged for half a second. A booking engine automatically retries a request that timed out. If "spend the points" is a single subtraction, both requests can succeed, and a member has just spent points they no longer had by the time the second one landed.
That isn't an edge case worth hand-waving away. It's the default behavior of any system that treats redemption as one UPDATE statement. It's also why cash credit is built the way it is below, as a lifecycle with a database enforcing it, not a discount field trusting the client.
A member doesn't spend points in one place, so cash credit isn't one feature, it's three, each independently toggleable. A guest can redeem points as real money on a direct booking through the hotel's own booking engine, mid-stay through the guest WiFi portal, or at checkout through a front-desk terminal or kiosk. A hotel can turn on all three, or just one, mid-stay redemption on the WiFi portal without ever touching the booking flow, for instance.
That independence matters because the three surfaces don't carry the same risk profile. A guest redeeming from a phone mid-stay, a browser tab abandoned partway through a booking-engine checkout, a kiosk screen that hangs while a member walks away, each of those is a different way for a redemption to go uncompleted. Those aren't the same transaction wearing three different skins. They need different timing and different failure handling, which is a large part of why the lifecycle underneath all three doesn't collapse into a single "subtract now" call.
A discount field reads a balance, checks it's enough, and writes a smaller number back. That's fine for a value nobody else can touch between the read and the write. A loyalty balance fails that test constantly: a member's phone drops signal mid-checkout and the app retries, or a kiosk POST hangs and the terminal fires it again on timeout. Now two requests read the same starting balance and both believe they're authorized to spend against it.
A subtraction has no memory of having already run. A hold does.
The lifecycle is Quote, then Hold, then one of Confirm, Release, or Expire. Never a single "subtract the points" step. A quote just shows the member what a redemption would look like, no balance is touched at all. A hold is the commitment: it reserves the points against that member's balance so nobody else can spend them in the meantime, but a hold on its own hasn't actually spent anything. Only a confirm does that.
That distinction is what makes the retried request harmless. If the same hold request fires twice, the second one sees a hold already exists and doesn't create a duplicate reservation against the same points. If a guest abandons the flow after the hold but before confirming, nothing has been spent, the points just sit reserved until the hold is released or it expires on its own. The failure mode that used to be a permanent, silent double-spend becomes, at worst, a hold that never gets confirmed and eventually clears itself.
None of this works if application code is still the thing doing the arithmetic. It isn't. The balance math runs entirely inside atomic SQL functions on the database: create_loyalty_credit_hold, confirm_loyalty_credit_hold, and a loyalty_member_spendable() function that computes what a member can actually still spend right now, accounting for anything already on hold. Application code calls these functions, it never directly subtracts a balance itself.
That's the piece that actually closes the race condition, not the naming of a lifecycle. Two concurrent requests hitting an atomic database function behave differently than two concurrent requests each running their own read-then-write in application code, because the function itself is the single place doing the check and the write together, not two separate steps a second request can slip between. Whichever one gets there first gets the hold; the second sees a balance that's already accounted for it and is correctly told there isn't enough left to spend.
A hold that never expires is just a different kind of bug, points a member can see but can never spend again because some abandoned session left a phantom reservation sitting on their account forever. So holds expire automatically if nobody confirms them: a 10-minute window on a booking-engine hold, a 5-minute window on a kiosk hold, reflecting how differently those two flows actually get abandoned. A guest who closes a booking tab mid-checkout gets their points back on their own, without a support ticket, without a manual balance correction, and without anyone at the hotel needing to notice.
Cash credit only applies to reservations the hotel can actually see the margin on. Bookings sourced from Booking.com, Expedia, and similar OTA or tour-operator channels are hard-blocked from ever using cash credit, and that block holds regardless of how a tenant configures the rest of the feature. There's no setting that opens it up for those channels, because there's no version of "let a guest redeem points against an OTA-sourced booking" that a hotel can price safely without knowing what it actually nets on that reservation. Points as payment for a hotel booking only makes sense where the hotel is the one setting the economics.
A hotel doesn't have to trust the integration blind. Before enabling redemption for real guests, cash credit can run in a live preview mode, a quote-only mode that shows exactly what a real redemption would look like without ever actually creating a hold. That's a way to sanity-check the integration against the real thing, without any risk of a stray hold sitting on a real member's account.
Once it's live, five separate webhook events, each individually HMAC-signed, notify a hotel's own systems, like its PMS, in real time as a hold is created, confirmed, released, or expires, so the front desk or a connected system isn't left polling to find out whether a redemption actually went through. The default redemption rate is 100 points to 1 unit of currency, though that's tenant-configurable, since what a point is worth is a property-level decision, not a platform one.
None of this is complicated to explain to a guest. What's underneath it is the part worth getting right before the feature ever ships, because a loyalty balance is the one part of a loyalty program a member actually checks the math on. If you're still deciding what a program like this should even look like before you get to the payment mechanics, how to launch a hotel loyalty program is the place to start.
Twenty minutes, your real properties, no generic demo environment.