Shared

Every business this account can act in

get/api/organization/businesses

The switcher's list. Returns one entry per book_org_members row the caller holds, so an account with a single business gets a list of one and the switcher renders nothing.

The roster IS the list, deliberately. POST /api/organization/switch-business authorizes against the same table, so what is offered here and what is permitted there cannot drift apart. An organization owner appears on each of their businesses because they hold an ordinary admin row on each: this route does not know organizations exist.

Not gated with requireMember(), and read with service-role, for the reasons switch-business sets out: requireMember() answers "is the caller in the company they are currently in", which is the wrong question for a list that spans all of them, and both book_companies and book_org_members are scoped by the company_id claim, so an RLS-scoped read would return a list of length 1 for everybody. The query is pinned to the caller's own verified user.id.

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

Response Body

application/json

application/json

application/json

curl -X GET "https://example.com/api/organization/businesses"
{  "canAddBusiness": true,  "atLocationCap": true,  "businesses": [    {      "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",      "name": "string",      "slug": "string",      "businessType": "appointments",      "role": "admin",      "active": true    }  ]}

Switch between the subdomain and the classic /{slug} link as the asserted address (admin) POST

Flips `book_companies.site_subdomain_preferred`. Refuses with 400 rather than a silent no-op if the company has no subdomain provisioned yet: there is nothing this preference could mean before then, and the dashboard control itself is never shown in that state. Not vertical-gated, unlike most of the Website screen's own routes: hospitality can hold a subdomain too (`ensureTenantSubdomainIfEntitled`, `tenant-subdomain.ts`, fires from the Stripe webhook regardless of `business_type`), so this has to be reachable from both engines. Admin-only, same posture the sibling `offline` route takes: this changes what search engines and the dashboard itself treat as this business's real address, a bigger call than the general settings a staff member with `manage_org_settings` can already make. No cache to invalidate, unlike `offline`'s own route: `tenantCanonicalUrl()` reads this column with a plain, uncached read on every call, by design, so there is nothing stale to revalidate.

Point this account at another business it belongs to POST

Re-mints `app_metadata.company_id` so the caller's next JWT is scoped to a different business under the same organization (migration 0031). **Not gated with requireMember().** That helper asks "is the caller a member of the company they are currently in", and the caller is about to leave exactly that company; it would check the wrong tenant. Authorization is the roster lookup against the TARGET company, done with service-role because `book_org_members`' own policy is `company_id = the claim` with no `auth.uid()` branch, so a session-scoped client cannot see the roster of a company it is not already in. The lookup is still pinned to the caller's verified `user.id`, so it can only ever find memberships that are genuinely theirs. The caller's existing token still carries the OLD claim when this returns; the client must call `supabase.auth.refreshSession()` before trusting any tenant read.