Cloud mode (multi-tenant SaaS)
Set TENANCY=multi. Then:
- Platform host = the host of
APP_URL(e.g.bookly-app.io). It serves the landing page,/pricing,/signup,/workspaces(a signed-in user's workspaces),/loginand the operator console at/console. - Tenants live at
<slug>.<ROOT_DOMAIN>and on verified custom domains. Sign-up creates the account, then the workspace (organization + workspace + subdomain) and sends the user to its admin. Sessions are shared across subdomains (advanced.crossSubDomainCookies), so a user signs in once. - Unknown tenant hosts return 404. The self-host setup wizard is disabled.
Plans and limits#
packages/cloud defines Free, Pro ($24 / $240 a year) and Team ($30 a member / $300, two seats
minimum; monthly prices are multiples of 6 so the yearly ten-month price also shows a whole
number of dollars a month): limits, feature gates, display prices and each plan's platform fee on paid bookings
(Free 5%, Pro and Team 0%; Console → Payments sets a rate per plan, stored in platform_state
key payments as fees, and the workspace page overrides one workspace). A workspace's plan + plan_status decide what applies; self-hosted (the default
outside cloud mode) means no limits. Enforcement points: creating event types, inviting members,
connecting integrations, adding custom domains, enabling workflows / team scheduling (paid
bookings are open to every plan; only the fee differs), Bookly video (a Pro feature: the location cannot be saved on Free, a Free workspace's
existing Bookly video rows are hidden from the booking page and provisioning skips the room), the
keyed API, the "Powered by Bookly" line on booking pages and emails (and the workspace's own
logo and colour in its place), the activity log's retention (auditRetentionDays: 90 days on
Free, a year on Pro, two years on Team), its CSV export (activityExport, Pro and Team) and
file storage (storageBytes: 1 GB on Free, 20 GB on Pro, 20 GB per seat pooled on Team;
shown on Admin → Billing).
Auto-capture: Pro 300 minutes a month (5 hours), Team 480 per member pooled (8 hours); the UI talks in hours
(captureHours, "5 hours", "$3 an hour") while billing counts minutes. Past the budget transcription
continues and the excess is billed at CAPTURE_OVERAGE_PER_MINUTE ($0.05) when
STRIPE_PRICE_CAPTURE_OVERAGE is set: a metered price on a Stripe Billing meter whose event name
is STRIPE_METER_CAPTURE_EVENT (capture_minutes). The price is added as a line at checkout and
to older subscriptions on first use; reportCaptureOverage sends one meter event per finished
transcript for the minutes above the budget (idempotent on the transcript id). Hosts can switch
overage off on Admin → Billing (settings.capture.overage), after which transcription stops at
the budget as before; without the price configured it always stops.
Billing#
Stripe subscriptions, same keys as paid bookings (STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET).
STRIPE_TAX=on (after enabling Stripe Tax on the account and setting the origin address there)
makes plan checkouts collect the billing address and VAT id and add sales tax or VAT
automatically; leave it off until then or checkout fails. Recurring prices are STRIPE_PRICE_PRO (per workspace) and STRIPE_PRICE_TEAM (per member; the
quantity follows the member count). Optional STRIPE_PRICE_PRO_YEARLY and
STRIPE_PRICE_TEAM_YEARLY add yearly billing (two months free, charged up front): the pricing
page and Admin → Billing show a monthly/yearly switch, the interval is mirrored onto the workspace
(settings.billingInterval) from the subscription, and the console MRR estimate counts a yearly
plan at a twelfth of its price. Team is billed for at least two seats (minSeats); above that, seats follow the member count: syncSeats updates the
subscription quantity with proration when someone joins or is removed, and reconcileSeats on
the job tick compares every Team subscription to its member count once a day (platform_state
key billing.seats) so a failed update cannot leave seats unbilled. Admin → Billing shows usage, upgrade buttons (Stripe Checkout)
and the customer portal. The webhook needs customer.subscription.created|updated|deleted and
checkout.session.completed; syncSubscription mirrors the subscription onto the workspace.
A lapsed subscription drops the workspace to Free limits without deleting anything.
Paid bookings are a separate money flow: guests pay the host's connected Stripe account, never
the platform's (docs/payments.md, "Cloud mode"). The platform keeps the fee set in Console →
Payments; STRIPE_CONNECT_WEBHOOK_SECRET verifies the events from hosts' accounts.
Operator console#
PLATFORM_ADMIN_EMAILS lists who may open /console on the platform host: workspace list with
plan, owner and booking counts, suspend / unsuspend (public pages and the API go offline; the admin
shows a banner), and "sign in as owner" (Better Auth admin impersonation, which lands you in that
workspace's admin). Stop impersonating from the account menu or by signing out.
The console also has Flags (feature flags and beta cohorts, below) and every operator action lands in Audit log.
Feature flags and betas#
New features reach a few workspaces before everyone. Each feature the code knows about is a
flag (FLAGS in apps/web/src/server/flags.ts, rows in feature_flags) with a state: off
(nobody), beta (the cohorts ticked for it plus any workspace granted it on its console page,
unless that workspace opted out of betas under Admin → Settings → General) or on (everyone).
A per-workspace deny always wins, a grant can carry an expiry, and cohorts
(beta_cohorts, Console → Flags) are named groups of workspaces such as "design partners" that a
flag can be opened to as a whole. Pages behind a flag show a "Beta" badge. In code,
hasFlag(workspace, key) resolves denial → on → opt-out → grant → cohort, once per request.
Flag, cohort and grant changes are operator actions and appear in the operator audit log; a
workspace's own activity log records when it opts in or out.
Background jobs on QStash#
Serverless has no long-running worker, so on Vercel the app uses QStash
as its queue and scheduler. Set QSTASH_TOKEN, QSTASH_CURRENT_SIGNING_KEY and
QSTASH_NEXT_SIGNING_KEY (from the Upstash console) and JOBS_WORKER=false. Then:
- Every job is an HTTP message to
POST /api/jobs/<name>, signed by QStash and verified with the signing keys. Handlers are idempotent, so QStash's retries (and the safety-net schedule) never double-send. - Reminders and follow-ups are scheduled at their exact time when a booking is confirmed
(
booking.remind, deduplicated per booking and start time); a reschedule simply creates new messages and the old ones find nothing due. - Webhook deliveries, transcript download + recap (
capture.process) and CRM sync/notes run as jobs with retries; failures beyond the retries land in QStash's dead-letter queue. - Three schedules are registered on boot with fixed ids (
bookly-booking-remindersevery 5 min,bookly-webhooks-retryevery 5 min,bookly-calendar-syncevery 6 h,bookly-feeds-syncevery 15 min). They are idempotent; Console → Health lists them and the dead-letter count.
/api/cron/tick with CRON_SECRET still works as a manual fallback. Self-hosted installs
keep the in-process pg-boss worker and need none of this.
Shared rate limiting#
Public-form throttles, API limits, the live-transcript endpoint and Better Auth's sign-in
limiter all go through one limiter. Without configuration it counts per process, which is
correct for a single container but not for Vercel, where every instance would count alone.
Set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN (any Upstash Redis database; the
free tier is plenty) and every instance shares the same counters over Upstash's REST API. No
SDK is involved: the app sends INCR + PEXPIRE NX + PTTL as one pipeline per check.
If Upstash is unreachable, requests are allowed and the failure is logged rather than taking bookings down; Console → Health shows the limiter's state.
Installs (telemetry receiver)#
The platform host receives the daily update check from self-hosted installs at
POST /api/telemetry and answers with the running platform version as the latest release.
Console → Installs shows active installs (7 and 30 days), the version spread, and usage totals
for installs that opted in. Rows live in the installs table keyed by the anonymous install id.
The platform never pings itself.
Marketing site#
The platform host serves the public site: landing page, /pricing, /about, /contact,
/security, /privacy, /terms and /changelog (rendered from the repository's
CHANGELOG.md), plus /robots.txt, /sitemap.xml and the social card at /og. Any other
path on the platform host is a 404. Tenant hosts stay out of search engines.
SUPPORT_EMAILis the address shown on the contact page, the legal pages and the footer.APP_URLmust be set at build time as well as at runtime: canonical URLs and Open Graph tags on the marketing pages are prerendered from it. Vercel does this automatically; a Docker image built without it would bake the default into those tags.- Sign-up records acceptance of the terms and privacy policy on the user (
consent_at,consent_version= thelegalUpdateddate). BumplegalUpdatedwhen the policies change; users who accepted an older version are visible in the console. - The legal pages are a starting point written for a sole-operator SaaS. Have them reviewed
before you take paying customers, and update
legalUpdatedinapps/web/src/app/(platform)/platform/site.tswhen you change them.
For AI agents. The platform host also serves /llms.txt (a Markdown map of the site and the
manual, https://llmstxt.org), /.well-known/ard.json (the Agentic Resource Discovery manifest,
https://agenticresourcediscovery.org, advertised with <link rel="ard">; the predecessor path
/.well-known/ai-catalog.json returns the same) and the Agent Skill it points at,
/.well-known/agent-skills/scheduling.md, which tells an agent how to read a host's event types
and availability and book, cancel or reschedule through the public API or the booking pages.
Every public form (booking, waitlist, routing, manage-booking, sign-up, sign-in) is a WebMCP
declarative tool (toolname, tooldescription, toolparamdescription) (a radio group is described on its <fieldset>; hidden fields fixed by the page carry no description, so they are not parameters), and the marketing pages
register two imperative tools, get_pricing_plans and search_documentation, when the browser
exposes navigator.modelContext. Lighthouse's Agentic Browsing category checks all of this;
workspace hosts answer 404 for the manifest and the skill.
Accounts#
The workspace address (the subdomain) and every member's username follow the rules in apps/web/src/lib/handles.ts, shared by the forms (live feedback, submit held until the name passes and is free) and the server actions: lowercase letters, digits and single hyphens, 3–40 characters (2 for usernames), and none of the reserved names (platform hosts and environments, mail and DNS hosts, app routes for usernames), no look-alikes of Bookly or well-known services (leetspeak folded, so b00kly counts), and no address built from sign-in or payment words. To give a brand its own address, the operator creates the workspace; the lists are plain arrays in that file.
Sign-up creates the account, sends a verification link (valid one hour) and waits: the workspace step opens once the link is used, which also signs the person in. Password sign-in before that answers "confirm your email first" and sends a fresh link. Invited members skip the step (the invitation went to their address), and social sign-ins are verified by the provider. Magic links sign in existing accounts only; they never create one.
Legal pages and compliance#
The platform host serves /terms, /privacy, /dpa (the data processing agreement with the
sub-processor table, incorporated into the terms) and /security. The operator is
SITE.operator in platform/site.ts (Cloudeo Solutions, LLC) with its address from
OPERATOR_ADDRESS; SUPPORT_EMAIL is the contact on every page. What the product does for the
operator: recording notices and stored consent (docs/contacts.md → Auto-capture), unsubscribe
links and one-click headers on host outreach (→ Proposals), AI-generated labels on briefings and
recaps, error reports scrubbed of guest emails and phone numbers before they reach Sentry, and
Stripe Tax on plan checkouts (STRIPE_TAX). What stays with the operator: accept each
sub-processor's DPA, keep the table on /dpa current (14 days' notice to workspace owners for
additions), register for sales tax / VAT where thresholds are crossed, and complete the Google,
Microsoft and Zoom app reviews (docs/integrations.md). The hosted service signs no HIPAA
business associate agreements; the terms say so.
Environments#
| Environment | Deploys on | Host | Mode | Database |
|---|---|---|---|---|
| Production | a push to the release branch, which the Release workflow does on a vX.Y.Z tag |
bookly-app.io, *.bookly-app.io, meet.bookly-app.io |
multi-tenant | Neon main |
| Staging | every merge to main, into the Vercel custom environment staging |
staging.bookly-app.io, *.staging.bookly-app.io |
multi-tenant | Neon staging branch |
| PR preview | every push to a pull request | the preview's own vercel.app URL |
single-tenant, demo seed | Neon preview branch |
Tagging is deploying: the Release workflow builds the image, publishes the GitHub release,
applies pending migrations to the production database (the PRODUCTION_DATABASE_URL repository
secret, a Neon pooled URL) and only then fast-forwards release, which Vercel has as its
production branch. A failed migration stops the release before production is touched. Staging and PR builds run migrations themselves before next build,
and PR builds the demo seed too (apps/web/vercel.json, keyed on VERCEL_TARGET_ENV: staging
for the custom environment, preview for pull requests), so both Neon branches are always current. Previews have their own branch because single-tenant mode takes the first workspace it
finds and seeds the deployment's host as that workspace's domain. A pull request's preview has no fixed address: APP_URL is unset there and the
app uses Vercel's own URL for it (withPlatformDefaults in packages/config), running as a
one-workspace install with demo@example.com. Cloud-only features (platform pages, tenants,
billing) are exercised on staging; everything else on the PR itself.
Variables: .env.cloud (production), .env.cloud.staging (the staging custom environment)
and .env.cloud.preview (Preview environment) at the
repository root list every value, with the staging webhook and callback URLs to register on
Stripe (test mode), Google, Entra, GitHub and Zoom. Staging has its own AUTH_SECRET, Upstash
database and Stripe test keys; it shares the QStash, Daily, Anthropic and OAuth credentials.
Uploads go to Cloudflare R2 (STORAGE_DRIVER=s3, the S3_* variables): the bookly-prod
bucket in production, bookly-staging on staging and previews. There is no custom domain on
the buckets (the zone stays on Vercel for the wildcard subdomains); public files are proxied
at /files/<id> and cached at the edge; private ones are streamed by the app at the same
address after checking the session and the drive's visibility on every request (never a
presigned download URL, which anyone holding it could open).
Deploying on Vercel (bookly-app.io)#
The production layout: platform https://bookly-app.io, tenants <slug>.bookly-app.io, built-in
video https://meet.bookly-app.io, www redirected to the apex by the proxy.
- DNS. Point the domain's nameservers at Vercel (a wildcard domain on Vercel needs Vercel
DNS). In the Vercel project add
bookly-app.io,www.bookly-app.io,*.bookly-app.ioandmeet.bookly-app.io. Vercel issues the wildcard certificate. - Project. Root Directory
apps/web, Node 24, install with pnpm, Production Branchrelease(see "Environments").apps/web/vercel.jsonkeeps a daily/api/cron/tickas the safety net behind QStash, and runs functions incle1(Cleveland), the same AWS region as the Neon database (us-east-2): a page makes a few dozen queries, and each one crossing regions would add a round trip. Create a custom environmentstaging(Settings → Environments) tracking themainbranch, withstaging.bookly-app.ioand*.staging.bookly-app.ioas its domains. - Environment. Import
.env.cloudfrom the repository root (it is git-ignored and carries the fixed values: hosts, drivers,JOBS_WORKER=false,TELEMETRY_URL) and fill in eachSET:line: Neon,AUTH_SECRET, Resend (verifybookly-app.iothere first), Upstash Redis and QStash, Anthropic, Daily, the OAuth apps (Google, Microsoft, Zoom, and a GitHub OAuth app for sign-in) and Stripe.APP_URLis read at build time too, so set it before the first build. - Database. For the first deploy, from your machine:
DATABASE_URL='<neon pooled url>' pnpm db:migrate. After that, releases migrate production themselves: add the same URL as thePRODUCTION_DATABASE_URLsecret under the repository's Settings → Secrets and variables → Actions. - Stripe. Two webhook endpoints:
https://bookly-app.io/api/webhooks/stripeon your account (subscriptions and Checkout) andhttps://bookly-app.io/api/webhooks/stripe/connectof type Connected accounts (docs/payments.md, "Cloud mode"). Enable Connect Express and set the platform name and icon hosts see during onboarding. Set the fee in Console → Payments. - After the first deploy. Sign in with an address in
PLATFORM_ADMIN_EMAILS, open/console/healthand/console/payments, re-register the Daily webhook from Admin → Notifications of your own workspace, and send yourself a booking end to end.
Local development#
TENANCY=multi APP_URL=http://localhost:3002 ROOT_DOMAIN=localhost:3002. Subdomains of localhost
resolve in Chrome without DNS changes, so acme.localhost:3002 works.