NorthBlue · payments

From tap to ticket.
Where the money goes.

Every route a purchase can take, what Stripe does at each step, where the money sits afterwards, and how it gets back out again — refunds, damage charges, chargebacks, and what is still missing. Companion to the booking engine, which covers when things can be booked; this one covers what happens when someone actually pays.

01Where a purchase goes

Not every booking takes the money at checkout, but nearly every booking takes the card. Two dials decide what happens when the guest presses Continue: booking_mode, which says whose inventory this is, and confirm_policy, which says who has to agree before the money moves.

Guest presses Continue checkout whose inventory, and who has to say yes? Partner's own site external_link we never see the money no card Card held approval · threshold authorized, not charged captured only on yes Charged now instant · over $0 card charged and captured in one step Booked outright instant · $0 vouchers, comps no card requested / held confirmed confirmed The middle route is the one worth understanding. The guest completes the card step exactly as they would for any purchase — same form, same wallet buttons — but the intent is created with capture_method 'manual', so Stripe places a hold and stops. Approving captures it. Declining, or letting it expire, releases it and the guest is never charged. This is the model Airbnb and the tour marketplaces use, for the same reason.
Prices always come from the database, so a tampered browser cannot change what the card is charged — or held for. The free route refuses priced products with a 402; without that, a direct request could book a $44 ticket for nothing.
manual_request is not a payment mode

A product set to booking_mode: manual_request never reaches Stripe at all — it creates an unpaid requested row and takes no card, whatever its confirm_policy says. That is right for a product genuinely settled offline, and wrong for anything that expects to be paid: the seller approves and then has nothing to charge. A priced product that needs someone to say yes belongs on northblue_inventory with confirm_policy: approval, which is the middle route above.

02The paid journey

Four stages between pressing Continue and holding a ticket — and one window in the middle where the seat is already yours but the money isn't ours yet.

guest Tickets chosen our database Seats held Stripe Card charged us again Confirmed nothing stored yet pending_payment money captured confirmed + emailed the seat is yours for 15 minutes nobody else can buy it — this is what stops two people taking the last one guest walks away seat frees itself at 15 min · the leftover row is tidied up after 24 h
The hold is a promise, not a payment. It expires on its own — capacity stays correct even if every background job stops running.

03The Stripe rail, message by message

Everything rides on a single Stripe primitive: the PaymentIntent. No Checkout Sessions, no saved cards, no subscriptions. One intent per purchase, charged and captured in the same instant, and its id — stripe_payment_intent_id — is the only Stripe artifact our database ever stores. It is the join key for everything that follows.

Browser NorthBlue server Database Stripe create-payment-intent only option ids + quantities — never a price booking_hold() per-experience lock · price from DB · 15-min hold amount_cents, computed in SQL PaymentIntent.create — full amount · usd · card only client_secret, via the server — the browser can now talk to Stripe directly confirmPayment — card · Apple Pay · Google Pay charged AND captured in one step — there is no auth-only hold webhook · payment_intent.succeeded signature checked against the webhook secret — unsigned bodies get 400 guarded update → confirmed only if not already confirmed · then email + seat locked A declined card fires payment_failed instead — the booking flips to cancelled and the seat frees at once.
The browser never names a price and the server never sees a card number. Each side only holds the half it is trusted with.
Card only, deliberately

Why no bank transfer or Klarna

A booking confirms only when the intent reaches succeeded. Delayed methods like bank debits sit on processing for days — the guest would be told the payment didn't complete and hold no seat while their money is in flight. Anything added to the list must settle synchronously.

Wallets ride the card rail

Apple Pay & Google Pay

Both are tokenized cards, so they change nothing about settlement. Apple Pay shows on Apple devices; Google Pay everywhere it can. The trap: an unregistered domain hides Apple Pay silently — no button, no error. It must be registered per domain, per Stripe mode.

Test vs live

Two parallel universes

Test mode and live mode have separate keys, separate webhooks with separate signing secrets, and separate Apple Pay domain registrations. Going live means swapping all of them together — a live key with a test webhook secret quietly kills the fastest confirmation path.

04Payment lands → ticket delivered

The most important stretch in the system, and the one worth understanding properly. Between “Stripe took the money” and “the guest has a ticket” there are five things that must happen, three systems racing to make them happen, and exactly one that is allowed to win.

