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
- Sign up at
/sign-upwith an email and password. This creates the login, nothing else. - Onboarding at
/dashboard/onboarding. This creates the Stripe connected account and collects what Stripe needs on it. - 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.
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
| Setting | What 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 zone | Every pricing rule and the trading day are read against this clock, so a rule that says 17:00 survives the clocks changing. |
| Take bookings online | The master switch. Off, /book/<slug> returns a 404 — not a "closed" page. Leave it off while setting up. |
| Payment at booking | Whole 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 fee | Charged to the saved card when a booking is marked as a no-show. £0 for none. |
| Free cancellation until | Hours before the start. Inside the window a customer cancelling online is refunded automatically. |
| Hold minutes | How 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
| Field | Meaning |
|---|---|
| Days | A weekday mask, ISO order: bit 0 is Monday through bit 6 Sunday. 31 is weekdays, 96 is the weekend. |
| From / to | Minutes from the venue's local midnight. The end may exceed 1440 for a venue that trades past midnight — 02:00 is 1560. |
| Slot length | 15, 30 or 60 minutes. This is the grid the booking page draws. |
| Price | Per slot, not per hour. A 30-minute slot at £15 is £30 an hour. |
| Member price | Optional. What a member pays per slot once their included allowance is spent. |
| Minimum / maximum | Both must be whole multiples of the slot length. The maximum may be left open. |
| Priority | Where rules overlap, the highest priority wins, then the most recently created. |
| Valid from / to | Optional 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
| Field | Meaning |
|---|---|
| Price and interval | Monthly or yearly. |
| Included minutes | Bay minutes granted each period, drawn down before any card is charged. Zero for a plan that is only about access and member rates. |
| Member pricing | Whether the rules' member price applies once the allowance is spent. On by default. |
| Access hours | always, 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 ahead | How 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
| Setting | Default | Effect |
|---|---|---|
| Access lead minutes | 15 | How long before the booking starts the code begins working. |
| Access grace minutes | 10 | How long after it ends the code keeps working. |
| Bay power lead minutes | 2 | How long before the start the bay switches on. |
| Bay power grace minutes | 5 | How 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.
| Provider | Kind | Status |
|---|---|---|
manual | Door | No 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. |
openpath | Door | Avigilon 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. |
shelly | Bay power | A 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, trackman | Door | The 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.
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.
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
| Tab | What it is for |
|---|---|
| Point of sale | A counter sale that is not against a bay — the bar, the shop. The sale screen is the same tab screen. |
| Bookings | The day's list, with cancel-and-refund and re-sending a confirmation email. |
| Venue | Address, trading day, booking policy, unattended settings, the venue code. |
| Bays | Resources: names, capacity, what is bookable. |
| Pricing | The rules, with a day preview that runs the same engine the booking page runs. |
| Packages / Menu / Tickets | What can be sold, its VAT treatment and its modifiers; bar tickets for what has been made. |
| Memberships | Plans, and the members on them. |
| Access | Doors, bay power switches, bay screens, and a test button per device. |
| Staff | People and their PINs. |
| Reports / Day close | Takings 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.
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
| Level | Promise |
|---|---|
| Committed | Will not change without a redirect from the old form and a note in this document. Build on these. |
| Best effort | Works today and is unlikely to change, but no redirect is promised. Fine to use; do not make it load-bearing. |
| Internal | Present in the URL, not for external use. Do not construct it, do not depend on it, do not parse it. |
The routes
| Path | What it is | Level |
|---|---|---|
/book/<venue> | Pick a bay, a day, a length and a slot, then pay | Committed |
/book/<venue>/confirmed | Where Stripe returns a paying customer; shows the access code | Committed |
/book/<venue>/booking/<token> | Manage or cancel a booking — the link in the email | Committed |
/book/<venue>/membership | The venue's plans, and joining one | Committed |
/book/<venue>/membership/joined | Where Stripe returns a new member | Committed |
/book/<venue>/account | Ask for a member link by email | Committed |
/book/<venue>/account/<token> | The member's own page | Committed |
/bay/<token> | The in-bay page; the QR target | Committed |
Parameters on /book/<venue>
| Parameter | Form | Meaning | Level |
|---|---|---|---|
date | YYYY-MM-DD | The day to show. Ignored unless it is between today and 21 days ahead, in the venue's time zone. | Committed |
mins | integer | Duration. Ignored unless the day's rules actually offer it. | Committed |
party | 1–200 | Party size. Filters out bays too small for it. Defaults to 2. | Committed |
what | gym | Selects the shared-space option at a venue that has one. Absent means a bay. | Committed |
member | Shows that membership's allowance and member prices. The server re-checks it; it grants nothing on its own. | Committed | |
pkg | uuid | Preselects a package, which fixes the duration and the price. | Best effort |
start | integer | Minutes from local midnight — 19:30 is 1170. Moves the page to the details step. | Best effort |
bay | uuid | Preferred resource, honoured if it is free for that slot. | Best effort |
error | text | A message to redisplay after a failed submission. | Internal |
cancelled | 1 | Set 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
| Route | Parameter | Meaning | Level |
|---|---|---|---|
/confirmed | booking | The booking's id. Required; the page 404s without it. | Internal |
/confirmed | session | Stripe's Checkout session id. | Internal |
/booking/<token> | new, cancelled, already, error | Which message to show. | Internal |
/account | sent, known, email | Which message to show, and the email to prefill. | Internal |
/membership, /account/<token> | error | A message to redisplay. | Internal |
/membership/joined | session | Stripe's Checkout session id. Required. | Internal |
/bay/<token> | ok, until, error | The 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>andbay/<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.mdThat 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.
#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.
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.
| Symptom | Cause |
|---|---|
| The frame is blank or the browser console says the page refused to connect | The 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 bookings | Take bookings online is off, or the slug is wrong. |
| Times appear on the booking page but not in the widget | The 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.
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.
| Key | For | Scope |
|---|---|---|
STRIPE_SECRET_KEY | Onboarding | Write: 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_KEY | Bookings and memberships on venues' accounts | Write: Checkout sessions, payment intents, customers, refunds, transfers, products, prices, subscriptions, customer portal. Read: charges. It must not have connected-accounts write. |
STRIPE_BILLING_KEY | Plan subscriptions on the platform | Write: 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.
| Endpoint | Type | Events | Secret |
|---|---|---|---|
/api/webhooks/stripe | Connect | account.updated, account.external_account.created, capability.updated | STRIPE_WEBHOOK_SECRET |
/api/webhooks/stripe/bookings | Connect | checkout.session.completed, checkout.session.expired, charge.refunded, payment_intent.succeeded, plus customer.subscription.created/updated/deleted and invoice.paid for memberships | STRIPE_BOOKINGS_WEBHOOK_SECRET |
/api/webhooks/stripe/bookings | Platform | payment_intent.succeeded, charge.refunded | STRIPE_TERMINAL_WEBHOOK_SECRET |
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
| Variable | Without it |
|---|---|
NEXT_PUBLIC_SITE_URL | Every absolute URL the site publishes is wrong. |
SUPABASE_SERVICE_ROLE_KEY | Bookings are switched off entirely. |
STRIPE_BOOKINGS_KEY | Availability shows but no card can be taken. |
MEMBER_LINK_SECRET | Member account links cannot be signed. Rotating it invalidates every link already sent. |
CRON_SECRET | The scheduler is refused, so nothing above happens. |
SHELLY_SERVER, SHELLY_AUTH_KEY | Bay power is logged and nothing is switched. |
OPENPATH_API_TOKEN | Door codes are not written to the controller; the manual instruction is the fallback. |
RESEND_API_KEY, EMAIL_FROM | Every 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_PASSWORD | Set, 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
- Merge.
- Pull, apply the migration, and list the applied migrations to confirm local and remote match.
- 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.