Engineering at Bookatu

How we build Bookatu

Honest notes from the team behind a 0%-commission booking platform: payments, multi-tenancy, internationalization, the booking engine and the calendar. Real decisions and the trade-offs behind them.

Engineering

Widget events, written as if they were published

Our booking widget now tells the website it sits on what happens inside it, so owners can count bookings in their own analytics. That website is someone else's, and so is every tag on it. Here is the event contract we wrote on that assumption, and three bugs review found before it shipped.

7 min read
Engineering

A nonce per request, and a cache that lived twice

In release 12 every page moved from a host allow-list to a nonce with strict-dynamic, and it shipped enforced because a test proves every page gets the nonce. In release 13 the widget's pages began naming the only sites allowed to frame them, for a business that sets a list. Review found that saving the list took up to a minute to apply, because the proxy had its own copy of the cache.

7 min read
Engineering

The card step was a page, not a rule

A business can ask for a card to hold a booking. Until release 13 the only thing that asked was the card step on the booking page, so the widget, the API and waitlist claims booked straight past it. Here is how the rule moved to where bookings are written, what counts as proof a card was saved, and what review found when booking terms and questions came after it.

7 min read
Engineering

Weekly blocks that keep to the wall clock

Blocked time can now repeat every week, for a person, everyone or a room. We store one rule instead of a row per day, and work out only the days a reader asks about, on the business's own clock. Here is how that holds across daylight saving and a change of time zone, and five review findings, one of which made a 14-day read work out 314 zone days instead of 14.

7 min read
Engineering

One pricing function, and the inputs that disagreed

Public holiday and busy-time surcharges are priced by one pure function, shared by every door that charges them. Review still found doors that showed one price and charged another, because each handed that function different inputs: another branch, another start, another person. Here is the function, the refusal that caught some of it, and the door it could not protect.

7 min read
Engineering

Test mode inside production, without one real email

Release 15 adds sandbox businesses: test businesses that sit in the production database beside real ones. They must never email a real person or charge a real card, and no live business may ever be deleted as one. Here is how two marks, a few CHECKs and triggers hold that line, how the expiry sweep is kept away from live businesses, and three design critic findings that changed the plan.

7 min read
Engineering

One gate for every send and every charge

A sandbox runs the same booking code as a live business, so every email, text and Stripe call had to be caught somewhere. We funnelled messages through one send gate and money through one Stripe gateway, then wrote static tests that fail when a new path appears. Here is how each choke point works, and the way round them that review still found.

7 min read
Engineering

API versions named by date, and limits on every answer

Release 15 gives the Bookatu API dated versions and rate-limit headers. Every key is pinned to a version, a call with no key and no header gets the oldest version still served, and a deprecated version will say so on every answer. Here is the order a version is chosen in, the headers, and why a call the route refuses is now taken back off the key's count.

7 min read
Engineering

A hidden menu item is not a gate

Almost every button in the Bookatu admin runs a server action, and each business decides on its own role grid who may do what. We audited every action against that grid. Of 40 candidate gaps, 34 held up and 6 were by design. Here is what the real ones had in common, the public side where the question is ownership, and how the fixes were tested on the old code first.

10 min read
Engineering

Browser tests that read like the journey

Our newest browser tests are written as the journey a person takes, in Given, When and Then steps that use their words. Quick swap needs two waiting clients reaching for one time, so every journey takes a seeded set of its own and can run again. Here is how they wait for hydration, ignore React's hidden streaming copy, pass three times in a row with no retries, and the four app bugs they found.

10 min read
Engineering

A private channel nobody else can join

The who's-online bar in our admin had not connected for three months, and nothing on screen said so. Allowing the socket in our CSP would have taken five minutes and switched back on a channel anyone could read and spoof. Here is what we built instead: a short-lived token, a private channel, and two row-level security rules, plus two lessons from putting them into production.

10 min read
Engineering

One rule, every door

A client can now book a colour and a cut as one visit, and a braiding salon can say that a 4 braids and a 6 braids are never booked together. Bookatu takes bookings through five different doors. This is how those rules live in one place that every door has to pass through, and how we taught an engine built around a grid to find starts that are not on it.

10 min read
Engineering

