Shared

Start add-on checkout, open the billing portal, or cancel an add-on (admin)

post/api/organization/addons

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.

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/addons" \  -H "Content-Type: application/json" \  -d '{}'
{  "url": "string",  "ok": true,  "immediate": true}

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

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.

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.