Shared

Read this business's tier and any self-serve upgrades (admin)

get/api/organization/tier

The 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
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

Query Parameters

feature?string

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.