Pin today, fail first

Most of what we ship changes code that already works for businesses taking bookings every day. Before a feature touches that code, we write down what it does today as tests that must pass on the old commit, then tests for the new behaviour that must fail on it. A small script checks both against a copy of the base. Review findings get the same treatment: no fix until a test reproduces the finding.

10 min read
Engineering

GST plus QST: tax lines that add up to the cent

One tax rate per business was enough until Canada. Quebec charges GST and QST, British Columbia charges GST and PST, and some services carry only one of them. Here is how our tax engine works each tax out on its own, rounds so the cents always add up, freezes the result on the sale, and hands the same figures to the tax report, the CSV exports and QuickBooks.

10 min read
Engineering

An append-only tip ledger

A tip at checkout can belong to three people, and the split is sometimes wrong. We record each share as a row the database will not let anyone edit, split tips with largest-remainder arithmetic so the cents always add up, and change a split by writing an adjustment that moves money and sums to zero. Here is how it works, and how commission, payroll and QuickBooks read it.

10 min read
Engineering

Handing over a slot that is still booked

Quick swap lets a client who would pay a late-cancel fee hand their time to someone on the waitlist instead. The time is never free, not even for a moment. Here is the one transaction that releases one booking and writes the other, how two claimers racing give one winner, and how a cancellation and a claim at the same moment give one outcome.

10 min read
Engineering

Passkeys without telling anyone who has an account

Business logins can now sign in with a passkey. The WebAuthn library does the cryptography, and most of our work went into everything around it: where the relying party comes from, challenges that live on the server and are used once, a sign-in prompt that never asks who you are, sign counters, and a fresh check before a session can add a passkey of its own.

8 min read
Engineering

An idle timer you cannot reset by deleting a cookie

A business can now sign its team out after 15 minutes to 2 hours without activity. Our sessions are stateless, so the server has no record of when anyone last did anything. Here is how a signed, per-business activity cookie answers that question without being something you can delete for a fresh window, and the two review findings that changed how it ships.

8 min read
Engineering

Parsing what someone else's AI wrote

The blog editor can now hand a business owner a prompt for ChatGPT, Claude, Gemini or any other AI, and read back whatever it replies. We do not control that model or its output. Here is how we ask for JSON, read it tolerantly, strip HTML until nothing changes, stay fast on hostile input, and the two review findings that changed the panel.

8 min read
Engineering

Six tables, one booking a night

Our availability engine grew up in a salon, where a busy stylist means no appointment however many chairs are free. A restaurant is the opposite shape, and one line that had been in the file since its first version meant a dining room with six tables went dark the moment the first party sat down. The fix was small. The two layers underneath it were not.

10 min read
Engineering

The optional parameter that was four hours wrong

We booked the noon slot at a test shop in Perth and the confirmation email said 4:00pm. Nothing threw, nothing logged, and the email was well formatted and wrong. The cause was a question mark in a function signature, and the part worth reading is the shape of the fix rather than the bug.

10 min read
Engineering

Green for the wrong reason

We put a bug back into the code to find out which of the tests written to catch it would go red. Three different ways of breaking it, and the suite stayed green through all three. This is what was wrong with those tests, and the cheap loop that came out of it, which then turned up a rule nothing in our suite has ever checked.

10 min read
Engineering

The link in the email was never going to work

A feature request sent us to read our own invoicing code, and the Pay now button in every invoice we had ever emailed pointed at an address that does not exist. Here is the bug, the worse one hiding behind it, and why the fix was a route rather than a string.

7 min read
Engineering

Propose then confirm: a safe pattern for AI that writes to your database

Owners can run parts of Bookatu by chat, and every one of those requests is a write to a live database that other businesses share. We never gave the model write access. It proposes a structured change, we validate it like any form post, and a person confirms before anything is saved.

7 min read
Engineering

Undo for AI actions: recording every change with its reverse

A confirm dialog stops most mistakes with an AI assistant, not all of them. So next to the changes ours makes there is an Undo, and it is not a history feature bolted on afterwards. Each reversible change records its own reverse at the moment it happens, so it can be taken back in one tap.

7 min read
Engineering

The allowlist is the boundary: keeping an AI assistant on rails

