Skip to content
cheekypayments
For venues and their developers

Integrating a venue
with Cheeky Till.

The whole journey, from applying to the URLs a venue's own website links into. Section 7 is a contract: those routes are a public interface, and they are safe to build on.

Reading this from a coding agent? Every page on this site has a markdown twin at the same path with .md appended. Fetch cheekypayments.com/venues/integrate.md for the whole thing in one request.

1. Applying and onboarding

What a venue signs up to, which questions it will be asked, and what has to be true before money can move.

Three parties, not two. The venue contracts with Cheeky Payments Limited for the software, and separately with Stripe for the account that holds the money — that second one is the Stripe Connected Account Agreement, and it is accepted during onboarding rather than signed anywhere else. Card payments are charged directly on the venue's own connected account. Money from a booking is the venue's from the moment it settles; it is never pooled and never passes through a Cheekypayments balance on its way.

One consequence is worth stating early, because it shapes everything downstream: a venue's Stripe account is its own. Refunds, payouts, disputes and the customer's card details all live there. This platform reads that account and writes to it, but it is not the custodian of it.

The path through the product

  1. Sign up at /sign-up with an email and password. This creates the login, nothing else.
  2. Onboarding at /dashboard/onboarding. This creates the Stripe connected account and collects what Stripe needs on it.
  3. Till setup at /dashboard/till. This creates the merchant record and the first venue, and is where everything in sections 2 to 6 is configured.

The order matters. Till setup works without a finished Stripe account — a venue can build its bays, hours and prices while the identity checks are still running — but the booking page cannot take a card until Stripe says the account can accept charges, so leave Take bookings online off until it can.

Sole trader or limited company

This is the single answer that changes the rest of onboarding, and it is not a preference — it is what the business actually is. Stripe asks a company about its officers and a sole trader about the individual, and it never asks one the other's questions. Four steps are common to both:

  • Your business — the name customers see on their statement, and roughly what is sold.
  • Payout bank — a UK sort code and account number. These are exchanged for a token before they reach the account record; they are never written to this platform's database or its logs.
  • Identity — documents, if Stripe asks for them. It often does not ask on a fresh account, because it has nothing to verify yet. This step showing as satisfied on day one means "not asked", not "done".
  • Terms — accepting the Stripe Connected Account Agreement.

A sole trader is additionally asked for the person: the name and date of birth as they appear on the ID, and a home address. A limited company is asked for considerably more — the company as Companies House holds it, its registered address, a named representative with their own address, and then every director, anyone owning 25% or more, and anyone else authorised to sign.

A company account can stall on Stripe's cross-check against Companies House even when every person on it is complete and verified — it keeps asking for director or owner names it already has. The stepper handles this correctly: the people step stays open and Stripe's own hosted flow finishes the job. If a company account will not clear, that hosted link is the route through, not another pass at the form.

What has to be true before money can move

Two flags on the Stripe account, both visible in the dashboard: charges enabled and payouts enabled. Until the first is true, a card cannot be taken; until the second is, money accumulates in the Stripe balance but does not reach the bank. Stripe also publishes a list of what is still outstanding, and the onboarding page renders it step by step rather than as a wall of requirement keys.

Most accounts clear within minutes. Where more is needed it is usually one document, and the turnaround is hours rather than days. Nothing about a venue's setup work is blocked while this runs.

2. Venue setup

Locations, bays, opening hours, prices and the booking grid — and why the buffer is zero.

The objects nest: a merchant is the business, a location is a venue with an address and a clock, and a resource is a thing that can be booked — a bay, a table or a room. Everything else hangs off a location.

The location

SettingWhat it does
Web address (slug)The public URL: /book/<slug>. Lower-case, hyphenated, 3 to 60 characters. Changing it breaks every link already published, including the ones in customers' confirmation emails.
Time zoneEvery pricing rule and the trading day are read against this clock, so a rule that says 17:00 survives the clocks changing.
Take bookings onlineThe master switch. Off, /book/<slug> returns a 404 — not a "closed" page. Leave it off while setting up.
Payment at bookingWhole session, paid up front, A deposit, card kept for the rest, or Nothing; pay at the venue. The last of these confirms the booking immediately with no Stripe round trip at all.
No-show feeCharged to the saved card when a booking is marked as a no-show. £0 for none.
Free cancellation untilHours before the start. Inside the window a customer cancelling online is refunded automatically.
Hold minutesHow long an unpaid booking blocks its slot before lapsing and freeing it. Default 10.