The race

A card being charged does not confirm anything by itself. Three independent things try to close the booking. Whichever gets there first does the work; the others find it already done and stop.

1 · Stripe tells us webhook · instant · normal path 2 · Guest lands back we re-ask Stripe ourselves 3 · Sweep, every 15 min catches whatever both missed One guarded write “mark confirmed — but only if it isn't confirmed already” Then, exactly once ✓ status → confirmed ✓ hold made permanent ✓ seat locked to guest ✓ confirmation email ✓ appears in seller Sales the other two do nothing Late arrivals match no rows. That is why nobody is ever double-charged or double-emailed. If Stripe's webhook is misconfigured the system still settles — just later. Nothing is load-bearing on its own.
Three chances to confirm, one possible winner. This is the single most important safety property in the payment path.

What the guest actually receives

Two things, and they arrive by different means. The email is the durable copy; the booking page is the live one that always reflects the current state.

EMAIL sent once, at confirmation
NB
Your booking is confirmed

Hi Maya, your payment went through and your spot is locked in.

Experience
Chinatown & North Beach Food Walk
When
2026-08-14 at 11:00
Hotel
Argonaut
Confirmation
9f2a1c77-1111-4a…
View your booking →
Adult × 2$178
Total paid$178

Show this email at the experience.

The live ticket

/booking/<id>

A permanent page showing the current status, items, total and confirmation code. The link in the email points here. It is also where a refund or a later damage charge shows up — the total becomes a net, with the original price and what moved listed above it. The email is a snapshot; this page is the truth.

Redemption

How they get in

Staff check the emailed confirmation or the booking page. There is no QR code or scannable voucher yet — redemption is visual. Worth knowing before a partner assumes otherwise.

Gap

Losing the link

There is no “my bookings” lookup. If the email doesn't send and the guest closes the tab, they have no way back to their ticket. This is the strongest argument for setting the email key up before a pilot.

Where it can go wrong, step by step

MomentIf it failsWhat saves it
Seats heldSlot already fullGuest is told before any card is touched. No money moves.
Card chargedCard declinedBooking is cancelled. If they retry with another card on the same attempt, the retry now settles correctly.
Stripe → usWebhook misconfiguredGuest landing back confirms it; failing that, the 15-minute sweep does.
Guest closes tabNobody returns to the pageWebhook confirms it silently. Sweep is the third net.
Confirmation emailNo email key configuredNothing. The success page still claims an email was sent. Set the key.
After confirmingGuest wants a refundOne action in the order drawer: money back, seat freed, commission reversed, guest emailed. See §07.
The one promise the system can break

The confirmation screen tells every guest “a confirmation will be sent to your email.” If the email provider key is missing, that sentence is false and nothing is sent — silently, with no error anywhere. It is the single highest-value thing to configure before taking real money.

05Where the money actually sits

Every charge lands in one Stripe balance belonging to the platform and pays out to one bank account on Stripe's normal schedule. Money can now go back out of that balance too — refunds and the ledger that records them are built. One leg is still missing, and it is the expensive one: nothing pays a seller automatically.

Guest's card charged in full, once saved for later charges 100% refunds · full or partial Stripe balance one platform account minus Stripe's processing fee payout NorthBlue bank Stripe's payout schedule booking_payments — the ledger one row per movement, in either direction charge · refund · extra charge · dispute keyed on the Stripe object, so a replay is a no-op Seller payout — still by hand no Stripe Connect, no transfers — but Finance says exactly what is owed Every settled booking now records what the guest paid, what NorthBlue kept and what the seller is owed — and a refund reverses all three in proportion.
The old version of this diagram had two dashed legs. Refunds are now solid and recorded; the liability they used to hide is a number. What is left is Connect — paying a seller their share automatically instead of by bank transfer and a spreadsheet.
Gross and net

The split is stamped, not inferred

A booking still stores what the guest paid in amount_cents. It now also stores commission_cents, seller_amount_cents and fee_rate_bps, written once when the booking confirms. The rate is stored alongside the figures because a rate that changes must not reach backwards and rewrite what a seller was already told they earned.

Refunds are counted

A returned sale stops being revenue