The most useful question to ask about an AI assistant is not what it can do, it is what it cannot. Ours can call a fixed, hand-picked set of tools and nothing else. Here is why that list is an allowlist rather than a denylist, why it lives in code rather than in a prompt, and what we keep off it on principle.

7 min read
Engineering

React 19 quietly resets your form, and when that bites

Our intake form posts through a server action. During testing, one rejected field came back to a page where everything the client had typed was gone, including a signature they had drawn with a finger. React 19 resets forms after an action on purpose, and the reasoning is sound. Here is why the reset exists, where it hurts, and the small submit pattern that keeps typed input on the page.

8 min read
Engineering

One app, two theme planes: where color-scheme leaks

Our platform shell has its own light and dark theme, and every storefront has an independent one. The custom property tokens were scoped correctly, yet native checkboxes still flipped with the wrong theme. The culprit was color-scheme, a property that inherits from the root like any other, and it bit us a second time across an iframe boundary.

7 min read
Engineering

The signature pad that would not draw

Our consent forms end with a client drawing their signature on a canvas. In testing it accepted every touch and produced no ink. The cause was a canvas that measured itself while hidden inside a later step of a multi-step form. Here is what a zero-sized canvas actually does, how to size one properly for phone screens, and the re-setup pattern that keeps the ink and never posts a stale signature.

7 min read
Frontend

Packing a day: laying out overlapping bookings without a calendar library

Our day-view preview drew every booking at full width, so overlapping appointments rendered as text over text. The fix was the classic interval packing pass: assign each booking the lowest column whose previous booking has ended, then size each overlap cluster by its own peak concurrency. Extracting it as a pure function is what finally made it testable.

7 min read
Engineering

Keeping AI free: a fair-use quota that never blocks the work

We wanted to use a language model across the product to make imports more accurate, without putting AI behind an upgrade and without a surprise bill. The trick was to treat AI as a boost on top of a result that is already correct, meter it per tenant with no new tables, and let it fall back the instant an allowance runs out.

7 min read
Engineering

Storefront themes that can't break a layout, and the CSS layer that fought us

Letting every business pick a look for their shop page sounds like a support nightmare waiting to happen. Here's how we made themes that restyle a storefront but are structurally incapable of breaking it, and the @layer gotcha that ate an afternoon.

6 min read
Engineering

The focus bug hiding in a disabled input

A search box that kicked the cursor out after every couple of keystrokes. The cause was one prop doing exactly what it was told, and a browser rule most of us half-remember. A short story about a sharp little bug.

4 min read
Engineering

Idempotent webhooks: making sure the money only moves once

Payment providers promise to deliver a webhook at least once, which is a polite way of saying sometimes twice. If a repeat delivery confirmed the booking a second time, awarded loyalty twice and emailed two receipts, our customers would notice fast. Here is the small idea that makes every downstream effect happen exactly once, no matter how many times the same event lands.

6 min read
Engineering

Settings as a directory, not a wall of tabs

A booking business has a lot to configure, and for a while our settings screen showed all of it at once behind a row of tabs. It worked, but it read as a wall. We rebuilt it as a plain directory of rows that opens one focused section at a time. Same fields, same save actions, a fraction of the cognitive load.

5 min read
Engineering

Generous storage that still makes money: a hard cap, real-byte metering, and pack overage

A photographer's plan now includes up to a terabyte of gallery storage. Here is how we made the included space generous without turning it into a loss: enforce the cap when photos go in, trust the size the store reports rather than the one the browser claims, and sell the overflow as simple fixed packs.

6 min read
Engineering

Reading the visitor's language: Accept-Language, q-values, and refining by country

Our marketing site serves regional markets like en-NZ and de-DE, and the bare URL has to redirect each visitor to the right one. We used to do that by IP country alone, which sent a German tourist in New York to an English page. Now we read the browser's own language preference first, refine it by where they are, and we moved the language dropdown to the footer because the page usually guesses right.

5 min read
Engineering

Protecting photographers' images on the web: what actually helps

Right-click blockers and CSS overlays are theatre. Here is the pipeline we use in the Bookatu photographer galleries: baked-in pixel watermarks via canvas, a hard downscale to 1080px, EXIF stripping, and serving the full-resolution file only through an ownership-checked route.

