Shared

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

get/api/organization/addons

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.

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