Bays, tables and rooms

A resource has a name unique within the venue, a capacity, and two switches: bookable decides whether the public page will offer it, and active whether it exists at all. Capacity is what the party-size filter on the booking page tests against, so a two-seat bay will not be offered to a party of four.

Each resource also carries its own access token, which is what the QR code on the bay points at. Section 4 covers that.

Opening hours are the pricing rules

There is no separate opening-hours table, and this catches people out. A pricing rule is both "we are open" and "this is what it costs". A day with no rule covering a kind of resource is closed for that kind — not free, not £0, closed.
FieldMeaning
DaysA weekday mask, ISO order: bit 0 is Monday through bit 6 Sunday. 31 is weekdays, 96 is the weekend.
From / toMinutes from the venue's local midnight. The end may exceed 1440 for a venue that trades past midnight — 02:00 is 1560.
Slot length15, 30 or 60 minutes. This is the grid the booking page draws.
PricePer slot, not per hour. A 30-minute slot at £15 is £30 an hour.
Member priceOptional. What a member pays per slot once their included allowance is spent.
Minimum / maximumBoth must be whole multiples of the slot length. The maximum may be left open.
PriorityWhere rules overlap, the highest priority wins, then the most recently created.
Valid from / toOptional dates, for a seasonal or promotional rule that should expire on its own.

How the grid is drawn

The booking page offers durations in slot steps, from the smallest minimum any of the day's rules sets up to the largest maximum, and then every start time at which some resource is free for that whole duration, each with its computed price. It hides any start less than fifteen minutes away, because a customer cannot realistically be at the bay by then and showing it reads as a fault. It offers 21 days ahead.

Prices shown are recomputed from the rules and the existing bookings when the customer submits. The URL carries the customer's choice; it never carries a price, and a price pushed in from outside is ignored.

The buffer, and why it is zero

A location has a Buffer between sessions setting for time to reset a bay. It defaults to zero, and on a grid it should stay there. This has been considered and rejected twice, so it is worth writing down properly rather than rediscovering.

The conflict check keeps the buffer clear on both sides of a booking. On a grid, any non-zero buffer therefore blocks the whole adjacent step: a five-minute buffer costs 15, 30 or 60 minutes of sellable time per changeover, depending on the rule's slot length. Nor can the session be shortened to 58 minutes to make room — a booking's duration has to be a whole multiple of the rule's slot length, and those are 15, 30 and 60.

Where a bay genuinely needs clearing between golfers, the gaps that already exist in a day's bookings do it, and Bay Lock resets the simulator in them. A venue that is back to back all day has no idle moment to reset in, buffer or no buffer.

Packages

A package is a fixed duration and party size at a fixed price, with its own deposit — two hours, four drinks and a pizza as one sellable thing. Selecting one on the booking page replaces both the duration picker and the rule price. Packages are offered alongside ordinary bookings, not instead of them.

3. Memberships

Plans, included minutes, and the Stripe products, prices and subscriptions behind them.

A membership is a Stripe subscription on the venue's own connected account, not on a Cheekypayments account. The venue sets the price, the venue is paid, and the member's card is managed in the venue's Stripe. Plans are defined once per merchant and are offered at /book/<slug>/membership.

What a plan carries

FieldMeaning
Price and intervalMonthly or yearly.
Included minutesBay minutes granted each period, drawn down before any card is charged. Zero for a plan that is only about access and member rates.
Member pricingWhether the rules' member price applies once the allowance is spent. On by default.
Access hoursalways, or a per-weekday set of windows in the venue's time zone. This governs the standing member code, not the booking page.
Booking days aheadHow far ahead a member may book, which can exceed the public 21 days.

The Stripe side

Saving a plan mirrors it to a product and a recurring price on the venue's connected account. A plan with no Stripe price is not offered on the public page at all, which is the safe failure: a member cannot join a plan that cannot bill them.

