Skip to content
GuideClaude CodeNext.jsPostgres

Build a Zenoti Clone

A complete implementation prompt for a full spa and salon management platform: appointment book, POS, card-present payments, guest self-service booking, staff portal, two-way SMS, an AI assistant, and a migration path off Zenoti.

This spec was reverse-engineered from a spa management platform running in production, then de-identified. It's written to be handed to a coding agent as a single, self-contained prompt — or worked through by hand, part by part.

I wrote this after several years of running a spa on Zenoti and eventually moving off it. The reasoning behind that decision — and the pricing that drove it — is in the post-mortem. This page is the constructive half: what you'd actually have to build to replace it.

What it looks like when the brand is yours

The spec below is abstract, so here is the concrete version of what it buys: the package checkout that replaced our Zenoti one — live, in production, taking real money.

The replacement package checkout: a deep green header with a gold monogram and wordmark, a cream page, the package name set in a display serif, the price, and three form fields for name, email, and optional phone.
Same business, same catalog, same 365-day expiry as the platform it replaced — and none of the vendor's chrome.

Compare that to what it replaced. On Zenoti, our branding was the rectangle in the top-left corner: a logo file dropped into the vendor's black bar, its own black background not quite matching the black behind it, its edges showing as a seam. Everything else on the page belonged to Zenoti — the grey card, the square black buttons, the checkout stranded off-centre in a field of empty white. A brand reduced to an image upload.

The copy is the part that gave it away. "Select a Series Packages." "Search for a Series packages." "Add Another Series Packages." Three ungrammatical strings on our own storefront, in front of paying guests, and not one of them was ours to fix. That is the same constraint as the SMS templates in the post-mortem, turned around to face the customer: the platform decided what we said, in our voice, under our logo.

The replacement has no logo slot, because there is no vendor chrome to slot it into. The green, the cream, the gold monogram, the display serif — none of it is a default that got recoloured. But the branding is the least of it, and that's the actual argument for building this. Look at what else changed once the page was ours:

The home-address field is gone

The one I couldn't switch off for years, on a business that ships nothing. Name, email, and an optional phone number. Every field that survived earns its place, and says why it's there: email — for your receipt, phone — links the sessions to your account. Asking for less is a conversion decision, and it was never available to us as one.

The button tells you what you're about to pay

"Continue to payment — $1,250.00", not "Continue to checkout". And underneath it, an exit ramp for the guest who picked the wrong thing: prefer to book single sessions instead?A platform optimising a median customer's funnel will never write that sentence for you.

The 365-day expiry is still there — and that's the point

We kept it. It was never a wrong policy, it was someone else's policy. Now it's a number in Settings that the owner can change on a Tuesday without filing a ticket, and the page states it as a sentence rather than a table cell. That is the whole thesis of milestone two, rendered at 14 pixels.

The four rules that shape everything

Most of the spec is detail. These four decisions are the ones that determine whether the detail holds together.

Degrade, never crash

Every module has to work with no database, no SMS provider, no payment key, and no AI key. Missing configuration produces a friendly disabled state — never an exception. This is what makes the thing developable by one person.

The server owns money and time

The client is trusted for choices — which service, which slot. Never for values. Prices, durations, and totals are always recomputed server-side from the database. A client-supplied amount is never authoritative.

Idempotency over transactions

The reference runs on a serverless Postgres HTTP driver with no transaction support, so it uses idempotency keys, WHERE-guarded conditional updates, and reconciliation jobs instead of BEGIN/COMMIT. Worth designing this way even when your driver does support transactions — it makes webhook and retry handling correct by construction.

One source of truth for captured money

The payment processor's webhook, and nothing else. Devices may report progress, but a device must never be able to declare a payment successful.

The non-negotiable one: everything lives in Settings

Every configurable value has to be editable by the business owner in-app. Nothing operational may live only in an environment variable or a hardcoded constant — not the business name, not the tax rate, not the tip presets, not the reminder times, not the AI model.

This sounds like a nice-to-have. It isn't. It's the single difference between software the owner operates and software the owner files a ticket against — which is the entire failure mode that made the incumbent expensive in the first place. The acceptance test is blunt: a non-technical owner can change the business name, address, timezone, hours, tax, tip presets, gift-card pricing, reminder times, alert numbers, AI model, and reader branding with no redeploy and no rebuild. Build this early — milestone two, before the features that read it.

Build order

Twelve milestones. The order matters — each one assumes the last.

Foundation

Stack, conventions, the full schema, seed data, and deploy-time migrations. Establish these first — they're load-bearing for everything after.

Settings

The entire settings system, with typed accessors everything downstream reads from. Build it before the features that depend on it, not after.

Catalog & staff

Services, categories, add-ons, rooms, equipment, employees, capabilities, commissions, weekly hours, and per-date overrides.

Appointment book

Geometry, three views, booking panel, popover, move/resize/cut/paste/copy, block-out, notes, waitlist, and live sync. The hardest UI in the system.

Billing & POS

Invoices, numbering, payments across every tender, gift-card sale and redemption, splits, refunds, and PIN-gated financial reporting.

Terminal

Card-present sessions, presence tracking, a watchdog, reconciliation, diagnostics, and tablet apps that fetch their config from the server.

Communications

A dispatcher, two providers, unified inbound with routing, threading, media, templates, and reminders.

Guest surfaces

Booking wizard including couples bookings, OTP identity, card on file, account cabinet, SMS cancellation, gift cards, packages, and card-capture links.

Employee portal

Schedules, fee-aware payroll, and schedule-change requests approved over SMS.

AI assistant

Tools, confirmation gating on every write, multi-provider support, voice, and memories.

Migration

The sync subsystem — only if you're moving off an incumbent platform. This is where the subtle data-loss bugs live.

Hardening

The security model end to end, then the hardening checklist.

The migration is the dangerous part

If you're moving off an incumbent rather than starting clean, the sync subsystem is where you can actually lose money — not metaphorically. The spec covers it in full, but three rules are worth stating here because they're the ones that bite. For the export side specifically — the auth problem, every endpoint, and the bugs that produce duplicate guests and $0 invoices — see how to export your data out of Zenoti, or the same export as copy-paste prompts if you'd rather not write it.

Never let a sync downgrade a paid invoice

Rows you created are yours and reconcile must never touch them. Before every invoice upsert, guard the open-downgrade: if the incoming status is open but a succeeded local payment exists, keep it paid. Then run a heal pass at the end of every sync, so a clobber from any path never survives a single run.

Lowercase the external ID

The source returns the same GUID in different cases across different endpoints, and Postgres unique keys are case-sensitive. Normalizing case on the way in is a one-line fix that on its own prevents duplicate guests.

An empty response is a failure, not an empty catalog

Reconcile deactivates rows the source no longer returns — so only ever reconcile when the fetched set is non-empty. Treating a failed API call as "the catalog is empty" will cheerfully deactivate your entire business.

Get the full prompt

Roughly 78,000 characters covering the data model, every settings section, all six surfaces, the server logic, the migration subsystem, the security model, and the acceptance criteria. Hand it to a coding agent whole, or work through it part by part.