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:
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.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 theseatsvalue Polar keeps on a subscription is the whole team including the owner. - Each paid plan also has a
legacy: trueprice: 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.
trialPeriodDaysin the config is display only. solois 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 onsolotoo.- A product id that still holds the
TODO_POLAR_IDplaceholder is never sent to Polar. Checkout answers a configuration error.
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).- 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,
seatsis 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, sominSeatsis set to the same floor andmaxSeatsto at least 100. - The checkout carries metadata
organization_idanduser_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.subscriptionIdis 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 theseats value on the Polar subscription.
apps/core-api/src/modules/billing/billing.seats.ts:
createSubscriptionSeatReaderreads 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).installSeatCacheRefreshsubscribes toonSubscriptionChange. The webhook and the update procedures callpublishSubscriptionChange, so the cache is corrected the moment Polar reports a change.
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 aSUBSCRIPTION 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
SetPOLAR_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.