Joining creates a Stripe Checkout session in subscription mode on that account. Nothing is written on this side until Stripe confirms — the membership, the customer and the standing access code are all created by the webhook, not by the form. That is why the joined page refreshes itself for a second or two after payment.

Four Connect webhook events keep it in step: customer.subscription.created, customer.subscription.updated, customer.subscription.deleted and invoice.paid. Without them a membership is created and then never renews, lapses or cancels correctly. Section 8 has the full endpoint setup.

How a member books

By entering the same email on the booking page. There is no member login and no password. The page shows the allowance remaining and member prices; the server re-checks both when the booking is submitted, so a stale or forged member parameter buys nothing.

Included minutes are spent first, then member prices apply to the remainder. A member cancelling inside the venue's window gets their included minutes back and their booking code revoked.

The member's own page

/book/<slug>/account takes an email and sends a signed link; /book/<slug>/account/<token> is the page itself — plan, minutes left, standing door code, coming bookings, and Stripe's Customer Portal for the card and for cancelling. Links last thirty days. The request page says the same thing whether or not the email matched, so it cannot be used to discover who is a member.

4. Access

Access codes, the bay page, doors and bay power.

Every confirmed booking is issued a six-digit code, unique among the live codes at that venue. It appears in the confirmation email, on the confirmation page and on the manage page. Members additionally hold a standing code for their plan's access hours.

When a code works

SettingDefaultEffect
Access lead minutes15How long before the booking starts the code begins working.
Access grace minutes10How long after it ends the code keeps working.
Bay power lead minutes2How long before the start the bay switches on.
Bay power grace minutes5How long after the end it stays on.

The power window is deliberately tighter than the access window. A customer wants the door to let them in early; nobody wants the projector burning for a quarter of an hour either side of every booking.

The bay page

/bay/<token> is what the QR code on each bay points at, keyed by the resource's own access token. The customer types their six digits, the booking checks itself in — which opens its tab on the till — and the bay switches on. The page reads the live session from the database rather than trusting the redirect, so a lost response cannot leave an unattended customer looking at a blank form.

It says nothing about any other booking, and it is noindex. Treat the token as a secret in the sense that it should not be published on the venue's website — but it is printed on a QR code screwed to a bay, so it is not a credential either. It identifies a bay; it does not authorise anything without a valid code.

Doors and bay power

A venue registers devices against its location. Each is either a door lock or a bay power switch, and a bay power device must name the bay it switches. Device records hold identifiers and channel numbers only — API tokens and cloud keys live in the environment, never in the database.

ProviderKindStatus
manualDoorNo API. The venue's fixed keypad PIN is put into the confirmation email alongside the booking code. Always works, and is the fallback for everything else.
openpathDoorAvigilon Alta. Creates a user with a PIN credential for the code's window and deletes it to revoke. Written from Openpath's public API documentation and not yet proven against a live controller — commission it with manual configured as the fallback throughout.
shellyBay powerA smart plug or relay per bay, switched through Shelly's cloud API. Brand-agnostic: anything on a mains switch works, including a projector set to power on when mains is restored.
nuki, igloohome, kisi, trackmanDoorThe interface accepts them; no driver is written. Do not select one expecting it to work.

Bay power is re-asserted on a schedule rather than fired once, so a plug that missed its instruction — or that somebody switched by hand — is corrected within a couple of minutes instead of staying wrong for the whole session.

Unattended venues

Marking a venue unattended changes what happens when nobody is at the desk: bookings check themselves in when their code is used, bays switch by the clock, and a confirmed booking whose code was never used is marked a no-show thirty minutes after its start, charging the saved card if the venue has a fee.

Where the door has no API, the code used on the bay page is the only check-in signal. A customer who walks in behind somebody else and never scans the QR is a no-show as far as the system is concerned. That is the main argument for getting a real door driver commissioned at an unattended venue.

5. Bay Lock

The lock screen on each simulator PC: what it is, what a venue needs, and where the real documentation lives.