Seller analytics read amount_cents − refunded_cents, so a refunded booking stops inflating gross the moment the money goes back — and the fee is taken off what is actually left, at the booking's own rate. A refund reverses the commission in proportion: booking fee income on money we returned would be indefensible.

Still to build

Connect payouts

Sellers are still paid outside the system, but they can now see what they are owed: the seller dashboard's Finance page reads the ledger directly — every movement, their share of each, and a CSV to settle against. What is missing is only the moving of the money: Connect accounts, onboarding, and a transfer per settled booking.

06“Hold” means two different things

Both kinds now exist, and they are not interchangeable. A capacity hold is a database row reserving the seat — the 15-minute one in §02. A card hold is a Stripe authorization: the guest's money is ring-fenced on their card and not taken. Which one a product gets is decided by a single dial, confirm_policy. Pay-now products are charged at checkout exactly as before. Products where somebody else has to say yes — a seller approving a request, or a min-party trip reaching its minimum — authorize instead, and the money moves only when the answer arrives. Keeping a card on file (§08) is a third thing again, and not a substitute: it gives permission to charge later, it reserves nothing.

Capacity holdCard hold / authorization
what it reservesThe seat, bike, or spotThe guest's money
lives inOur database — hold_expires_atStripe — an uncaptured PaymentIntent
applies toEvery productapproval and threshold only
lifetime15 minutes, self-expiring~7 days on a typical card
if abandonedSeat frees itself, nothing to undoAuth expires, money never moved
guest seesNothingA pending charge on their statement

The products that need a card hold

Two of the three confirm policies do not decide anything at checkout. Under approval the seller has to accept — a helicopter tour, a private charter. Under threshold the departure has to fill — “the airport shuttle runs if four people book”. Charging in full at checkout meant the guest paid for a service nobody had yet agreed to provide, and a trip that never filled was cancelled by the platform while the platform held the money. Authorizing instead makes the money follow the decision.

PAY NOW (confirm_policy: instant) — unchanged Book Charged in full capture_method: automatic Confirmed payment_state: captured Nothing left to decide. A later cancellation refunds (§07). ON REQUEST / MIN-PARTY (approval, threshold) — money follows the decision Book Card authorized pending on statement payment_state: authorized The answer seller accepts, or the departure fills Yes → capture money moves only now No → release auth vanishes — no refund, no fee, no 5–10 day wait The 7-day catch, handled: a card authorization dies after about a week, so the checkout refuses to take a hold it cannot honour. It measures to the decision point — the cutoff, not the departure — and declines anything landing more than six days out, keeping a day of slack for a late sweep. “Held” is no longer the same as “paid”: status says whether the seat is real, payment_state says where the money is, and only capture moves it.
Manual capture is one capture_method flag on the PaymentIntent — the work was everything downstream that assumed charged-equals-captured.
A called-off trip now costs the guest nothing at all

When a min-party departure misses its minimum, the cutoff sweep releases every authorization on it before the refund pass runs — so for a booking that was only ever held, there is nothing to refund and no processing fee on a sale that never happened. Bookings that genuinely were charged (a pay-now product, or a legacy row from before this existed) still go through the full refund path in §07, which owns the ledger and the guest email. Two mechanisms, one outcome: the guest is never out of pocket for a trip that did not run.

What a request carries besides a card

A transfer is booked against a flight, not a clock. flight_airline, flight_number and flight_departs_at are their own columns on the booking — set by experiences.requires_flight_details, asked at checkout, and shown to the seller above the contact card, because on a transfer the flight is the booking. They are deliberately not packed into guest_notes: the note is the guest's own words, and a note is the one field a seller cannot sort tomorrow's runs by. Same shape as guest phone and room. See supabase/flight-details.sql.

Rentals: a card on file, not a deposit

Bike-style rentals need damage cover and late-return charges. What exists is the card saved at checkout, chargeable after the fact for a stated reason (§08) — not a deposit. Nothing is held, so there is no agreed ceiling and no pending charge on the guest's statement, and the guest's bank can still refuse the charge when it comes. A real deposit is authorize-at-pickup, capture-some-at-return: the machinery above is now the same shape, but pointing it at rentals is still unbuilt.

07Returns & refunds

