Create a business: a new organization, or another one in the caller's own (onboarding)
/api/onboarding/completeWhere a business is born, now that a tenant is two rows deep (migration 0031). The only creation path: POST /api/companies was a second one and was deleted on 2026-07-29, having never had a caller and having created companies with no organization above them.
Two modes, decided by the caller's company_id claim, over one identical write set.
No claim: a brand new tenant. Creates book_organizations -> book_companies (with the structured address) -> book_org_members (admin) -> book_organization_members (owner) -> the company_id claim. organizationName is required.
A claim, and the caller OWNS the organization their active business belongs to: adds a business to that organization. Same writes minus the two that belong to an organization's own creation: no book_organizations row, and no second book_organization_members row (it exists, and unique (organization_id, user_id) would reject it). organizationName is ignored: the organization is resolved from the caller's verified ownership, never from the body.
A claim, and the caller does NOT own it: 409, exactly as before. A plain admin or staff member cannot create a business.
The target organization is the one the caller's ACTIVE business belongs to, not "any organization this user owns". Those coincide today; the active-business rule is the one that stays unambiguous if they ever stop coinciding, and it matches what the operator is looking at.
requireMember() is deliberately not used, for the reason POST /api/organization/switch-business sets out: it answers whether the caller is a member of the business they are currently IN, which is not the permission being asked for. The book_organization_members owner row is the authorization, read with service-role and pinned to the caller's own verified user.id.
Service-role throughout, because every write either happens BEFORE the caller's JWT carries a claim (new tenant) or against a DIFFERENT company than the one it names (added business). RLS would reject both. Each step rolls back everything before it, but only the organization it created, never one that was already there.
The claim moves to the new business in both modes, which in the second is a switch: the operator has just created a venue with no services, staff or tables, and every one of those screens is scoped by the claim.
Hours and periods are best-effort and are NOT rolled back. A business with no hours is an ordinary supported state that both screens render; discarding a successfully created one over a schedule insert would trade a small self-correcting gap for the loss of all the operator's work. The atomic replace RPCs are structurally unusable here; both read company_id off the caller's JWT, which at that instant names no company or the previous one, so they would write the schedule onto the wrong venue.
Authorization
sessionCookie The dashboard's Supabase Auth session cookie, set at sign-in. Large sessions are split across
numbered chunks (…auth-token.0, .1), so treat this as a cookie family rather than one name.
Every request re-validates it against the Auth server (getUser()), never by decoding the cookie
locally: a JWT nothing has checked is not a credential. Tenancy is then read from the verified
app_metadata.company_id claim and enforced by row-level security; it is never read from request
input, on any route, ever.
role (admin / staff) is deliberately not in RLS. It gates specific actions in route code,
the operations marked admin below, so hiding a button in the UI is cosmetic only, and a route's
own check is the enforcement.
In: cookie
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
application/json
application/json
application/json
application/json
application/json
application/json
application/json
application/json
curl -X POST "https://example.com/api/onboarding/complete" \ -H "Content-Type: application/json" \ -d '{ "businessType": "appointments", "businessName": "string", "slug": "string", "phone": "string" }'{ "ok": true, "company": { "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "name": "string", "slug": "string" }, "organization": { "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "name": "string" }}Cancel a transfer in progress (owner only) POST
The escape hatch the cooling-off window exists to provide: the real owner reads the notification, sees a transfer they did not start, and kills it. Works in both live states. **Owner only, not any admin.** An admin who could unilaterally veto could make transfer permanently un-completable in any organization with one disgruntled admin; admins are notified instead and can escalate. Compare-and-set on status, so this cannot race the cron into cancelling a transfer that already completed.
Live booking-URL availability while typing, during onboarding POST
A DB read, not a metered third-party call like places/details next to it: the ceiling here is about typing speed and abuse, not a bill. Lets OnboardingWizard's confirm step show "Available" or "Already taken" as the operator types, instead of only finding out from the 23505 catch on submit (onboarding/complete still has that catch; this route is UX only and changes no enforcement). validSlug() runs first and for free: an invalid format or a reserved word (`api`, `book`, `dashboard`, ...) never reaches the database, and is a real, displayable answer rather than a 400, since an operator mid-keystroke on a name passes through several too-short prefixes on the way to a valid one.