Shared

Start tier checkout, or change a paid plan in place (admin)

post/api/organization/tier

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.

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

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.