Giving money back used to be a two-half manual dance — refund in the Stripe dashboard, then remember to cancel the booking — and doing only one half left the system lying to somebody. It is now one action that does both halves, records the movement, reverses the platform's cut and emails the guest. What each guest gets back is decided by the product's own policy, in numbers.

Refund seller admin, or the system amount defaults to the policy Stripe refunds.create idempotency key from the state it was decided against money has left — the rest is bookkeeping Booking updated refunded_cents, refunded_at full refund → cancelled, seat back on sale Ledger row kind = refund keyed on the refund id, so a replay is a no-op Commission reversed in proportion, at the rate the booking was sold under analytics follow immediately Guest emailed amount, what was kept, and that a card takes 5–10 days to show it A refund issued by hand in the Stripe dashboard still lands here: charge.refunded syncs the totals from Stripe, cancels the booking if it was refunded in full, and writes the missing ledger rows.
Stripe first, database second. If the bookkeeping fails afterwards the money has still gone back — which is the outcome the guest cares about — and the webhook re-syncs the totals. That ordering is deliberate.

The policy, in numbers

cancellation_policy is prose a human reads; nothing could compute a refund from it. Each product now also carries refund_policy, the same promise as three numbers — so the seller sees the amount before they press the button, and every guest in the same situation gets the same answer.

When they cancelDefaultWhat the guest gets
More than free_until_hours before the start 48 h 100% back Nothing has been committed on their behalf yet.
Inside that window, before the start late_refund_bps 7500 75% back The guest forfeits 25%. The seat was held off sale too late to resell.
After it has started cutoff_hours 0 Nothing The experience was delivered, or the place was there and went unused.
We cancelled — called-off trip, seller cancelled the departure 100% back, automatically The policy does not apply: the guest did not change their mind, we did.
A seller can always override the number

The policy fills the amount in; it is not a cage. A seller admin can type any figure up to what is left of the charge — a goodwill gesture on a no-show, a partial for a trip cut short by weather. What they cannot do is refund more than the guest paid, or refund a booking that is not theirs.

Every way money goes back

Five triggers. Four of them are now handled by the system; one still belongs to Stripe, because a chargeback is not a request we get to answer.

ScenarioWho triggers itWhat happens
Guest cancels in time Guest, via the hotel desk or the seller Built A seller admin refunds from the order drawer. The policy fills in the amount; a full refund frees the seat.
Guest cancels late / no-show Guest, or nobody Built Same action, and the policy quotes the reduced figure — or none — without anyone having to argue about it.
Seller cancels a departure Seller, from the calendar Automatic Cancelling a departure settles every booking on it in the same action: a card hold is released, a booking that really was charged is refunded in full, and the confirmation message says how many of each. Closing one still only stops new sales.
Min-party trip never fills The system, at cutoff Automatic The 15-minute sweep that calls the departure off settles everyone on it in the same pass — releasing the holds, refunding anyone charged up front. This was the worst gap on the page: charged in full, cancelled by us, money kept.
Card dispute / chargeback Guest's bank Recorded, not fought charge.dispute.* stamps the booking and writes a ledger row; a lost dispute cancels the booking so the seat is not held for money we no longer have. Submitting evidence is still done in Stripe.
Two Stripe facts that have not changed

A refund returns the guest's money but not Stripe's processing fee — every refunded sale costs the platform that fee, which is exactly why the authorize-first path in §06 is worth having: a released authorization was never a sale, so there is no fee to lose. And refunds can race the payout: money already paid out is clawed back from the next one, which can briefly drive the balance negative.

Still missing: a guest doing it themselves

There is no guest-facing cancel button. A guest who wants out emails the hotel desk, and somebody with a seller admin login does it for them. The policy being machine-readable is the prerequisite for self-service — that part now exists, the button does not.

08Charging after the fact

Some money is only owed once the experience is over: a cracked mudguard, a bike back two hours late, an extra the guest agreed to on the day. Collecting it means charging a card that is no longer in front of anybody — which is a different problem to taking a payment, and has a different failure mode.

