Shared

Point this account at another business it belongs to

post/api/organization/switch-business

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.

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

curl -X POST "https://example.com/api/organization/switch-business" \  -H "Content-Type: application/json" \  -d '{    "companyId": "8bb73d03-06b4-47c7-80c7-59301f770eda"  }'
{  "ok": true}

Every business this account can act in GET

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`.

Read the paid add-on catalogue and what this org has (admin) GET

The add-ons that can be bought, and this org's state for each. **Three states, not two**, and collapsing any two of them is a billing bug: `allowed && !active` means the TIER already includes it and there is nothing to sell; `active` means they pay the add-on fee; neither means it can be bought. Offering an upgrade in the first case charges a Pro customer twice for one feature. Deposits are included on Growth and Pro (Venue Growth and Venue Pro), or A$14.99/mo as an add-on on Solo (Venue Starter), one price on both ladders since 2026-10-02 (src/lib/billing/config.ts). The gate itself lives in `resolveDeposit()`, not here; this route only describes it. **`trialDays` is 30 on every add-on** (2026-10-02): an add-on starts with its own free trial and bills when it ends unless switched off first, on top of the business's 30-day trial of every feature. The UI must not claim a trial that does not exist. `status`/`trialEnd`/`cancelAt` come from `book_addon_subscriptions` (migration 0063), the per-add-on detail table: null for every field when the org has never bought that add-on, populated once a Stripe subscription exists for it regardless of whether it is still live. **`usable` (2026-08-07) can disagree with `allowed`.** `allowed` says the plan covers the slug; `usable` says the grant actually works right now. They differ during a trial: the plan covers deposits, paid tickets and your own web address, but a trial never takes payments or connects a domain, and it covers `marketing` while sends wait for a paid plan, so each reads `allowed: true` with `usable: false` (`TRIAL_EXCLUDED` and `marketingSendGranted` in `src/lib/plan.ts`). Falls back to `allowed` for a slug that is not itself a feature, which no slug is today. Reads `book_billing`, which has RLS with no `authenticated` policy, so it goes through the service-role client. There is deliberately no tenant-scoped path to these columns: it is what stops a member PATCHing themselves an entitlement through PostgREST.