This page is about the studio’s own SaaS subscription to Perform. It is unrelated to what a trainee pays a studio: Product and ClientSubscription rows are bookkeeping for the coach and never reach a payment provider.

Provider

packages/payments/src/provider/index.ts exports one adapter:
Polar is the active provider. A Stripe adapter sits next to it in provider/stripe and is not exported, so it is not used. Switching provider means changing that export. Billing is attached to the organization: config.billingAttachedTo is 'organization'. requireActiveSubscription is false, so a studio with no purchase is on the free plan and is not locked out.

Environment variables

These are read from process.env inside the package, not through the validated env schema.
The variables are called PRICE_ID_*, but with Polar the values are product ids. Checkouts are created per product, and every webhook payload carries productId. Purchase.priceId therefore stores a Polar product id.
The current product ids have production defaults written into packages/payments/src/config.ts. They are identifiers, not credentials, but it means a deployment with no PRICE_ID_*_V2 variables set talks to the production catalog.

Plan catalog

config.plans in packages/payments/src/config.ts. Every base plan is the full product with the same features. Plans differ only by limits. Things the comments in that file establish:
  • Each seat beyond the included ones costs 49 a month (SEAT_AMOUNT). The current products bill a fixed fee plus a graduated seat price where the first two seats are free. So the seats value Polar keeps on a subscription is the whole team including the owner.
  • Each paid plan also has a legacy: true price: the fixed-price product sold until 5 October 2026 (390, 590, 1090, 1690 and 2390). Studios that bought one keep it. It still resolves their purchase rows, and it is never sold again.
  • Trials are 30 days and are configured on the Polar products. trialPeriodDays in the config is display only.
  • solo is subscribed to through a Polar checkout that stores a card and never charges. Its Polar price sits behind a very long trial, because Polar’s minimum price in ILS is not zero and a zero checkout asks for no card. A studio with no purchase at all is on solo too.
  • A product id that still holds the TODO_POLAR_ID placeholder is never sent to Polar. Checkout answers a configuration error.
Helpers: lib/plans.ts (FREE_PLAN_ID, getPlanLimits), lib/provider-price-ids.ts (getPlanIdByProviderPriceId, getPlanPriceByProviderPriceId), lib/studio-plan.ts (resolveStudioPlanPurchase).

The gateway

Billing calls do not go through the Express /v1 lanes. packages/api is a Hono app that the core API mounts at /api (app.use('/api', honoGateway) in app.ts): Procedures authenticate with the better-auth session of the calling user.

Procedures

packages/api/src/modules/payments/procedures.

POST /api/payments/create-checkout-link

Starts a Polar checkout and returns its URL.
string
required
A plan id from the catalog.
string
required
subscription or one-time.
string
month or year.
string
Where Polar sends the user after a successful checkout.
string
The organization being billed. The caller must be a member.
number
Requested team seats, 1 to 100 (MAX_TEAM_SEATS).
Behaviour:
  • The price and product id are resolved from the plan. An unknown plan or price is NOT_FOUND.
  • An organization that already has a billed subscription is refused with reason HAS_SUBSCRIPTION. A paying studio changes its plan and does not buy a second one, because the second would be charged next to the first while the first keeps deciding the plan. An admin grant does not count.
  • On a seat-priced plan, seats is the larger of what was asked and the active team (teamSeatFloor: active coach rows including the owner and pending invites, never below one). The hosted page lets the customer change seats, so minSeats is set to the same floor and maxSeats to at least 100.
  • The checkout carries metadata organization_id and user_id. Polar rejects empty metadata values, so only set values are sent. This metadata is how the webhook knows who paid.
  • An existing customer id is passed so the checkout is pre-filled. Otherwise the user’s email is.

POST /api/payments/change-subscription

Moves a paying studio to another self-serve plan. Body: organizationId, planId (one of start, grow, pro, elite, enterprise), and seats. The caller must be an organization owner or admin (requireBillingManager). requirePlanFitsTrainees refuses a move to a plan smaller than the studio’s current trainee count, with reason PLAN_LIMIT_TRAINEES and limit, count and planId in the error data. The studio’s current plan is never refused, even when it is over it.

POST /api/payments/set-seats

Body: organizationId, seats (1 to 100). Billing managers only. Calls setSubscriptionSeats on the provider.

POST /api/payments/create-customer-portal-link

Body: purchaseId, optional redirectUrl. Returns a Polar customer portal URL for the purchase’s customer. The caller must be allowed to manage that purchase, otherwise FORBIDDEN.

GET /api/payments/purchases

Query: optional organizationId. Lists purchase rows for the organization, or for the user.

Refusal reasons

billingError raises an oRPC error whose data.reason is one of BillingRefusal: The frontend picks its copy by reason, not by message.

Webhook

POST /api/webhooks/payments calls webhookHandler in packages/payments/src/provider/polar/index.ts. Verification uses validateEvent from @polar-sh/sdk/webhooks on the raw request text, before any parsing. Design points:
  • An unknown product is acknowledged with 200 and ignored. A 4xx would make Polar redeliver forever. This is how manually created custom deals pass through.
  • Redeliveries are idempotent because Purchase.subscriptionId is unique and the handler checks for an existing row first.
  • A handler exception answers 400 with the error message, which makes Polar retry.

Seats and the cache

A studio’s seat count is not stored in Postgres. It is the seats value on the Polar subscription. apps/core-api/src/modules/billing/billing.seats.ts:
  • createSubscriptionSeatReader reads seats from Polar and caches them in Redis for 10 minutes (SEAT_CACHE_TTL_SECONDS). After a failed lookup it waits 60 seconds before trying again (SEAT_LOOKUP_RETRY_MS).
  • installSeatCacheRefresh subscribes to onSubscriptionChange. The webhook and the update procedures call publishSubscriptionChange, so the cache is corrected the moment Polar reports a change.
The listener registry lives on globalThis under a Symbol.for key, so every copy of the payments module in the process shares one list. This works because the gateway and the core API run in the same process. Trainee limit checks never read seats, so they never wait on Polar.

Plan summary for the app

GET /v1/web/billing/plan, any staff role.

Limits

The guards that refuse a write past the plan (withTraineeRoom, withSeat and the variants that take no lock) are in billing.limits.ts. They throw FORBIDDEN with a reason of PLAN_LIMIT_TRAINEES or PLAN_LIMIT_SEATS. Usage is counted live. Nothing about limits is persisted.

Manual grants

An admin can give a studio a plan without billing. That is a SUBSCRIPTION purchase row whose customerId is the manual sentinel MANUAL_PURCHASE_CUSTOMER_ID. It wins over any billed subscription when the plan is resolved, and the change procedures refuse to act on it with reason MANUAL_PLAN.

Testing against the sandbox

Set POLAR_SERVER=sandbox, a sandbox POLAR_ACCESS_TOKEN and POLAR_WEBHOOK_SECRET, and the sandbox product ids in the PRICE_ID_*_V2 variables. Purchase rows created against sandbox products that the production catalog does not know resolve to plan id custom, and resolveStudioPlanPurchase prefers a subscription on a known product over one on an unknown product.