Cheeky Bay Lock covers every display on a bay's Windows PC until a booking code is used, and follows that bay's bookings. It sits over the simulator software rather than integrating with it, which is what makes it brand-agnostic: it needs no partnership, no API and no subscription with the simulator vendor.

The installation steps, the developer notes, the watchdog and the simulator reset are documented in apps/bay-lock/README.md, and the full test sheet in apps/bay-lock/TESTING.md. Those are the source of truth and are not repeated here — this section covers only how Bay Lock relates to the rest of a venue's setup.

What a venue needs on each simulator PC

  • A Windows PC the venue can install software on, with the simulator software already working.
  • Network access to this platform. Bay Lock polls; it needs no inbound port, no static IP and no firewall change.
  • The installer, run once. It starts at logon and a check every two minutes puts it back if it stops.
  • A printed QR code on the projector or the bay, so a golfer can unlock from their phone as well as from a keypad.

Pairing

Pairing happens from the till, not from a config file: Access → Bay screens → Pair a screen next to the bay produces a six-digit code, which is typed on the bay's screen. Within about ten seconds the screen reports itself locked and the till shows it online. Moving a screen to another bay or another PC means unpairing on the Access page and pairing again.

Screens authenticate with their own identity and secret, which is why the Bay Lock endpoints sit outside the site password entirely — a simulator PC has no site password and never will.

Getting in when things go wrong

  • Staff — hold the button, enter a manager PIN, then unlock for an hour or lock it now.
  • The venue code — an eight-digit code from Till → Venue that works even when the till is unreachable, and which can also close the screen for maintenance.
  • The Board — a manager can lock or unlock any bay, and restart a screen that has gone odd.

Closing Bay Lock deliberately tells the watchdog to leave that PC alone for half an hour, so a bay can be worked on without fighting it. Starting it again by hand ends that early.

6. The till and the Board

What staff actually touch, day to day.

The till is a web application on a tablet browser. There is nothing to install, and a phone works for the occasional job. Staff identify themselves with a PIN on the shared device rather than logging in and out of accounts.

The Board

The screen a venue lives on. One card per bay and table for the trading day, refreshing itself every twenty seconds. From it staff check a booking in — which opens its tab — take a walk-in as a priced booking, extend a session by 30 or 60 minutes at the rule price, mark a no-show, and lock or unlock a bay's screen.

Opening a tab gives the bill: the menu as quick-add buttons with modifiers, void, one discount, a note, move or rename, split by item, and the tenders — cash, the venue's own card machine, a payment link shown as a QR, or Tap to Pay on the phone.

The other tabs

TabWhat it is for
Point of saleA counter sale that is not against a bay — the bar, the shop. The sale screen is the same tab screen.
BookingsThe day's list, with cancel-and-refund and re-sending a confirmation email.
VenueAddress, trading day, booking policy, unattended settings, the venue code.
BaysResources: names, capacity, what is bookable.
PricingThe rules, with a day preview that runs the same engine the booking page runs.
Packages / Menu / TicketsWhat can be sold, its VAT treatment and its modifiers; bar tickets for what has been made.
MembershipsPlans, and the members on them.
AccessDoors, bay power switches, bay screens, and a test button per device.
StaffPeople and their PINs.
Reports / Day closeTakings by payment method, bay utilisation, VAT by rate; the drawer count and the Z-report.

The trading day

A trading day runs to a 05:00 cutoff, so a Friday night that ends at 01:30 belongs to Friday. Every tender carries its trading day. A manager closes the day by counting the drawer against float plus cash and declaring the card machine's batch; a day nobody closes closes itself at 05:00 with no count, and an owner can reopen a closed day with a corrected summary.

A summary lands by email each morning to the venue's chosen recipients, or to every owner and manager if none are set.

7. The URL contract

The public routes, treated as a committed API. This is what a venue's own website integrates against.

There is no booking API, and there is not going to be one. No availability endpoint, no create-booking endpoint, no API keys. The integration surface is the hosted booking flow below, and it is deliberately the only one: a second implementation would mean every feature — memberships, packages, access codes, no-show handling — shipping twice and diverging the first time one of them was in a hurry.

A URL is an API. The routes below are public, are outside the site password, and are treated as a contract: a venue's website links into them, and that has to be safe to rely on.

