Charge the player only when the game is on
Charging at booking time turns every change of plan into a refund. In this marketplace the card is now held first and captured only when the game is really going ahead, groups can split the cost, and each Stripe event is claimed before it is applied, so a repeat delivery is skipped.
- Market
- Court booking marketplace, Australia
- My role
- Lead engineer and backend architect
- Payments
- Card, Apple Pay, Google Pay
- Money moves
- Player to platform to venue
ProblemTaking the money at booking time meant every cancelled or changed game became a refund, with a wait for the player and work for the venue.
What I didI built hold-first payments with manual capture, split payments with one hold per payer, a processed-events store for webhooks, and one cancellation engine.
ResultMoney moves only when the game is on, a repeated Stripe event is skipped, and the refund a player is shown is the refund they get.
A refund is the most expensive way to say no
The marketplace lets players in Australia find a sports venue, book a court and pay in the app or on the web. Venues run their day in a portal. The platform takes a fee on each payment, and the rest goes to the venue.
The simple way to take payment is to charge the card when the player books. It looks fine until plans change, and with sport, plans change all the time. A friend drops out. It rains. Someone books the wrong day. Every one of those becomes a refund: the player waits days to see their money back, the venue has a cancelled booking to deal with, and a confused player may open a dispute with their bank instead of asking us.
Groups make it worse. A court is booked by one person but played by four. With charge-at-booking, one person pays for everyone and then chases the others for money, outside the app, for weeks.
I was the lead engineer on this platform and owned the server, so the payment design was mine to get right. The goal was a flow where saying no to a booking costs nobody anything.
Holds expire, webhooks repeat, and every payer has a deadline
Holding a card instead of charging it is a feature Stripe offers: you authorise the amount now and capture it later. But an authorisation does not last forever. If you do not capture it in time, the bank lets it go and the venue is never paid. So every hold needs a deadline, and something has to act on that deadline reliably, at any hour.
Stripe reports what happens to a payment with webhooks, and it delivers them at least once. The same event can arrive twice. An event can also arrive late, after our server was busy or briefly down, because Stripe keeps retrying for days. Any handler that adds a ledger row or sends a receipt has to be safe to run again.
Cancellations have rules. Inside the free window, the player gets everything back. Late cancellations keep a fee. A venue that cancels refunds in full. The player sees a preview of their refund before they confirm, and that preview must match what actually happens, to the cent.
Split payments multiply all of this. If four people share a court, there are four cards, four holds and four chances for something to go wrong, each with its own deadline, and the venue still has to be paid for the full court.
Follow one payment from authorised to captured
- Step 1
Authorised
The player confirms their card, Apple Pay or Google Pay. Every payment is created with manual capture, so the bank places a hold for the amount and nothing is charged. The amount comes from the server's price, never from the app.
- Step 2
Held
Stripe tells the server the hold is in place, and the booking is confirmed. The server works out when this payment should be captured and stores that time on the payment. If the same webhook arrives again, the server sees it has already handled that event and changes nothing.
- Step 3
Captured
When the free-cancellation window closes, the game is on. A scheduled job finds every hold that is due and captures it. The charge goes through, and the venue's share follows.
- Step 4
Or released
If the player cancels inside the free window, or the venue cancels, the hold is simply cancelled. The money never left the player's account, so there is nothing to refund and nothing to wait for.
Capture at the last fair moment, and never too late
The capture time is the heart of hold-first. I made it the earlier of two moments. The first is the end of the free-cancellation window for that booking: the point where the player can no longer cancel for free, so the game is really on. The second is just before the bank's authorisation would expire, with a safety margin, so the hold is never lost.
For most bookings the first moment wins. For bookings made far ahead, the authorisation would run out before the cancellation window closes, so the second moment wins and we capture earlier. Either way, the time is stored on the payment when the hold is confirmed.
A scheduled job on the server looks for payments whose capture time has passed and captures them. Because the time is stored, the job does not need to understand booking rules. It only needs to find what is due.
One bug taught me to fix the capture time before anything else. Working it out needs a live call to Stripe, and that call used to sit after the booking was created and the confirmation email was sent. If the call failed, the booking was confirmed and emailed, but the payment had no capture time, so the job never picked it up. The authorisation expired and the venue was never paid. I moved the call to the start. Now a failure happens before anything is promised to anyone, and Stripe retries the whole event cleanly.
A single booking, as the records see it
| Event | State | Amount |
|---|---|---|
| Player confirms the card | Requires capture | A$48.00 |
| Webhook: hold is in place | Held | A$48.00 |
| The same webhook, delivered again | Ignored | A$0.00 |
| Free-cancellation window closes | Captured | A$48.00 |
The same webhook, twice, changes nothing
Stripe promises to deliver each event at least once. For a payments system that is dangerous. A handler that writes a ledger row, issues a receipt or fulfils a membership will do it twice if it runs twice.
So the webhook endpoint first verifies Stripe's signature, then claims the event before doing anything else. It inserts the event's ID into a processed-events store with a unique index. If the insert succeeds, this delivery owns the event and the handler runs. If the insert fails as a duplicate, another delivery of the same event already ran, and the endpoint answers "already processed" without touching any money.
The opposite case matters just as much. If the handler fails halfway, for example because the database had a short outage, the endpoint deletes its claim and returns an error. Stripe then retries, and the retry is not thrown away as a duplicate. The handlers themselves use conditional updates, so running one again after a partial failure is safe.
The same idea runs through split payments. When a co-player's hold is confirmed, the update only matches while their share is still pending or failed. A redelivered webhook finds nothing to change.
One live hold per payer, whatever they retry with
Webhooks are not the only thing that repeats. People do too. A player taps Pay, nothing seems to happen, and they tap again. Or Google Pay fails on the phone and they switch to a card.
An early version treated "a payment already exists for this booking" as a conflict. That caused a real lockout. A Google Pay sheet that failed on the device produced no event from Stripe, so our payment row stayed pending, and every retry with a card was refused. The player could not pay at all.
I changed the rule. Stripe does not use up a payment after a failed attempt; it can be confirmed again. So now the server asks Stripe what state the existing payment is in, and hands the same one back whenever it can still be confirmed. That also prevents double charges: there is one live hold per payer, whatever they retry with, instead of a fresh authorisation for each attempt.
Two taps are also stopped in three more places: an idempotency key on the create call that includes the amount, an upsert on our payment row so a race cannot leave a second one pending, and a guard in the app itself. Each layer covers a gap the others leave.
Four players, four holds
With a split, the booker reserves the court, and the server works out how many players share it from the sport and the court, and what each share is. The app never sends amounts. Each co-player gets a request to pay their share, which creates a hold on their own card, not a charge.
Splits can start empty. The booker can reserve the court first and invite people into the open places later, from the booking screen. That matches how games really come together: the court first, the players after.
Every share has a deadline to be authorised, set before the booking's capture time. A scheduled job marks shares that miss the deadline as expired. At capture time, the server captures every co-player's hold first, then captures what is left from the booker's hold. The booker's hold is the backstop: if a co-player never pays, their share falls back on the booker, and the venue is still paid for the full court.
Nobody pays for everyone up front, and nobody has to chase friends for money outside the app.
The refund you are shown is the refund you get
I rebuilt cancellations around one engine. The same code works out the preview the player sees before confirming and the real cancellation that follows. There is no second calculation that can drift from the first.
The engine knows where the payment is in its life. If the player cancels inside the free window, the hold is released. If they cancel late but before capture, the server captures only the fee that the rules keep, and the rest of the hold is released. If the payment was already captured, the server refunds everything except that fee. Co-players are refunded in full inside the free window. A cancellation by the venue returns the customer's money in full.
Then I made the paperwork follow reality. In an early version, if Stripe refused part of a cancellation, the code could still record success. A failed release of a co-player's hold marked them as cancelled and told them their hold was released while it was still on their card. A failed partial capture was stored as succeeded. Credit notes were issued from the preview list, so someone whose refund had failed could receive a document saying they were refunded.
I fixed all three. A failed release now stays visibly unreconciled and sends nothing. A capture is recorded as succeeded only when it actually succeeded. Credit notes follow what Stripe actually did, not what we planned.
The choices behind hold-first payments
Charge now, or hold first?
Charge at booking, refund when plans change
Hold with manual capture, captured at a stored deadline
A cancelled game is a released hold, not a refund. The player never waits for their own money.
What if the same event arrives twice?
Trust Stripe to deliver it once
A processed-events store, claimed before any handler runs
Each event changes money once. A failed handler releases its claim so Stripe's retry still counts.
Cancel preview and real cancel?
Two calculations, one for each screen
One engine for both
The refund shown is the refund given, because it is the same code.
A court shared by four players?
One hold on the booker's card for the whole court
One hold per payer, with the booker's hold as the backstop
Nobody pays for everyone, and the venue is still paid in full.
Claim the event, then touch the money
async function onStripeEvent(event: Stripe.Event) {
try {
await processedEvents.create({ eventId: event.id });
} catch (err) {
if (isDuplicateKey(err)) return "already processed";
throw err;
}
try {
await handle(event); // conditional updates only
} catch (err) {
await processedEvents.deleteOne({ eventId: event.id });
throw err; // non-2xx, so Stripe retries
}
}Say when the money moves, in plain words
Hold-first payments have more states than a simple charge, and none of them mean anything to a player. So I gave customers one small set of words: successful, failed, refunded. Venues see the same set. The internal states, like requires capture or authorised, stay inside the server.
I also closed four silences on the money paths. A hold that was declined at capture time, hours before play, used to be logged and nothing else, so the player could turn up to a booking that was not paid. Now they are told. The same went for a failed payment, a refund started from the Stripe dashboard, and a co-player removed from a booking who still had a "pay your share" request queued. Each now ends with a clear message, or with the queued request cancelled.
Saving a card works with holds too. The card is saved at the moment the player confirms, so the payment can still wait for capture. Each user has exactly one Stripe customer record, with their name on it.
Wallets needed their own care. In the app, a Google Pay choice used to fall through to the card flow, where it could not work. I wired it to the wallet confirmation instead, and made the wallet's environment follow the mode of the Stripe key, so a test build never talks to a live wallet. Apple Pay needed its merchant setting in the place the Stripe plugin actually reads it.
Finally, the fee we estimate before payment is not always the fee Stripe charges. International cards and wallets can cost more. So when a charge succeeds, the server reads the real Stripe fee and the card's issuing country from Stripe and stores them on the payment. The records show what really happened, not our guess.
What hold-first changed
Money should move as late, and as rarely, as possible
The biggest lesson is about timing. Most payment problems in a booking product come from moving money before the outcome is known. Holding first and capturing at the last fair moment removes a whole class of refunds, and with them the disputes and support work that follow.
The second lesson is that every external call can fail, and the code must say so honestly. Each of the bugs I fixed in cancellations had the same shape: an error was caught, and the next line wrote down success anyway. Now a write that claims something happened sits after the thing that happened, not beside it.
The third is to design for repeats from the start. Stripe repeats events, people repeat taps, and the database can repeat a transaction. A payment flow that is only correct when everything happens once is not correct.
Building a payments flow?
Tell me what is breaking. If your product charges too early, refunds too often or cannot tell a repeated event from a new one, I can help you fix it.