Introduction
When people hear "payment security," most picture encrypted connections and protecting card data. Those matter, of course — but the things that actually cause accidents tend to be far less glamorous.
Billing whatever amount the screen happened to show. Running the same charge twice. Charging a real card for a booking created in the test environment. This article walks through those traps and how we closed them off.
Never take the amount from the browser
Numbers on screen are trivially easy to change
The booking screen displays a total. If you send that value straight to the server and bill it, anyone can open their browser's developer tools, rewrite the amount to 1 yen, and stay for 1 yen.
Values on the browser side can be freely rewritten by the user. A booking goes through for 1 yen
Re-fetch the amount from the reservation management system and simply check it matches what was sent
So the amount arriving from the browser is used only as a reference value for comparison, and the amount we actually bill is the one the server re-fetches from the reservation management system. If the two don't match, we display "The rate has changed" and stop the process.
With this design, even if someone rewrites the amount, all that happens is the comparison fails and the booking halts. No damage done.
The server assembles option pricing too
The same applies to add-ons like renting barbecue equipment. All the browser sends is whether a box is ticked — no amounts whatsoever. Unit prices come from data on the server side.
Stick rigidly to "never accept an amount from the client" and there's simply no room left to tamper with.
Never run the same charge twice
Idempotency keys as protection
If a request times out in transit, the payment provider may have actually succeeded while it looks like a failure on our end. Retry at that point and you've double-charged.
Payment providers offer idempotency keys for exactly this situation. Attach a unique string to a request and any subsequent request with the same key isn't processed anew — it returns the result of the first one.
We used the booking number to set idempotency keys in all three places: creating the customer, creating the payment screen, and charging. That gives us a structural guarantee of one charge per booking.
Add constraints on the database side too
On top of the payment provider's protections, we put constraints on our own database that forbid duplicates. Unique constraints on the booking number, the payment screen identifier, and the notification event ID mean the same value can never be recorded twice.
Build two seawalls
Try to prevent duplicates in application code alone and things slip through when processes run concurrently. Database constraints are the last line of defence — they hold no matter how much runs at once.
Re-check state immediately before charging
The automatic charge job first fetches a list of eligible bookings, then bills them one at a time. In the meantime, a guest might cancel from their private page.
So immediately before executing a charge, we re-read that booking's latest state from the database. If it's already cancelled, we don't bill it. The idea is simple: don't keep trusting information from the moment you fetched the list.
Never mix up live and test
The danger of sharing one database
During development you verify behaviour against the test payment environment. If you're sharing the production database while you do that, there's a real risk that the live automatic charge job picks up bookings you created in test.
And that's exactly what happened during development. The production scheduled job scooped up a booking created in the test environment and treated it as a charge candidate.
Record which environment a booking came from
The fix was to add a field to the booking data itself recording whether it was created under live or test payments. The charge job only processes bookings matching the environment it's currently running in.
In production accept only live keys; everywhere else, only test keys
Record which environment it was, at the moment the booking is created
Bookings from a mismatched environment are quietly skipped as out of scope, not treated as errors
The key point is treating a mismatched booking as "out of scope" rather than "an error." Raise an error and an alert email goes to the operator — which means a notification every single time anyone runs a test.
If the keys aren't right, hide the feature entirely
When the payment keys are unset, or don't match the environment, we don't render the booking button at all. Far kinder to make booking unavailable from the outset than to let a guest get partway through and hit "An error has occurred."
Hold less, expose less
Send the payment provider only what it needs
The metadata we pass to the payment provider contains only the booking number and the dates — no name, phone number, or address whatsoever. There's no reason to leave personal data that the payment doesn't need sitting in an external service's logs.
By the same token, error messages and logs never output card details, tokens, or the URL of a guest's private page. Information that ends up in a log becomes a leak path in its own right.
Protect the scheduled job's entry point too
Scheduled jobs like the automatic charge can be triggered by anyone who learns the URL. So we put a shared-secret check in front of it — and configured it to refuse to run when the secret is unset.
A system that lets requests through unauthenticated because someone forgot to configure it is an accident waiting to happen. Far safer to fail closed: no configuration, no execution.
Wrapping up
Three things anchored our payment security.
- Never accept the amount from the client — what's sent is for comparison only; the server re-fetches what to bill
- Prevent duplicates twice over — the payment provider's idempotency keys plus unique constraints in the database
- Prevent environment mix-ups structurally — stamp the environment on the booking and never process a mismatch
What "Holding a Payment" Really Means — Card-on-File and Auto-Charge at D-14
The design behind charge timing, and the abnormal-amount guard in detail.
Safe with Just a URL — Token Authentication and Time-Based Design
The security design behind a guest page protected without a password.
Stripe Payment Design for Lodging — Choosing Not to Charge at Booking
For the full picture of the payment design, start with the hub article.