Stability levels

LevelPromise
CommittedWill not change without a redirect from the old form and a note in this document. Build on these.
Best effortWorks today and is unlikely to change, but no redirect is promised. Fine to use; do not make it load-bearing.
InternalPresent in the URL, not for external use. Do not construct it, do not depend on it, do not parse it.

The routes

PathWhat it isLevel
/book/<venue>Pick a bay, a day, a length and a slot, then payCommitted
/book/<venue>/confirmedWhere Stripe returns a paying customer; shows the access codeCommitted
/book/<venue>/booking/<token>Manage or cancel a booking — the link in the emailCommitted
/book/<venue>/membershipThe venue's plans, and joining oneCommitted
/book/<venue>/membership/joinedWhere Stripe returns a new memberCommitted
/book/<venue>/accountAsk for a member link by emailCommitted
/book/<venue>/account/<token>The member's own pageCommitted
/bay/<token>The in-bay page; the QR targetCommitted

Parameters on /book/<venue>

ParameterFormMeaningLevel
dateYYYY-MM-DDThe day to show. Ignored unless it is between today and 21 days ahead, in the venue's time zone.Committed
minsintegerDuration. Ignored unless the day's rules actually offer it.Committed
party1–200Party size. Filters out bays too small for it. Defaults to 2.Committed
whatgymSelects the shared-space option at a venue that has one. Absent means a bay.Committed
memberemailShows that membership's allowance and member prices. The server re-checks it; it grants nothing on its own.Committed
pkguuidPreselects a package, which fixes the duration and the price.Best effort
startintegerMinutes from local midnight — 19:30 is 1170. Moves the page to the details step.Best effort
bayuuidPreferred resource, honoured if it is free for that slot.Best effort
errortextA message to redisplay after a failed submission.Internal
cancelled1Set by Stripe's cancel URL when a customer backs out of Checkout. The page says nothing was charged and that their own unpaid hold keeps the slot blocked for a few minutes. The rest of the customer's choice comes back with it, so they land where they left off.Internal

pkg, start and bay are best effort for the same reason: they carry an identifier or an encoding that a venue would have to have obtained from somewhere, and there is no published way to obtain one. Linking to a specific package from a venue's own page works — copy the id from the till — but it will break if the package is recreated.

Parameters on the rest

RouteParameterMeaningLevel
/confirmedbookingThe booking's id. Required; the page 404s without it.Internal
/confirmedsessionStripe's Checkout session id.Internal
/booking/<token>new, cancelled, already, errorWhich message to show.Internal
/accountsent, known, emailWhich message to show, and the email to prefill.Internal
/membership, /account/<token>errorA message to redisplay.Internal
/membership/joinedsessionStripe's Checkout session id. Required.Internal
/bay/<token>ok, until, errorThe result of a code submission. The page reads the live state from the database regardless.Internal

Linking in, from a venue's own website

These are the patterns that are safe to hard-code. Replace bunkr-club with the venue's own slug.

<!-- Book now -->
<a href="https://cheekypayments.com/book/bunkr-club">Book a bay</a>

<!-- A specific day, an hour, for four -->
<a href="https://cheekypayments.com/book/bunkr-club?date=2026-10-03&mins=60&party=4">
  Friday night, an hour for four
</a>

<!-- The gym rather than a bay -->
<a href="https://cheekypayments.com/book/bunkr-club?what=gym">Book the gym</a>

<!-- Memberships -->
<a href="https://cheekypayments.com/book/bunkr-club/membership">Join</a>
<a href="https://cheekypayments.com/book/bunkr-club/account">Members: your account</a>

