Start tier checkout, or change a paid plan in place (admin)
/api/organization/tierWithout 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.
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
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
application/json
curl -X POST "https://example.com/api/organization/tier" \ -H "Content-Type: application/json" \ -d '{ "tier": "solo" }'{ "url": "string", "changed": true}Read this business's tier and any self-serve upgrades (admin) GET
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.
Read Gaplessly's own billing details for this org (admin) GET
Migration 0128, the scoped-down native-billing build (docs/backlog.md): a SECOND, distinct ABN and a receipts-email override for GAPLESSLY'S OWN invoices to this venue, kept on `book_billing` rather than `book_companies`. Not the same field as `receiptAbn` on `PATCH /api/companies` (migration 0126), which is the venue's ABN on ITS OWN guest receipts; the two answer opposite questions and live on opposite tables on purpose. Reads `book_billing`, which has RLS with no `authenticated` policy, so this goes through the service-role client, the same reason `/api/organization/addons` and `/api/organization/tier` do. Both fields default to null when the org has never set them, or has no `book_billing` row at all yet.