Seller raises a charge amount + a reason the guest reads capped at $2,000 without an admin is a card saved? Off-session PaymentIntent on the saved card customer + payment_method + off_session, confirmed immediately idempotent on (booking, amount, reason) — a double click cannot charge twice succeeded refused, or the bankwants the cardholder Money in, immediately ledger row succeeded · extra_charged_cents re-totalled from the ledger, never incremented guest emailed a receipt naming the reason Hosted invoice, emailed ledger row requires_action — asked for, not collected guest pays it themselves, due in 7 days invoice.paid settles it days later No card saved at all — card-on-file turned off, or an older booking — skips straight to the invoice.
The fallback is the point. An off-session charge can be refused for reasons that have nothing to do with the guest, most often authentication_required — the bank wanting the cardholder present. Without a second route, a seller with a genuine claim would have no way to collect.
Consent

The card is saved with the guest told

Checkout attaches a Stripe Customer and asks Stripe to keep the card. The payment step says plainly that the operator may charge it for damage or a late return, the Terms carry the same clause, and card_on_file_consent_at records the moment it was shown. A charge weeks later against a card nobody remembers agreeing to save is how a chargeback starts.

Not a sale

Damage passes through whole

The platform takes no commission on a post-booking charge. Recovering the cost of a broken mudguard is not revenue to split, so seller_delta_cents is the entire amount and the commission delta is zero.

Guardrails

Ceiling, reason, and a name against it

Only a seller admin can raise one, only against their own booking, only up to NORTHBLUE_MAX_EXTRA_CHARGE_CENTS — $2,000 by default — and only with a reason, because the guest is shown it. Every charge records who made it.

What this is not

It is not a deposit. Nothing is held or authorized at checkout, so there is no ceiling agreed in advance and no pending charge on the guest's statement — the card is simply available if something is owed. A true damage deposit is the authorize-and-capture design in §06, and it is still unbuilt.

Turning it off

NORTHBLUE_CARD_ON_FILE=off stops checkout saving cards. Existing saved cards keep working; new bookings simply have none, and every charge against them falls back to an emailed invoice. The checkout disclosure follows the setting, so the sentence a guest reads is never shown for a card that was not kept.

09What every status means

Nine states a booking can sit in. “Seat taken” is the operational one — it decides whether the slot is still on sale to somebody else. Note what is not here: there is no “refunded” status. Whether money went back is refunded_cents, deliberately separate, because a partly refunded booking is still a live booking with a guest turning up.

StatusIn plain termsSeat taken?
requestedGuest asked, seller must approve. The card is authorized, not charged — or untouched, on a product that takes no card.No
pending_paymentSeats held, card not charged yet. Dies after 15 minutes.Yes — until it expires
heldMin-party trip waiting for enough people to join. The card is authorized, not charged.Yes
confirmedPaid (or free) and booked. The guest has a real ticket.Yes
completedSeller marked it delivered after the fact.
cancelledCalled off by the seller, the card was declined, or it was refunded in full. Read refunded_cents to tell which.No
releasedAn expired hold tidied away, or a request the seller declined. Any card hold is released.No
abandonedNobody paid within a day. Written off.No
called_offMin-party trip that never reached its minimum. Holds are released and anything actually charged is refunded in full, both by the sweep that called it off.No
Where the money sits is its own field

payment_state answers “what happened to the card”, separately from status, which answers “is the seat real”. It reads authorized while funds are held, captured once taken, released when a hold was dropped without ever charging, and refunded when money was taken and given back. The two can disagree legitimately — a confirmed booking whose departure has just filled is briefly confirmed + authorized, until the sweep captures it — which is precisely why they are not one column.

The money is three numbers, not a status

amount_cents is what the guest agreed to pay, refunded_cents is what went back, and extra_charged_cents is what was collected afterwards for damage or a late return. A full refund also flips the status to cancelled, because the seat has to go back on sale — but the status is the consequence, not the record.

Seats are counted, never stored

There is no “seats left” number anywhere in the database. Availability is worked out live by subtracting the holds that still count. An expired hold stops counting the instant it expires — which is why capacity stays correct even if every cleanup job stops running.

10Three ways money used to go missing

Found in the pilot audit, all now fixed and covered by tests. Each one took a real card payment and produced no booking.

BEFORE — only one status could settle pending_payment held cancelled charged, stranded, and told “your card was not charged” NOW — anything not yet confirmed can settle pending_payment held cancelled confirmed stays out — that is the safety catch
Leaving confirmed outside the set is deliberate: it is what turns a late webhook into a harmless no-op instead of a second charge.
Fixed