Rules for anything built against this

  • Link, do not scrape. The HTML of these pages is not part of the contract — not the markup, not the class names, not the order of the slots, not the wording. It changes without notice.
  • Never proxy a booking through the venue's own server. The booking action is rate-limited per connection. Proxy it and every customer shares one bucket, so the venue's own site starts refusing its own customers at busy times. Send the browser to the URL.
  • Do not POST to anything here. The forms are server actions, not endpoints; they are not addressable, their payloads are not documented, and they will not answer an external caller.
  • Never construct or display a price from parameters. Prices come from the venue's rules and are recomputed server-side at submission.
  • Do not cache availability. It is live and contended; two customers can want the same slot in the same second, and the exclusion constraint in the database is what settles it.
  • Treat the tokens as secrets. booking/<token>, account/<token> and bay/<token> are bearer URLs. Keep them out of analytics, out of query strings you log, and out of anything that leaks a referrer.
  • Expect a 404, not a message, when a venue has bookings switched off or a slug is wrong. Handle it as a dead link on the venue's side.

Search engines

Every page in the booking flow is noindex, nofollow, on purpose: they are a venue's customers doing a transaction, not content. The venue's own page should be the one that ranks, and it should link here. Do not expect a booking page to appear in search results, and do not build a venue's SEO around one.

Reading this document from an agent

Every page on this site has a markdown twin at the same path with .md appended, and this page is no more exception than any other. A venue's coding agent can fetch the whole contract in one request:

curl https://cheekypayments.com/venues/integrate.md

That is the copy to work from. It is generated from the same source as this page, so the two cannot disagree, and there is no file to email around and no snapshot to go stale.

8. Your own look, and the embed

Making the booking pages look like the venue, and putting live availability on the venue's own website.

Everything here is set by the venue, in the till, under Venue → How your booking pages look. None of it needs anybody at this end.

One colour

A venue gives a single hex colour. It becomes the links, the borders and the focus ring, and a pale tint of it becomes the panels and buttons. One colour rather than a palette is a deliberate limit: the two brand slots want opposite things — one sits *on* the page background and one sits *under* black text — so deriving the second from the first is the only way a venue cannot make its own checkout unreadable.

The colour has to reach 4.5:1 against the page background, because it becomes link text. This is stricter than it sounds and catches colours that look strong on screen: a mid green such as #1a8a3a is 4.0:1 on cream and is refused. The answer is to pick a darker shade of the same colour — the pale version used for panels is derived from it anyway, so nothing is lost. The form shows the actual ratio when it refuses.

Headings

Four choices — Bold, Clean, Classic, Technical — and no free-text font field. Every face offered is already self-hosted and preloaded by the site, so a venue's choice costs nothing to load. A free-text field would mean fetching a font from a third party on the one page where a slow first paint costs a booking, which is a trade no venue would knowingly make.

A logo

The https address of a logo the venue already hosts on its own website — right-click it there and copy the image address. It appears above the venue name on every booking page. It is height-capped, so any shape works and nothing gets pushed off the screen. http addresses are refused, because a browser blocks an insecure image on a secure page and storing one would only guarantee a blank space.

The embed

Two lines on the venue's own page put its live availability inline:

<div data-cheeky-venue="bunkr-club"></div>
<script src="https://cheekypayments.com/embed.js" async></script>

The widget shows a day strip and the real start times and prices for the selected day, in the venue's colour and face. It resizes itself, so the host page needs no height and no CSS. Two optional attributes set where it opens: data-cheeky-date="2026-10-03" and data-cheeky-mins="90".

What the embed deliberately does not do

It stops at the slot. Clicking a time leaves the frame and continues on /book/<venue> with the parameters from section 7 — so the widget is a client of the URL contract, not a second implementation of it.

That is a constraint, not a preference. Stripe's hosted Checkout refuses to render inside a frame, so an embed that carried the whole flow would fail at the pay button — the worst possible place to fail. Handing over at the slot means the customer moves to the full page knowingly, by clicking a time, with all their context intact. It also means the booking flow has exactly one implementation, which is the same reason there is no booking API.

Who is allowed to embed

Embedding is opt-in per venue. The venue lists the websites allowed to frame its availability, one address per line, in the same settings block. Until it lists one, the widget refuses to be framed and says so in words rather than leaving a developer looking at a blank rectangle.

Everything else refuses framing outright. The booking pages, the bay page, a member's account page and the till all send frame-ancestors 'none', so none of them can be put inside a frame on any site. The availability widget is the single exception, and only for the origins its venue named.

