Read this business's tier and any self-serve upgrades (admin)
/api/organization/tierThe venue paying US for the base plan itself; sibling to /api/organization/addons, which buys something on top of it. Not engine-gated: every business type has a ladder.
currentTier follows the same fail-closed rule as planTier()/planLimit() in src/lib/plan.ts, no billing row, an inactive subscription_status, or an unrecognised tier string all read as free.
options is the list of plans this business could move to right now, each only if it has a real Stripe price and is never below the group floor. Without a paid plan, only tiers ABOVE the current one; with one (hasTierSubscription), every such plan up or down, since POST then changes the plan in place (the card leaves out the exact plan and period in currentPeriod). Never a tier without a real Stripe price (scripts/setup-tier-prices.ts never prices free or enterprise; the top tier stays sales-assisted, sold as Custom (Venue Custom)). A manually-set tier (every business today, since book_billing.tier had no purchase path before this) is therefore never offered a downgrade.
?feature=x is optional and additive (Bruno, 2026-08-02): PlanGate (src/components/PlanGate.tsx) sends it after a 402 on a tier-gated feature, asking "which tier would unlock this". When present and a real PlanFeature slug, the three required* fields below are also present, computed by minimumTierFor() in src/lib/plan.ts and priced on THIS business's own ladder, never duplicated client-side, since a client component cannot import plan.ts at all (it opens a service-role Supabase client at module scope). Every other caller (TierCard) never sends the param and sees no change.
Reads book_billing, which has RLS with no authenticated policy, so this goes through the service-role client, the same reason /api/organization/addons does.
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
Query Parameters
A PlanFeature slug (e.g. table_combining) to ask "which tier includes this". Ignored (and adds nothing to the response) if it is not a real slug.
Response Body
application/json
application/json
application/json
curl -X GET "https://example.com/api/organization/tier"{ "currentTier": "free", "currentTierLabel": "string", "hasTierSubscription": true, "currentPeriod": "monthly", "teamSize": 0, "launchNote": "string", "options": [ { "tier": "solo", "name": "string", "priceAud": 0, "annualPriceAud": 0, "teamPriceAud": 0, "teamAnnualPriceAud": 0 } ], "cardPlan": { "tier": "solo", "name": "string", "priceAud": 0, "freeUpToBookings": 0, "bookingCap": 0 }, "unpaid": true, "requiredTier": "free", "requiredTierLabel": "string", "requiredTierPriceAud": 0}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.
Start tier checkout, or change a paid plan in place (admin) POST
Without a paid plan: returns a **Stripe-hosted Checkout URL** for the browser to navigate to, subscribing this business to the given tier (`annual: true` for the yearly price). Sibling to `POST /api/organization/addons`, and the same rule applies: this route never grants an entitlement; `book_billing.tier`/`subscription_status` are written only by the Stripe webhook (`handleTierBought`, `handleTierChanged`), because the browser arriving at a success URL is the party that benefits from lying about having paid. **With a paid plan** (migration 0058's `tier_subscription_id`, active or trialing): changes that subscription in place (`changeTierPlan`), never a second one. Only the tier item's price changes, charged or credited at once, and a change that needs paying happens only once paid: `url` is then the invoice to pay. Stripe's portal cannot do this, since it never switches a subscription with a metered price or more than one product. Asking for the plan it is already on, or for Solo (Venue Starter) (reached by cancelling), is a 400. Admin-only: changing what the organization is billed is not a staff decision. Not engine-gated; every business type has a ladder.