6 min read
Engineering

The refund we never meant to give: closing a free-download loophole in gallery proofing

A photo client could trade a delivered photo back and forth in a way that settled to free. The fix was one identifier, and a clear lesson about modelling state by its lifetime.

5 min read
Engineering

Magic links done right: why a login token is not a share link

Two things in Bookatu look identical from a browser address bar: the passwordless login link and the gallery share link. They must never be the same token. Here is how we sign, scope, and expire them, with a sign and verify sketch.

7 min read
Engineering

One dashboard, seven industries: a config-driven, vertical-aware admin

Bookatu runs salons, spas, trainers, coaches, studios, restaurants and photographers from one admin. Here is how a single config map, plus named constants instead of hardcoded industry strings, keeps that admin clean as the list of verticals grows.

7 min read
Payments

How 0% commission works: Stripe Connect and money that never touches us

A walk through how Bookatu takes a client deposit straight into the salon's own Stripe balance with a direct charge on a connected account, so the platform never holds the money.

7 min read
Engineering

Two planes and a gotcha: per-tenant theming with CSS custom properties

How Bookatu themes every tenant's storefront from one component tree: brand colour injected as CSS variables, dark mode built as a second value plane, and the inline-specificity bug that needs exactly one !important.

5 min read
Architecture

One codebase, thousands of branded sites: our multi-tenant model

How Bookatu serves every tenant's branded storefront and back office from one Next.js app: a slug in the path, a flag-gated host proxy, and org_id on every query.

7 min read
Engineering

A +25GB storage pack is just a quantity line: billing add-ons as Stripe subscription items

How Bookatu turns a photographer's "+25GB storage pack" into a quantity line on the org's existing platform subscription, finds or creates a versioned price, prorates the change, and keeps the in-app storage cap from drifting away from what Stripe actually bills.

6 min read
Internationalization

Localizing into 13 languages without /[locale] routes

How Bookatu ships its UI in 14 languages with no /[locale] URL segment: a pure deep-merge catalog, a visitor-locale resolver over header, cookie and geo, and SEO bolted on later behind a flag.

7 min read
Engineering

Keeping a client photo gallery private: row scoping, a private bucket, and a route that 404s on any mismatch

How Bookatu keeps each photographer's client galleries private in a multi-tenant app: org plus customer row scoping in Postgres with Drizzle, a private blob store, and a serving route that checks ownership and returns 404 for anything you do not own.

7 min read
Frontend

Building a touch-friendly booking calendar from scratch

Why we built Bookatu's drag-to-reschedule calendar on raw pointer events instead of a library, and how the touch model, swim lanes and optimistic updates actually work.

7 min read
Reliability

Stopping fake signups without punishing real owners

How Bookatu keeps junk businesses off a public booking platform with layered checks that fail open, so a DNS hiccup or a Google outage never blocks a real salon owner.

7 min read
Architecture

Recurring appointments and the 'admin force-book' rule

How Bookatu books a standing recurring series through the same pipeline as one-off bookings, why admin scheduling force-books while customer booking stays strict, and the one collision the database still refuses.

7 min read
Infrastructure

Custom domains for every tenant with Vercel for Platforms

How Bookatu lets each tenant point their own domain at their storefront: a flag-gated routing proxy, the Vercel Domains API behind a wrapper that never throws, and a TTL cache that keeps the common request free.

7 min read
Architecture

Pricing by active clients: computing who actually counts

How Bookatu bills by active clients instead of seats: a 90-day window, a deliberately plain in-memory de-dupe across two activity sources, and a clean split between lifecycle and counting.

6 min read
Reliability

Testing a booking engine with pglite and 1600+ tests

How Bookatu tests its booking engine against a real in-memory Postgres: actual migrations, partial unique indexes that catch double-bookings, and 1600+ tests that run anywhere.

7 min read
Internationalization

Per-language URLs for SEO via a proxy rewrite

Cookie-based localisation is invisible to Googlebot, so it only ever indexed our English pages. Here's how we made 14 languages crawlable with a flag-gated proxy rewrite and zero new routes.

7 min read