Plans and billing
Every tenant has a plan. The plan is stored in tenants.plan (default 'free') and controls which features the tenant can use. The plan column is the source of truth — the marketing site and the signup form are decoupled from the runtime plan and do not yet write it.
plan = 'pro' tenants in D1 and matching against the closed Stripe accounts.The three plans
The PATCH /api/admin/tenants/:id endpoint accepts exactly these three plan values:
z.enum(["free", "pro", "enterprise"])Any other value (e.g. "starter", "team", "business") is rejected with a 400.
Free
- Price: $0 / month
- Default for every new signup (set by the
tenantsDDL default) - Limits (enforced at the API): 3 staff, 1 location, no custom domain, no AI Ops Agent
- Who it's for: a single-operator salon that's trying the platform, or a brand-new tenant before they upgrade
Pro
- Price: $29 / month per tenant (internal price; the public marketing page currently shows different public-facing pricing tiers — see "Discrepancy" below)
- Unlocks: unlimited staff and locations, custom domain (CNAME), AI Ops Agent with auto-pilot, priority support, no booking volume cap
- Who it's for: any business actively taking bookings, especially multi-staff shops
Enterprise
- Price: custom contract. Quoted per deal by the sales team, billed via invoice. No Stripe charge in the platform.
- Unlocks: everything in Pro, plus SSO/SAML, audit log retention beyond 90 days, custom SLA, dedicated CSM, security review, MSA, DPA, BAA if healthcare
- Who it's for: multi-location chains, healthcare, regulated industries, anything that needs an MSA
plan value, not a public marketing tier. The marketing site is a separate React component and does not read tenants.plan. This is a known issue tracked in the platform backlog; the fix is a single source-of-truth pricing table.Feature matrix
| Feature | Free | Pro | Enterprise |
|---|---|---|---|
| Bookings / month | 50 | Unlimited | Unlimited |
| Staff | 3 | Unlimited | Unlimited |
| Locations | 1 | Unlimited | Unlimited |
| Custom domain | — | ✓ | ✓ |
| AI Ops Agent (auto-pilot) | — | ✓ | ✓ |
| SMS reminders | — | ✓ | ✓ |
| Priority support | — | ✓ | ✓ |
| SSO / SAML | — | — | ✓ |
| Custom SLA | — | — | ✓ |
| Dedicated CSM | — | — | ✓ |
| Audit log retention | 90 days | 1 year | 7 years |
How to upgrade a tenant
There is no self-serve upgrade button in the dashboard today. To change a tenant's plan, you (the super admin) do it from the tenant's detail page:
- Open the tenant.
- In the "Change plan" panel, click
proorenterprise. - The console prompts for a reason (free-text). Always include a reason — it goes into
audit_log.reason. - Click confirm. The change is instant. The
audit_loggets atenant.plan_changedrow with the before/after plan values. - Email the tenant owner separately to tell them the change was made. The platform does not yet send a plan-change email automatically.
Equivalent API call:
curl -X PATCH https://booking-api.netwit.ca/api/admin/tenants/$TENANT_ID \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "plan": "pro", "reason": "Manual upgrade per sales agreement" }'Revenue tracking
There is no live MRR number in the platform today. The closest equivalents:
- Pro tenant count:
SELECT COUNT(*) FROM tenants WHERE plan = 'pro' AND is_suspended = 0. Multiply by$29for the floor MRR. The marketing site's Starter price of $29 aligns with this number, but the public Pro tier ($79) does not yet exist in the admin enum. - GMV (30 days): from
GET /api/admin/stats→totals.gmv30d. This is the gross merchandise volume that flowed through BookFlow to tenants' Stripe accounts — it is not NetWit revenue. NetWit only collects the standard Stripe processing fee (2.9% + 30¢) plus, eventually, a SaaS subscription. - Enterprise contracts: tracked in the shared finance spreadsheet, not in the platform. Sum the contracts manually and add to the MRR formula above.
Quick MRR formula
-- Run against D1 (bookflow)
SELECT
(SELECT COUNT(*) FROM tenants WHERE plan = 'pro' AND is_suspended = 0) * 29
+ <manual_enterprise_total> AS mrr_usdPlan enforcement
Plan limits are enforced at the API layer. The relevant rules live in apps/api/src/routes/services.ts, staff.ts, and tenant.ts. Free-plan tenants hitting the 3-staff cap get a 402 Payment Required with code plan_limit_reached. Custom domains and AI auto-pilot are gated the same way.
API
Plan changes go through PATCH /api/admin/tenants/:id. There is no dedicated /api/admin/plans endpoint today. Read more in the tenant detail guide.
/api/admin/tenants/:idSet plan via the { plan: 'free' | 'pro' | 'enterprise', reason: '...' } body. Writes tenant.plan_changed to audit_log.
Sample upgrade call
curl -X PATCH https://booking-api.netwit.ca/api/admin/tenants/$TENANT_ID \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"plan": "enterprise",
"reason": "Signed 12-month MSA, $499/mo, 5 locations"
}'Sample downgrade / revoke Pro
curl -X PATCH https://booking-api.netwit.ca/api/admin/tenants/$TENANT_ID \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"plan": "free",
"reason": "Trial expired, no payment received"
}'Downgrades and data retention
Downgrading a Pro tenant to Free does not delete their data. If a Free tenant has 6 staff and you downgrade them, the 7th-onward staff records stay in D1 but become inaccessible (the UI hides them and the API rejects reads beyond the limit). If they re-upgrade to Pro, the staff come back. If a Free tenant is on a custom domain, the custom domain mapping is kept but the public booking page falls back to booking.netwit.ca/book?slug=....
Trials
tenants.trial_ends_at (added in migration 0003) is an explicit timestamp. While julianday('now') < julianday(trial_ends_at), the tenant is treated as Pro for limit purposes. After the trial ends, the limits revert to Free. The console exposes a date picker to set trial_ends_at manually; the auto-trial-creation flow on signup is on the roadmap.
Email hello@netwit.ca or call +1-604-206-8169. NetWit responds in 1 business day.