Read the paid add-on catalogue and what this org has (admin)
/api/organization/addonsThe 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.
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
Response Body
application/json
application/json
application/json
curl -X GET "https://example.com/api/organization/addons"{ "addons": [ { "slug": "string", "label": "string", "priceAud": 0, "trialDays": 0, "active": true, "allowed": true, "usable": true, "status": "string", "trialEnd": "2019-08-24T14:15:22Z", "cancelAt": "2019-08-24T14:15:22Z" } ]}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.
Start add-on checkout, open the billing portal, or cancel an add-on (admin) POST
Three shapes, dispatched on the body rather than three routes: `{ addon }` starts Checkout for a monthly subscription (with a free trial when the add-on's `trialDays` is above 0); `{ action: "manage" }` opens the Billing Portal to change a card, read invoices, or manage a TIER subscription; `{ action: "cancel", addon }` cancels ONE add-on. **The buy and manage shapes never grant or end an entitlement themselves.** They return a URL and stop; `book_billing.addons` is written only by the Stripe webhook, because the browser arriving at a success URL is the party that benefits from lying about having paid. **`cancel` is the one exception, and it is add-on-only.** A TIER subscription still has no cancel route of its own; Stripe's portal handles that, emits `customer.subscription.deleted`, and the webhook withdraws it. An add-on needs behaviour the portal's single static cancellation mode cannot express: cancelling during its free trial ends access **immediately** (nothing was ever charged), while cancelling an already-converted, paying add-on is **deferred to the end of the period already paid for**: `book_addon_subscriptions.status` (migration 0063) is what this route reads to decide which. See `cancelAddon` in `src/lib/billing/subscription.ts`. Admin-only on every verb: an add-on changes what the organization is billed, which is the same class of act as a tier change. Not engine-gated; deposits are sold to both verticals. 409 on the buy shape when the add-on is already active, rather than selling a second subscription for the same feature and billing twice for one thing.