Min-party trips

A group tour charged the card in full but filed the booking as held — a status the confirm step didn't recognise. The guest paid, was told their card was not charged, and the booking was written off a day later.

Fixed

Retry after a decline

A declined card marked the booking cancelled. Stripe lets the guest try another card on the same attempt — that second payment went through, but the booking could never come back, and no safety net could see it.

Fixed

The free-ticket back door

The “skip payment when it's free” check only existed in the browser. A request sent directly could book a $44 ticket for nothing. Reproduced against real data, then closed with a 402.

Fixed

Cleanup that never ran

Two housekeeping routines existed but were never called, so stale “awaiting payment” rows piled up forever and group trips never reached a decision. Both now run every 15 minutes.

11When something looks wrong

Four questions, in this order.

  1. Guest paid but it still says awaiting payment. Check Stripe → Developers → Webhooks for failed deliveries. The 15-minute sweep will settle it regardless; if webhook deliveries are red, the signing secret is wrong.
  2. Stuck on awaiting payment. Normal for 15 minutes. Past that it's an abandoned checkout — the seat is already free even though the row still shows.
  3. Charged but no email arrived. Emails need a provider key. Without one nothing sends, even though the success page promises it.
  4. Seat count looks wrong. Don't read status alone — expired holds still appear in lists but no longer occupy a seat.
  5. A refund was issued but the booking still looks live. The charge.refunded webhook is what syncs a dashboard refund back. If deliveries are red, the totals will be stale until it is fixed — refund from the order drawer instead, which writes both sides itself.
  6. A seller says they cannot refund or charge. Both are seller-admin only, and only on their own bookings. Check the account has an admin membership on the seller org.
  7. A seller disagrees with what they are owed. Send them to Finance in the seller dashboard. Every movement is listed with its own share and a CSV export; the figures come from the ledger, not from a recomputation, so they agree with Stripe by construction.
  8. A damage charge became an invoice instead of going through. The bank refused the saved card — usually it wants the cardholder to approve. The guest has been emailed a payment link; it settles when they pay it, not before, and the ledger row says requires_action until then.
Refunding: use the order drawer, not the Stripe dashboard

The drawer does both halves — money back and the seat released — and records what happened. A dashboard refund still works and is synced back by the charge.refunded webhook, but it depends on that webhook being healthy, and it cannot record who did it or why.

12Switching to real money

Test and live are two parallel universes with nothing shared between them. Going live is not one switch — it is a set of them, several of which fail quietly if you flip one and forget another. npm run stripe:check asks Stripe about all of it and prints one list; this is what it is checking, and why each one matters.

SwitchWhereWhat happens if it is wrong
Secret key sk_live_… Vercel env Still on the test key: real cards are declined, and nothing that happens is real.
Publishable key pk_live_… Vercel env Silent Mismatched modes mean the browser confirms an intent the server never created. The card form loads and then fails on submit.
Webhook endpoint + signing secret Stripe → Developers → Webhooks, then Vercel env Silent A live key with a test signing secret rejects every webhook with a 400. Bookings still confirm — fifteen minutes late, via the sweep — so the symptom is "sometimes slow", not "broken". Refund and dispute syncing simply never happens.
Enabled events The same endpoint Missing charge.refunded and the booking drifts from Stripe after any dashboard refund. Missing invoice.paid and a guest can pay an invoice that is never marked collected.
Apple Pay domain npm run stripe:domains, with the live key Silent No button, no error, nothing in the network tab — indistinguishable from a guest with no card set up. Register the apex and the www host: Apple treats them as different domains.
Account verified Stripe onboarding charges_enabled false refuses payments outright. payouts_enabled false is worse: money accumulates in the balance and never reaches the bank.
Statement descriptor Stripe → Settings → Public details An unrecognisable name on a bank statement is the most common cause of a chargeback. A guest who booked through a hotel may not remember NorthBlue at all.
Email provider key Vercel env Silent Without it no confirmation, refund notice or charge receipt sends — while the success page still promises one. An unexplained charge with no receipt is a dispute waiting to happen.
Fees migration supabase/product-fees.sql Without it no product can carry a fee: the editor's fee card saves minus its fees, checkout charges the tickets alone, and the airport transfer keeps advertising its curbside charge as a ticket. Nothing breaks — a fee the row cannot record is simply not taken. See §13.
Database migration supabase/money-out.sql Refunds and charges cannot record anything. The app is written to survive this — bookings keep working, the columns are simply absent — but every figure in §05 stays blank until it runs.
The one that costs money to learn

