Shared

Create a business: a new organization, or another one in the caller's own (onboarding)

post/api/onboarding/complete

Where 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
sb-qxrvgfkjyvbngipqvslu-auth-token<token>

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"  }}