SymptomCause
The frame is blank or the browser console says the page refused to connectThe venue has not added this site's address to the allowed list, or added it with a different subdomain. www.example.co.uk and example.co.uk are different origins.
The widget says the venue is not taking bookingsTake bookings online is off, or the slug is wrong.
Times appear on the booking page but not in the widgetThe widget shows bays only, at one length. Use the “All times and lengths” link for packages, the gym and other durations.

9. Stripe, webhooks and environment

What has to be in place on the platform before a venue's bookings work end to end.

This section is for whoever runs the platform, not for a venue. A venue needs none of it — it is listed so that when something does not work, the gap is findable.

Three Stripe keys, deliberately separate

All three are restricted keys, never a plain secret key, and they are not interchangeable. The separation is the point: the onboarding key cannot move money, and the bookings key cannot create connected accounts.

KeyForScope
STRIPE_SECRET_KEYOnboardingWrite: connected accounts, account links, tokens, external accounts. Read: balance, charges, payouts, disputes, customers, files, reporting. It cannot refund, pay out or move a balance.
STRIPE_BOOKINGS_KEYBookings and memberships on venues' accountsWrite: Checkout sessions, payment intents, customers, refunds, transfers, products, prices, subscriptions, customer portal. Read: charges. It must not have connected-accounts write.
STRIPE_BILLING_KEYPlan subscriptions on the platformWrite: Checkout sessions, customers, subscriptions, customer portal. Read: products, prices, invoices, promotion codes.

Three webhook endpoints

Two of these point at the same URL and are still both needed, which is the part that catches people. Stripe delivers events about a connected account only to a Connect endpoint, and events on the platform's own account only to a platform endpoint. A payment taken on the platform will never arrive at the Connect endpoint no matter how long anyone waits.

EndpointTypeEventsSecret
/api/webhooks/stripeConnectaccount.updated, account.external_account.created, capability.updatedSTRIPE_WEBHOOK_SECRET
/api/webhooks/stripe/bookingsConnectcheckout.session.completed, checkout.session.expired, charge.refunded, payment_intent.succeeded, plus customer.subscription.created/updated/deleted and invoice.paid for membershipsSTRIPE_BOOKINGS_WEBHOOK_SECRET
/api/webhooks/stripe/bookingsPlatformpayment_intent.succeeded, charge.refundedSTRIPE_TERMINAL_WEBHOOK_SECRET
Without the bookings Connect endpoint, a paid booking stays held and the confirmation page refreshes forever. That is the first thing to check when payments appear in Stripe but not in the till.

The scheduler

/api/cron/bookings must be called every two minutes with Authorization: Bearer $CRON_SECRET. It sweeps unpaid holds, sends day-before reminders, frees finished bays, switches bay power, checks unattended bookings in, expires codes, marks and charges no-shows, closes any trading day nobody closed, and sends the morning summary. It is idempotent, so two schedulers overlapping is harmless — but a schedule slower than two minutes makes bay power visibly late.

Environment

VariableWithout it
NEXT_PUBLIC_SITE_URLEvery absolute URL the site publishes is wrong.
SUPABASE_SERVICE_ROLE_KEYBookings are switched off entirely.
STRIPE_BOOKINGS_KEYAvailability shows but no card can be taken.
MEMBER_LINK_SECRETMember account links cannot be signed. Rotating it invalidates every link already sent.
CRON_SECRETThe scheduler is refused, so nothing above happens.
SHELLY_SERVER, SHELLY_AUTH_KEYBay power is logged and nothing is switched.
OPENPATH_API_TOKENDoor codes are not written to the controller; the manual instruction is the fallback.
RESEND_API_KEY, EMAIL_FROMEvery email is a line in the function log instead of a message. Confirmations carry the access code, so this is not optional in production.
SITE_PASSWORDSet, the whole site is behind HTTP basic auth — except the booking flow, the bay page, the legal pages and the markdown twins, which are always open.

Deploying a change that carries a migration

  1. Merge.
  2. Pull, apply the migration, and list the applied migrations to confirm local and remote match.
  3. Only then let the deploy run.

A migration is additive and safe to apply before the code that uses it, never after. Deploying first has already produced eighty minutes of production running against a database without the functions it was calling.