A live key with a test webhook secret is the failure worth rehearsing, because everything looks fine. Guests pay, get their tickets, and nobody notices that confirmation is running fifteen minutes behind on a sweep — until a refund issued in the dashboard never reaches the booking, and the seat stays off sale with the money already gone.

Before the first real card

Run npm run stripe:check with the live key and get to zero blocking items. Then take one real booking on a real card, refund it from the order drawer, and confirm three things: the money came back, the booking reads cancelled with the seat on sale, and both emails arrived. That single round trip exercises nearly every path on this page.

13Fees on top of the price

A product's price used to be its tickets and nothing else. That left a seller passing on an airport's curbside charge with one place to put it — a ticket option called “Airport entry & curbside fee” — which sat in the ticket list as if a guest could choose it, could be left out, and dragged the “From $X” headline down to the cheapest add-on: a $100 van advertised at the price of its $5 child seat. Fees are now their own thing, and there are two kinds, deliberately kept apart. Additional fees are the seller's — fixed amounts, per booking or per ticket, named by them, listed on the product page under the rates. The booking fee is NorthBlue's: 6% of the ticket subtotal, one rate for every product, which a seller can only switch on or off, and which appears at checkout and nowhere earlier.

Product page the seller's price, and only that Tickets & rates Private van, up to 6$100 Child seat, installed$5 Additional fees Airport curbside · per booking$10 booking fee: not shown here From $100 — advertised_price_cents wins Checkout computeFees() — same function both sides Tickets$100.00 Airport curbside fee$10.00 Processing fee · 6% of tickets$6.00 Charged$116.00 6% of $100, not of $110 — the seller's fee does not grow the platform's cut. bookings.amount_cents = 11600 · fee_lines = the three rows above Split at confirmation splitWithBookingFee(11600, 600) Seller $110 − 4%$105.60 NorthBlue $6 fee + $4.40 commission$10.40 fee_rate_bps = 897 (effective), so refunds reverse in proportion. commission + seller = amount, always Where the fees land on the row: booking_hold() still prices the tickets and takes no fee argument, so applyBookingFees() writes them onto the booking after the hold and before the PaymentIntent exists — and the intent is created for the total that write returns. On a database without the columns the write fails, the tickets alone are charged, and the guest pays what the product page said. A fee the row cannot record is not taken.
The worked example is the live airport transfer. Its curbside charge was a $10 ticket option until supabase/product-fees.sql moved it into experiences.fees and switched the option off — past bookings still reference it, so it is not deleted.
Two kinds

A fee is not a ticket

A ticket is something the guest chooses; a fee is something they owe once they have. A child seat stays a ticket — optional, picked, counted. An airport's curbside charge is a fee: not optional, not picked, added to whatever they chose. The editor keeps them in separate cards for exactly that reason, and the “From $X” headline is built from the tickets alone.

Shown where

The product page tells the seller's truth

Additional fees are listed under the rates with their basis — per booking or per ticket — so nobody meets them for the first time at the card form. The booking fee is the platform's, one rate for everyone, and belongs on the checkout total next to the button that agrees to it. It is never on the product page, the way a marketplace guest expects.

The split

Effective rate, not nominal

The booking fee is NorthBlue's whole and the 4% is taken on the rest, but fee_rate_bps is stamped as commission over gross — 8.97% in the example, not 400. Everything downstream multiplies the gross by that one number: a refund reversed at the nominal rate would hand the seller back a slice of a fee they were never paid.

What the seller can and cannot set

Up to eight additional fees, each with a label, an amount and a basis. The booking fee is a checkbox — on or off — and the rate lives in one constant, BOOKING_FEE_BPS in src/shared/fees.ts. A negotiated per-seller rate would be a column next to booking_fee_enabled; nothing else would need to change.