Billing
Subscriptions, plans, the customer portal, and the entitlement layer, billed through Stripe, Polar, or Creem.
SyntaxKit ships a complete B2B subscription billing layer that runs on top of one of three payment providers: Stripe (the default), Polar, or Creem (both merchants of record). Subscriptions are scoped to organizations, not users, so a paid plan is a team-level entitlement. Checkout uses provider-hosted pages so card details never touch your domain. Every knob (tiers, intervals, trial length, feature flags, limits) lives in one config file and is yours to tune. The catalog, the entitlement rules, the seven-phase state machine, and the persisted subscription ledger are owned by the kit; a provider owns only money movement (hosted checkout, portal, the live subscription object, signed webhooks). The design is recorded in ADR 0006 (docs/adr/0006-billing-provider-boundary.md).
Choosing a billing provider
BILLING_PROVIDER, what each provider can do, and why switching never migrates.
How a subscription comes to life
The synchronous checkout half and the asynchronous webhook half.
Pricing plans and catalog
One config object: baseline, plans, prices, features, and limits.
Subscription phases
The seven-phase state machine from free to entitled to lapsed.
Entitlements and gating
Read billing state, then gate features and limits from your code.
Choosing A Billing Provider
Exactly one provider is active per deployment, selected by BILLING_PROVIDER:
| Value | Provider | Secrets the doctor requires | Price references |
|---|---|---|---|
unset or stripe | Stripe | STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET | STRIPE_PRICE_ID_* (one per catalog price) |
polar | Polar | POLAR_ACCESS_TOKEN, POLAR_WEBHOOK_SECRET, plus POLAR_SERVER=sandbox|production | POLAR_PRODUCT_ID_* (one per catalog price) |
creem | Creem | CREEM_API_KEY, CREEM_WEBHOOK_SECRET, plus CREEM_SERVER=test|prod | CREEM_PRODUCT_ID_* (one per catalog price) |
The default is Stripe, so an installation upgraded from V1.0 needs no configuration change. Only the active provider's variables are required; the other providers' blocks can stay blank. Billing stays optional: with no provider configured the app boots with billing disabled, and BILLING_REQUIRED=true keeps its fail-closed meaning for the doctor. An unrecognized value (BILLING_PROVIDER=paddle) is a fatal setup error (BILLING_PROVIDER must be one of: stripe, polar, creem.), never a silent fallback. Secrets for a non-active provider produce a doctor warning (STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET are set but BILLING_PROVIDER=polar; stripe is ignored.) so a half-migrated deploy is visible. Creem seats are not advertised.
Switching never migrates anything
Changing BILLING_PROVIDER on an installation with existing subscriptions does not move customers, payment methods, or subscriptions. Every persisted row carries a provider discriminator, and the kit diagnoses a mismatch instead of reinterpreting it:
- A non-terminal subscription row whose
providerdiffers from the active one resolves to phaseconfiguration_errorwith issue codebilling_provider_mismatch. Paid entitlements are not granted from a row the active adapter cannot verify, in-app checkout is blocked, and the portal is denied (the active adapter cannot open another provider's customer). - Terminal rows (
canceled,incomplete_expired) from another provider are history. The organization may start a fresh checkout with the active provider. pnpm setup:doctorcounts live rows owned by another provider when it can reach the database and prints the same diagnosis before boot ("N live subscriptions belong to a billing provider other than Polar (BILLING_PROVIDER=polar) ..."). The fix is to switchBILLING_PROVIDERback, or to cancel those subscriptions in the other provider's dashboard so the organizations can re-subscribe.- An organization can legitimately hold a historical
stripeCustomerId,polarCustomerId, andcreemCustomerId; each is unique only within its provider. Switching providers still never migrates customers.
Running two providers for different customers in one deployment is out of scope, and so is live migration between providers. If you switch, do it before you have paying customers, or plan the migration as a customer-facing cancellation-and-resubscribe.
What each provider can do
Capabilities are declared per adapter in packages/payments/src/provider/capabilities.ts, not inferred at runtime. The dashboard and the support console read these flags to show an explicit reason instead of a dead button.
| Capability | Stripe | Polar | Creem | Notes |
|---|---|---|---|---|
| Hosted checkout | Yes | Yes | Yes | Provider-hosted pages; the browser never sees a card form or a price/product id. |
Customer portal (customerPortal) | Yes | Yes | Yes | Stripe and Polar: payment method, invoices, cancel and resume. Creem opens a billing link. The app does not list Creem invoices. |
Resume a scheduled cancellation from the app (resumeScheduledCancellation) | Yes | Yes | Yes | billing.cancel / billing.resume toggle cancelAtPeriodEnd through the provider API. |
| Plan change in the portal | Yes | Yes | Not advertised | Polar requires "plan changes" to be enabled in its customer portal settings. |
Refresh and reconcile (pnpm billing:reconcile) | Yes | Yes | Yes | Sweeps the active provider's non-terminal rows. |
| Support console compare and resync | Yes | Yes | Yes | Dispatches on the row's provider. |
Manual webhook replay from the support console (manualWebhookRetry) | Yes | No | No | Polar and Creem expose no authenticated "retrieve event by id" API; resync from the live subscription is the recovery path. |
Per-seat checkout (perSeatCheckout) | Yes | Yes | No | Creem seats are not advertised. A quantity above 1 fails closed. |
Usage reporting (reportUsage) | Yes, only if a fork declares a meter | No | No | The shipped catalog is flat. Polar and Creem do not report usage. |
| Kit-sent payment receipts | Yes | No | No | Polar is merchant of record and issues invoices and receipts itself. Creem does not get a kit receipt. |
| Kit-sent trial-ending reminder | Yes | No | No | Polar sends its own trial notifications. The Creem adapter does not send one. |
| Kit-sent welcome, payment-failed, and canceled emails | Yes | Yes | Yes | Sent from the webhook handlers. |
| Tax handling | Opt-in Stripe Tax (STRIPE_AUTOMATIC_TAX) | Included | Merchant of record | As merchant of record, Polar collects and remits tax. Creem is also a merchant of record. |
| Local webhook tooling | Stripe CLI (stripe listen, stripe trigger) | Public tunnel or polar listen | Public tunnel to /api/webhooks/creem | No test clocks in the Polar sandbox; see Local Development With Polar. |
How A Subscription Comes To Life
The flow has two halves. The synchronous half (top of the diagram) creates a provider-hosted checkout session and sends the user there; on success the dashboard simply navigates back with a query param so the page can show a toast. The asynchronous half (dotted edges) is the provider's webhook telling the kit what actually happened. The webhook route claims the event id (so retries don't double-process), writes the Subscription row through the canonical recency-guarded upsert, updates Organization.currentSubscriptionId, and dispatches transactional email and analytics through OutboundEffect so each side effect runs at most once. The next request that calls getBillingState reads the new row and resolves to the entitled phase.
The diagram shows Stripe; Polar and Creem follow the same shape with their own routes (/api/webhooks/polar, /api/webhooks/creem), their own event names, and their own signature schemes. The claim, upsert, and side-effect layers are shared code in packages/payments/src/ledger/.
The diagram source lives at apps/docs/diagrams/billing-checkout-flow.mmd. Rerun pnpm --filter @syntaxkit/docs diagrams:build after editing it to refresh both SVG variants.
Package Layout
The Subscription and StripeWebhookEvent tables keep their historical
column names (stripeSubscriptionId, stripePriceId, stripeLastEventAt,
the table name itself). Since V1.1 those columns hold the row's provider's
identifiers, qualified by the provider column: Stripe sub_…/price_… ids
for stripe rows, Polar subscription and product UUIDs for polar rows.
Renaming them would have been a risky column-rename migration for every
production database and a merge-conflict storm for customized forks (ADR 0006
§3). The domain and wire layers expose neutral names instead:
SubscriptionData.provider, providerSubscriptionId, providerPriceId.
What's Wired In
| Capability | How it's enabled |
|---|---|
| One active provider | BILLING_PROVIDER=stripe|polar|creem (default stripe); adapters in packages/payments/src/{stripe,polar,creem} behind the contract in provider/types.ts |
| Org-scoped subscriptions | Organization.stripeCustomerId / polarCustomerId / creemCustomerId + currentSubscriptionId on the org row |
| Provider-hosted checkout | createCheckoutSession from @syntaxkit/payments/server dispatches to the active adapter and returns a hosted URL |
| Configurable trial | billingCatalogDeclaration.plans.<plan>.prices[].trialDays; defaults to 7 days, tunable per price; first subscription per organization only |
| Customer portal | createPortalSession; one button covers plan changes, payment method, invoices |
| Cancel + resume in-app | cancelSubscription and resumeSubscription toggle cancel-at-period-end on the provider that owns the row |
| Monthly + yearly intervals | Each plan declares any subset of prices; Stripe supports other intervals on the linked Price, Polar models one interval per product |
| Idempotent webhooks | StripeWebhookEvent.eventId claim (Stripe evt_… or Polar webhook-id) + OutboundEffect.(kind, key) per dispatch |
| Recency-guarded writes | upsertProviderSubscriptionRecord rejects a write older than the row's last provider event time, so late redeliveries are harmless |
| Seven-phase state machine | free / paywalled / entitled / grace_period / recoverable / lapsed / configuration_error |
| Per-feature gates | assertBillingFeature(billing, "webSearch", ...) style on every protected action |
| Per-plan limits | maxMembers, monthlyAiResponses; add your own in BillingLimitConfig |
| Lifecycle emails | Welcome, payment failed, canceled for Stripe, Polar, and Creem; receipt, payment action required, and trial ending for Stripe only (Polar sends those itself) |
| Reconciliation | pnpm billing:reconcile re-fetches every non-terminal row of the active provider and heals drift through the same canonical write |
| Price-drift guard | pnpm billing:check-prices compares each declared price with its live Stripe Price, Polar Product, or Creem product |
| Dashboard-driven payment methods (Stripe) | Checkout does not pin payment_method_types; cards, wallets, and regional methods appear based on Settings -> Payment methods |
| Promotion codes (Stripe) | allow_promotion_codes: true on every session so Stripe coupons work without per-call wiring |
| Opt-in Stripe Tax | STRIPE_AUTOMATIC_TAX=true flips on automatic_tax, tax_id_collection, address collection, and writes the data back to the customer for renewals |
| Optional baseline / no free tier | Declare a baseline for a free or locked floor, or omit it for a hard paywall. One file, no code edits (see ADR 0002) |
| Optional live tests | pnpm test:stripe runs *.live.test.ts against real test-mode keys (RUN_STRIPE_LIVE=1); the Polar live test opts in with RUN_POLAR_LIVE=1 against the sandbox |
Why These Choices
One active provider behind a narrow adapter
A universal billing framework would pretend every provider supports every pricing mode; a fork per provider would drift into two state machines. The kit owns the catalog, phases, and ledger; each adapter owns only checkout, portal, the live subscription, and signed webhooks. Capabilities are declared, so an unsupported action degrades to an explicit reason, not a broken button (ADR 0006).
Hosted checkout over a custom page
The provider absorbs PCI scope, 3DS / SCA, wallets, and tax collection. Customers trust the provider domain. There is no card form to maintain, audit, or break.
Stripe by default, Polar for merchant-of-record
Stripe is battle-tested and B2B-native; every operator already knows it. Polar takes over invoicing, tax, and receipts as merchant of record, which is why the kit sends fewer emails for it. Both run the same seven-phase machine.
Server-only price references
Stripe price ids and Polar product ids live in server-only STRIPE_PRICE_ID_* / POLAR_PRODUCT_ID_* env vars and never reach the client bundle. The browser sends a plan slug + interval to checkout; the server resolves the provider reference from the catalog. The marketing page and dashboard render display amounts (no ids) from the catalog.
Pricing Plans And Catalog
The catalog is one TypeScript object you declare with defineBillingCatalog in packages/payments/src/catalog/declaration.ts. It has two parts (see ADR 0002): an optional baseline (what an organization with no active subscription gets) and the plans map of purchasable tiers. Each price names a server-only env var per provider that holds its reference: stripePriceEnv for a Stripe Price ID and/or polarProductEnv for a Polar Product ID. At least one is required per price, names must be unique per provider, and only the active provider's variables must be set. The IDs themselves are never written in the file or shipped to the client. Everything below is a default; the next section is a tour of the knobs you can tune.
The catalog shape
export const billingCatalogDeclaration = defineBillingCatalog({
// The unsubscribed floor. `visibleAsPlan: true` renders it as a card.
// Omit `baseline` entirely to run a hard paywall (see "Paid-only" below).
baseline: {
name: "Free",
description:
"For individuals validating the product and exploring the starterkit.",
visibleAsPlan: true,
entitlements: {
features: createBillingFeatureFlags({}),
limits: { maxMembers: 3, monthlyAiResponses: 100 },
},
},
// The purchasable tiers. `PlanSlug` is derived from these keys.
plans: {
pro: {
name: "Pro",
description:
"For growing teams that need larger organizations and multi-model AI.",
recommended: true,
prices: [
{
interval: "month",
amount: 20,
currency: "USD",
trialDays: 7,
stripePriceEnv: "STRIPE_PRICE_ID_PRO_MONTHLY",
polarProductEnv: "POLAR_PRODUCT_ID_PRO_MONTHLY",
},
{
interval: "year",
amount: 200,
currency: "USD",
trialDays: 7,
stripePriceEnv: "STRIPE_PRICE_ID_PRO_YEARLY",
polarProductEnv: "POLAR_PRODUCT_ID_PRO_YEARLY",
},
],
entitlements: {
features: createBillingFeatureFlags({
billingPortal: true,
multiModelAccess: true,
}),
limits: { maxMembers: 10, monthlyAiResponses: 2000 },
},
},
ultra: {
name: "Ultra",
description:
"For teams that want unlimited scale plus every AI capability unlocked.",
prices: [
{
interval: "month",
amount: 50,
currency: "USD",
trialDays: 7,
stripePriceEnv: "STRIPE_PRICE_ID_ULTRA_MONTHLY",
polarProductEnv: "POLAR_PRODUCT_ID_ULTRA_MONTHLY",
},
{
interval: "year",
amount: 500,
currency: "USD",
trialDays: 7,
stripePriceEnv: "STRIPE_PRICE_ID_ULTRA_YEARLY",
polarProductEnv: "POLAR_PRODUCT_ID_ULTRA_YEARLY",
},
],
entitlements: {
features: createBillingFeatureFlags({
billingPortal: true,
multiModelAccess: true,
webSearch: true,
}),
limits: { maxMembers: null, monthlyAiResponses: null },
},
},
},
});Notice there is no features: string[] on a plan. The human-readable feature
bullets shown on the marketing pricing page and the dashboard plan grid are a
projection of entitlements (see
packages/payments/src/catalog/feature-bullets.ts) and are localized through
the BillingFeatures i18n namespace in apps/web/messages. This guarantees
"advertise == enforce": a plan can only advertise what the server actually
enforces, and the bullets can never drift from the entitlements. See ADR 0003.
A Stripe Price and a Polar Product are not the same shape. Stripe attaches
several Prices (monthly, yearly) to one Product; Polar models one billing
interval per product, so the shipped monthly and yearly Pro plans are two
Polar products. That is why the catalog names one env var per price and per
provider, and why the setup doctor reports a catalog price that declares no
reference for the active provider as an error (Billing catalog: pro (month) declares no Polar price reference. Add polarProductEnv ...). A price that
only names stripePriceEnv is fine as long as Stripe is the active provider.
Dropping the free tier (paid-only)
The baseline is what makes "no free tier" possible. Two paid-only shapes:
// (a) Paid-only with a usable-but-locked floor: unsubscribed orgs can sign in
// but everything is gated until they subscribe.
defineBillingCatalog({
baseline: {
name: "Inactive",
description: "Subscribe to unlock the product.",
visibleAsPlan: false, // no "Free" card on the pricing grid
entitlements: {
features: createBillingFeatureFlags(),
limits: { maxMembers: 1, monthlyAiResponses: 0 },
},
},
plans: { pro: {/* ... */} },
});
// (b) Hard paywall: omit `baseline`. Unsubscribed orgs land in the `paywalled`
// phase and the dashboard routes them to the billing page until they subscribe.
defineBillingCatalog({
plans: { pro: {/* ... */} },
});For trial-only, omit (or lock) the baseline and give every paid plan a trialDays. New orgs must start a trial via checkout to use the product.
What ships today
| Free | Pro | Ultra | |
|---|---|---|---|
| Price | $0 | $20/month or $200/year | $50/month or $500/year |
| Members | Up to 3 | Up to 10 | Unlimited |
| AI responses / month | 100 | 2,000 | Unlimited |
| Multi-model access | No | Yes | Yes |
| Web search | No | No | Yes |
| Billing portal | No | Yes | Yes |
| Trial | N/A | 7 days (per price, configurable) | 7 days (per price, configurable) |
The demo catalog also declares an enterprise plan: $5,000 per year, unlimited members and AI responses, a Contact sales button linking to /contact, and the sso, scim, and auditExport flags on. Pro and Ultra keep those flags off.
Selling through sales
A plan is sold with prices, a contact entry, or both. Declare the contact entry in the plan's sales field: sales: { contact: { href: "/contact", labelKey: "contactSales" } }. href is a site route (the locale prefix is added) or an https:// URL. defineBillingCatalog rejects a plan with neither prices nor sales.contact.
A plan with sales.contact has optional price env vars. While one is unset (for the demo plan, STRIPE_PRICE_ID_ENTERPRISE_YEARLY, POLAR_PRODUCT_ID_ENTERPRISE_YEARLY, or CREEM_PRODUCT_ID_ENTERPRISE_YEARLY), the pricing card and the dashboard plan picker show "Custom pricing" and only Contact sales. Billing stays configured, pnpm setup:doctor lists the variable as optional, and pnpm billing:check-prices skips the plan. A plan with no prices at all behaves the same way and needs no provider env.
Enterprise feature flags
sso, scim, and auditExport are per-plan feature flags that default to false. Turn them on in any plan's createBillingFeatureFlags({ ... }), including baseline.entitlements for the free plan. The flags are advertised as feature bullets; the features they gate arrive in follow-up changes.
Amounts are display-only. The provider charges whatever the linked Stripe
Price or Polar Product is configured to charge; the amount field in
billingCatalogDeclaration is for the marketing page and dashboard plan grid.
The "yearly discount" is whatever you set on the provider side. pnpm billing:check-prices keeps the two honest.
Per-seat prices
Shipped Pro and Ultra are pricingModel: "flat". Their Stripe Prices are a single licensed unit, not a per-seat unit price. Do not flip those rows to per_seat: checkout would multiply the existing flat amount by the seat count.
A fork opts in on a price whose Stripe Price is licensed per_unit (recurring.usage_type=licensed, not metered) and whose Polar product is seat-based, by setting pricingModel: "per_seat" on that catalog price. No new secret env var is required. Checkout quantity is capped at 1000 and at the plan's maxMembers when that limit is set. Stripe sends line_items[].quantity and Checkout adjustable_quantity; the webhook persists quantity from the entitlement line whose price id matches the catalog, never items[0]. Polar sends seats and persists quantity from subscription.seats. SyntaxKit does not assign Polar customer seats or grant Polar benefits; members and invitations remain the access list. Creem checkout does not send a unit count other than the SDK default of 1, and Creem seats are not advertised. A fork must not document Creem as seat-capable.
Member limits stay maxMembers on flat plans, including a historical row whose quantity defaulted to 1. An entitled per-seat subscription caps members plus pending invitations at the purchased quantity, or the tighter of that quantity and maxMembers.
Configurability: What You Can Tune
Every value below is a default. Change the file, restart the app, and the catalog updates everywhere: marketing pricing page, dashboard plan grid, checkout, and entitlement checks.
Knobs in the kit
| Knob | Where | Examples |
|---|---|---|
| Trial length per price | billingCatalogDeclaration.plans.<plan>.prices[].trialDays | 0 (no trial), 7 (default), 14, 30 |
| Price licensing model | billingCatalogDeclaration.plans.<plan>.prices[].pricingModel | "flat" (default; shipped Pro and Ultra) or "per_seat" on a licensed per-unit Stripe Price and a seat-based Polar product |
| Pricing intervals | billingCatalogDeclaration.plans.<plan>.prices[] | Drop yearly, add quarterly, year-only, etc. |
| Number of tiers | keys of billingCatalogDeclaration.plans | Pro and Ultra today; add team, enterprise, etc. |
| Plan name + tagline | billingCatalogDeclaration.plans.<plan>.{ name, description } | Marketing copy on cards |
| Feature bullets | Derived from entitlements (feature-bullets.ts); copy in the BillingFeatures i18n namespace | Bullets project the entitlements and are localized; edit limits/flags to change which bullets appear, edit the messages to change wording |
| Recommended badge | billingCatalogDeclaration.plans.<plan>.recommended: true | Highlights one tier in the grid |
| Baseline (free / locked floor) | billingCatalogDeclaration.baseline | Edit limits, rename "Free" to "Starter", set visibleAsPlan: false, or omit for a paywall |
| Per-plan feature flags | billingCatalogDeclaration.plans.<plan>.entitlements.features | multiModelAccess, webSearch, billingPortal, your own keys |
| Per-plan limits | billingCatalogDeclaration.plans.<plan>.entitlements.limits | maxMembers, monthlyAiResponses; add your own |
| Default AI model | defaultModelId in packages/shared/src/ai/chat-models.ts | Which model free users get; the same file lists every model the picker offers |
| Display amounts | billingCatalogDeclaration.plans.<plan>.prices[].amount | Marketing display only; the provider charges the actual Price / Product |
| Active provider | BILLING_PROVIDER in the runtime env | stripe (default) or polar; see Choosing A Billing Provider |
Knobs in the Stripe Dashboard
These do not require code changes. They live in your Stripe account.
| Knob | Where in Stripe |
|---|---|
| Payment methods (Apple Pay, Link, Cash App, SEPA, iDEAL, etc.) | Settings -> Payment methods. The kit does not pin payment_method_types, so toggles here apply to every Checkout session. |
| Currencies | On the Price itself (per Price, not global) |
| Tax / VAT collection | Settings -> Tax in Stripe + set STRIPE_AUTOMATIC_TAX=true (see Payment Methods And Tax) |
| Invoice branding (logo, colors, footer) | Settings -> Branding |
| Customer-facing email templates | Settings -> Customer emails |
| Webhook event subscriptions | Developers -> Webhooks |
Knobs in the Polar Dashboard
| Knob | Where in Polar |
|---|---|
| Price, currency, and interval | On the product itself (one interval per product) |
| Product-level trial | On the product; the kit overrides it at checkout (see Trial Abuse Boundary) |
| Plan changes in the portal | Settings -> Customer portal -> plan changes |
| Customer emails (receipts, trial reminders, and so on) | Settings -> Customer emails; disable the ones that overlap with the kit's welcome, payment-failed, and canceled emails if you do not want customers to receive both |
| Webhook endpoint and event subscriptions | Settings -> Webhooks |
| Access tokens | Settings -> Developers |
Three concrete recipes
Want a 14-day trial on monthly only and no trial on yearly?
Set trialDays: 14 on the monthly price and trialDays: 0 on the yearly price in billingCatalogDeclaration.plans.pro.prices. Users picking yearly skip the trial and get charged immediately. With Polar, the kit passes an explicit day-based trial (or allow_trial=false) at checkout, so a product-level trial in Polar does not leak through.
Want to add a Team tier?
Add a team: entry alongside the shipped Pro and Ultra plans with its own prices and entitlements. Full walk-through in Customizing The Catalog.
Want quarterly billing instead of yearly?
Stripe: create a quarterly recurring Price, point a new server-only env var (STRIPE_PRICE_ID_PRO_QUARTERLY) at it via the price's stripePriceEnv, and keep interval: "year" for the display toggle (Stripe charges per the linked Price's interval_count: 3). The dashboard toggle reflects the declared intervals automatically. Polar: create a product with a quarterly interval and name it via polarProductEnv; the Polar price check compares the product's interval strictly, so keep the declared interval in step with the product.
Catalog and provider can't silently disagree. The catalog stays the source
of truth for the displayed amount/currency; to make sure those never drift
from what the provider charges, run pnpm billing:check-prices (also wired
into the stripe-live CI job for Stripe). It reads BILLING_PROVIDER and
compares each declared paid price against its live Stripe Price or Polar
Product, failing on amount/currency drift and on archived or missing objects
(zero-decimal currencies handled correctly). For Stripe, an interval
difference is an advisory warning unless you pass --strict or set
BILLING_PRICE_SYNC_STRICT_INTERVAL=1, because the "quarterly billed as
yearly" pattern above intentionally diverges. For Polar the check also
compares the trial length against trialDays. The guard is test/script-only
and never runs on a request. Not to be confused with runtime drift.ts
telemetry, which flags unmapped webhook price references.
Subscription Phases
A subscription's effective state is a BillingPhase, derived from the persisted Subscription row by resolveBillingState. The phases and the transitions between them:
| Phase | When assigned | Has access? | Blocking? | What buyers see |
|---|---|---|---|---|
free | No persisted Subscription row and the catalog declares a baseline | Baseline entitlements only | No | Baseline plan limits and feature flags. |
paywalled | No persisted Subscription row and the catalog declares no baseline (hard paywall) | Locked floor (everything off, limits 0) | No (fails closed) | The dashboard routes them to the billing page to subscribe. |
entitled | Status is active or trialing and the price is in the catalog | Yes (paid plan) | No | Full access; "Current Plan" badge. |
grace_period | Status is past_due; price still in catalog | Yes (paid plan) | No | Paid features keep working; banner asks for payment recovery. |
recoverable | Subscription exists but status is unpaid / incomplete / paused (slot still occupied) | Baseline floor only | No | Baseline behavior; in-app checkout stays blocked, but the billing portal is available so the user can recover the payment with the provider. Polar never persists unpaid (see below). |
lapsed | Subscription is terminal: status is canceled / incomplete_expired (slot free) | Baseline floor only | No | Baseline behavior; "Reactivate" CTA starts a new checkout. |
configuration_error | Active or past_due subscription whose price is not in billingCatalogDeclaration (provider_price_not_configured), or a non-terminal subscription owned by a provider other than the active one (billing_provider_mismatch) | Baseline floor only | Yes | "Billing Attention Needed" card; new checkout / feature gates throw CONFLICT. The portal stays available for a catalog mismatch and is denied for a provider mismatch. |
The "baseline floor" is the declared baseline when one exists, or the synthetic locked floor (everything off, limits 0) on a hard-paywall catalog.
unpaid is not one phase. Stripe persists unpaid and the phase machine treats it as recoverable: the slot stays occupied, checkout is blocked, and the customer recovers in the portal. Polar reports unpaid only after subscription.revoked, so the adapter persists it as terminal canceled and a new checkout is allowed. Creem unpaid persists as grace past_due, and paid access stays on while Creem retries. Full refunds and disputes write canceled and end the entitlement regardless of event delivery order. Partial refunds are audited without ending access. Provider cancellation failures leave the event retryable while local access stays revoked; an explicit support resync can restore access from verified live provider state.
Two callouts worth burning into memory:
grace_periodkeeps paid access. The kit does not abruptly downgrade on the first failed charge; the provider drives recovery, the kit reflects it.configuration_erroris the only blocking phase. Every other phase makes its decision (free vs paid) cleanly and lets requests through.
Plan grants
A plan grant gives an organization a catalog plan without checkout, for example a sales-led Enterprise customer. Only a platform admin can create or revoke one, through the named actions in the support console.
A grant only fills in when no paying subscription is in effect. In the entitled and grace_period phases the subscription's plan wins and the grant is ignored. In free, paywalled, recoverable, lapsed, and configuration_error, the grant supplies the plan, and billing.subscription still returns the leftover row so you can see it. A granted org never queues metered usage overage, because no live subscription would invoice it.
getBillingState reports the winner in source: "subscription", "grant", or "free". Read source rather than recomputing the rule. The billing page leads with the granted plan when source is "grant" and shows any leftover subscription as a secondary note. The support console labels each grant active, expired, scheduled, or not in effect (superseded by a paying subscription).
Connecting Stripe
The first-time Stripe walkthrough (account, products, env vars, webhook endpoint, Stripe CLI for local dev) lives on Setup: Billing (Stripe). When pnpm setup:doctor reports Billing (Stripe): OK, head back here for the deeper coverage of how the subscription lifecycle, entitlements, and dashboard UI fit together.
For the test-to-live cutover (live keys, live price ids, live webhook endpoint), see Going To Production: Pricing And Stripe Live Mode.
Billing is fail-closed for the active provider: once STRIPE_SECRET_KEY
is set, a missing STRIPE_WEBHOOK_SECRET is a fatal config error. The app
doesn't crash — it serves the branded Configuration error page and
/api/health returns 503 (so a health-gated rollout won't mark the deploy
healthy). On a first deploy that's a chicken-and-egg — you need the live URL
to create the Stripe webhook endpoint, but the app can't serve traffic without
the secret. The fix is the documented two-phase first deploy (placeholder
secret, deploy, register the endpoint, swap in the real whsec_…):
Deployment: First Deploy With Stripe
Billing. The same
applies to POLAR_ACCESS_TOKEN and POLAR_WEBHOOK_SECRET when Polar is
active.
Local Development With The Stripe CLI
The asynchronous half of billing — the part that actually creates the Subscription row — only fires when Stripe can reach your webhook endpoint. On a deployed app that's a public URL; on localhost it isn't, so Stripe can't call you. The Stripe CLI closes the gap: stripe listen opens a tunnel from your Stripe test account to localhost:3000/api/webhooks/stripe and prints a signing secret for the session, so the whole loop — checkout, webhook, entitlement flip — runs end-to-end on your machine with test-mode keys and test cards. No money moves; nothing reaches a real card network.
This is the canonical local-billing walkthrough for Stripe. The Setup: Billing (Stripe) page gets you the account, products, and env vars; come back here once pnpm setup:doctor reports Billing (Stripe): OK. Running Polar locally is covered in Local Development With Polar.
Everything here runs in Stripe test mode. Your STRIPE_SECRET_KEY is the
test key (sk_test_*), the price IDs are test-mode Prices, and you pay with
test cards. Checkout is Stripe-hosted, so there is no
publishable key to configure — the browser never touches a card form. The
live-mode cutover is a separate, deliberate step covered in Going To
Production.
One-time CLI setup
Install the Stripe CLI
Follow stripe.com/docs/stripe-cli for your platform (brew install stripe/stripe-cli/stripe on macOS, or the apt/scoop/binary options elsewhere).
Authenticate
stripe loginThis opens a browser to pair the CLI with your Stripe account and grants it test-mode access. You only do this once per machine; listen and trigger below both require it.
The end-to-end loop
Run the app and the CLI side by side, then drive a real checkout. You need two terminals.
Terminal 2 — forward webhooks
Leave pnpm dev running and, in a second terminal, start the listener:
stripe listen --forward-to localhost:3000/api/webhooks/stripeIt prints a line like Ready! Your webhook signing secret is whsec_.... This secret is per session — the CLI mints a fresh one each time you run listen, and it differs from the production endpoint's secret in the Stripe Dashboard. Copy it.
Wire the secret into .env
Paste the printed whsec_* into apps/web/.env:
STRIPE_WEBHOOK_SECRET="whsec_..."Restart pnpm dev so the new value is read. The webhook route verifies every incoming event against this secret; a stale value is the Webhook signature verification failed error in Troubleshooting.
Subscribe from the dashboard
Open http://localhost:3000/dashboard/billing, pick an interval, and click Upgrade. You land on Stripe-hosted Checkout. Pay with the success test card 4242 4242 4242 4242, any future expiry (e.g. 12 / 34), any 3-digit CVC, and any postal code.
Watch the loop close
On success, Stripe fires checkout.session.completed and customer.subscription.created. Terminal 2 logs each forwarded event with a 200 from your route; Terminal 1 logs the webhook upserting the Subscription row. Stripe redirects you back to /dashboard/billing?success=true, and the next read resolves the org to the entitled phase — the plan grid now shows the paid plan as Current Plan.
If stripe listen isn't running (or the secret in .env is stale), Checkout
still completes and Stripe still redirects you back, but no webhook reaches
the app — so the dashboard keeps showing the free plan. That exact symptom is
covered in Troubleshooting: Checkout completes but the dashboard still shows
the free plan. The redirect
is cosmetic; the Subscription row is born from the webhook (see How A
Subscription Comes To Life).
Simulating events with stripe trigger
Clicking through Checkout exercises the happy path, but the kit handles a dozen events (see The Webhook Path) — trials ending, payments failing, subscriptions canceling. You don't need to reproduce each one by hand. With stripe listen running in Terminal 2, fire any event from a third terminal:
# Re-run the checkout-completed handler
stripe trigger checkout.session.completed
# Drive the subscription lifecycle handlers
stripe trigger customer.subscription.created
stripe trigger customer.subscription.updated
stripe trigger customer.subscription.deleted
stripe trigger customer.subscription.trial_will_end
# Exercise the dunning / invoice emails
stripe trigger invoice.payment_failed
stripe trigger invoice.payment_succeededEach trigger creates the underlying test objects in Stripe and forwards the resulting event through your listener, so you can watch a handler run — and the email/analytics side effect dispatch — without a real subscription in that state. Use it to test the lifecycle emails (log mode writes them to .local/email-outbox/*.html) or to confirm a new handler you've wired up actually fires.
stripe trigger fabricates generic test objects, so a triggered event's IDs
won't line up with the specific subscription you created through the
dashboard. It's the right tool for exercising a handler; to test a
specific subscription's transition, drive it through Checkout or the
Customer Portal instead.
Test cards
Stripe test mode accepts a small set of magic card numbers. The two you'll reach for most:
| Card number | Behavior |
|---|---|
4242 4242 4242 4242 | Succeeds immediately. The default "happy path" card. |
4000 0000 0000 0002 | Declined (card_declined). Use it to see how the kit surfaces a failed checkout. |
For every card, use any future expiry, any 3-digit CVC, and any postal code. Stripe's test-cards reference is the source of truth and lists many more (3DS / SCA challenges, specific decline codes, regional methods) — reach for it when you need a scenario beyond success and decline. Polar's sandbox checkout accepts the same Stripe test cards.
Connecting Polar
Polar is a merchant of record: it sells to your customer, issues the invoice, collects and remits tax, and sends its own receipts and trial reminders. The kit keeps its catalog, phases, and ledger exactly as with Stripe and talks to Polar through packages/payments/src/polar/ using @polar-sh/sdk.
Sandbox vs production
Polar runs two fully separate environments. The sandbox (dashboard at sandbox.polar.sh, API at sandbox-api.polar.sh) has its own organizations, access tokens, products, and webhook endpoints; nothing created there exists in production and no real payments are collected. POLAR_SERVER tells the kit which one to talk to:
POLAR_SERVER | Talks to | Use for |
|---|---|---|
sandbox | sandbox-api.polar.sh | Local development, staging, the live test suite |
production (default when unset) | api.polar.sh | Real customers |
The default is production so a deployment that forgets the variable never bills against a sandbox by accident; sandbox is the explicit opt-in. A production build with POLAR_SERVER=sandbox gets a non-fatal doctor warning (POLAR_SERVER=sandbox in production: checkouts go to Polar's sandbox ...) because staging legitimately runs that way. An unrecognized value is a fatal setup error (POLAR_SERVER must be one of: sandbox, production.).
First-time setup
Create the organization and an access token
Sign in to the sandbox dashboard, create an organization, and under Settings -> Developers create an organization access token. Copy it into POLAR_ACCESS_TOKEN. Repeat in the production dashboard when you go live; the tokens are not interchangeable.
Create one product per plan and interval
Polar models one billing interval per product. For the shipped catalog that is four products: Pro monthly, Pro yearly, Ultra monthly, Ultra yearly, each with a recurring fixed price whose amount and currency match the catalog's amount/currency. A product-level trial is optional; the kit overrides it at checkout (see Trial Abuse Boundary). Copy each product id (a UUID) into its env var:
POLAR_PRODUCT_ID_PRO_MONTHLY="..."
POLAR_PRODUCT_ID_PRO_YEARLY="..."
POLAR_PRODUCT_ID_ULTRA_MONTHLY="..."
POLAR_PRODUCT_ID_ULTRA_YEARLY="..."The names come from each price's polarProductEnv in packages/payments/src/catalog/declaration.ts; the setup doctor derives the required list from the catalog.
Register the webhook endpoint
Under Settings -> Webhooks add an endpoint with URL <NEXT_PUBLIC_APP_URL>/api/webhooks/polar and format raw. Subscribe to subscription.created, subscription.updated, subscription.active, subscription.canceled, subscription.uncanceled, subscription.past_due, subscription.revoked, checkout.updated, order.paid, and order.refunded. Polar shows the signing secret once; copy it into POLAR_WEBHOOK_SECRET. order.refunded ends the entitlement for a full refund and audits a partial refund. refund.created can describe a pending refund, so it is acknowledged without changing access. Any other event type you subscribe to is acknowledged and ignored.
Set the env vars
| Var | Value |
|---|---|
BILLING_PROVIDER | polar |
POLAR_SERVER | sandbox for development, production when live |
POLAR_ACCESS_TOKEN | The organization access token |
POLAR_WEBHOOK_SECRET | The endpoint's signing secret |
POLAR_PRODUCT_ID_* | One product id per catalog price |
Leave the STRIPE_* block blank; setting it while Polar is active only produces a doctor warning.
Verify
pnpm setup:doctor reports Billing (Polar, sandbox): OK and Billing provider consistency: OK. pnpm billing:check-prices compares the four products with the catalog. Visit /dashboard/billing; the plan grid renders with working upgrade buttons.
Webhook verification and dedup
Polar signs deliveries with Standard Webhooks headers: webhook-id, webhook-timestamp, and webhook-signature, with a five-minute timestamp tolerance. The kit verifies them with the standardwebhooks library and accepts both encodings a Polar secret can have (Polar changed its secret encoding on 2026-09-08: older secrets sign with the UTF-8 bytes of the whole whsec_… string, newer ones with the base64-decoded remainder). You never have to know which generation your secret is. Missing headers are answered with 400 and Polar does not retry; a bad signature is also 400. The webhook-id header is the dedup key stored in StripeWebhookEvent.eventId (stable across Polar's redeliveries), and the payload timestamp is the ordering signal the recency guard compares, so out-of-order deliveries cannot roll a row backwards. Polar retries a failed delivery up to 10 times with exponential backoff and disables an endpoint after 10 consecutive failures; check Settings -> Webhooks after an outage and re-enable it.
Lifecycle semantics
Polar's subscription statuses coincide with the kit's persisted enum by name, and the adapter keeps their lifecycle bucket with one deliberate divergence: unpaid. Polar reports unpaid only after subscription.revoked ended the subscription with dunning retries exhausted, and offers no way to revive it, so the adapter persists it as the terminal canceled. Keeping Stripe's "recoverable" meaning would strand the customer behind a portal that cannot help them.
| Polar event | Persists | Resulting phase | Kit email | Polar email |
|---|---|---|---|---|
checkout.updated (status succeeded) | Organization.polarCustomerId when missing | unchanged | None | Order confirmation / receipt |
subscription.created | Subscription upsert (often still incomplete before the first payment) | recoverable until active | None yet | None |
subscription.active | Status active or trialing | entitled | Welcome email + subscription_started analytics on the first healthy write | Trial reminders (if any) |
subscription.updated | Status, periods, cancelAtPeriodEnd, trialEnd, product | Follows the status | None | None |
subscription.past_due | Status past_due | grace_period (paid access stays on while Polar runs its dunning: four retries over 21 days) | Payment-failed email pointing at the portal | Dunning emails |
subscription.canceled | cancelAtPeriodEnd = true, status stays active | entitled until the period ends | None | Cancellation notice |
subscription.uncanceled | cancelAtPeriodEnd = false | entitled | None | None |
subscription.revoked | Status forced to canceled (period end reached, immediate revoke, or dunning exhausted) | lapsed | Canceled email | None |
order.refunded | Full refunds write canceled; partial refunds record an audit event | lapsed for a full refund, otherwise unchanged | None | Refund receipt |
order.paid | Nothing (structured log only) | unchanged | None | Invoice / receipt |
subscription.cycled, subscription.paused, subscription.resumed, benefit events, refund.created, refund.updated | Acknowledged, no handler; no access change | — | None | Per Polar's settings |
A customer in grace_period recovers by updating the payment method in the Polar customer portal, which retries the charge immediately. A subscription that Polar no longer knows (deleted in the dashboard) is marked canceled by the reconcile sweep and by the read-path self-heal.
Customers and the one-email limitation
The adapter creates the Polar customer up front with external_id set to the SyntaxKit organization id and metadata.organizationId, and persists the id on Organization.polarCustomerId with compare-and-set. Those two provider-signed identifiers are what webhook correlation and reconciliation rely on; a payload whose customer, subscription, and metadata disagree about the owning organization is rejected as an invalid association rather than guessed. If the local column was never written (crash between create and persist, restored database), the adapter recovers the mapping through customers.getExternal instead of creating a duplicate.
Polar enforces one customer per email per Polar organization. If a user owns two SyntaxKit organizations and starts checkout for the second with the same billing email, Polar refuses to create a second customer; the kit records drift kind polar_customer_conflict and billing.createCheckout answers CONFLICT with a message explaining the email clash instead of silently attaching the first organization's customer. Polar also enforces one active subscription per customer by default, so a checkout for an organization that already has one is refused by Polar and likewise answered as CONFLICT (both errors extend BillingCheckoutConflictError from the provider contract). Neither limitation exists with Stripe. If your product routinely lets one person pay for several organizations, collect a distinct billing email per organization or stay on Stripe.
Local development with Polar
Polar has no equivalent of stripe listen built into the kit's workflow, and the sandbox has no test clocks: renewals, trial expiry, and dunning cannot be fast-forwarded. Those paths are covered by unit tests with signed fixture payloads (packages/payments/src/polar/**/*.test.ts) and the shared ledger's tests (packages/payments/src/ledger/*.test.ts); the live sandbox test (RUN_POLAR_LIVE=1) exercises customer creation, checkout, and the portal. For the interactive loop:
Expose localhost:3000
Polar's sandbox must be able to reach your webhook route. Use any public tunnel, for example cloudflared tunnel --url http://localhost:3000 or ngrok http 3000, or Polar's own polar listen CLI, which forwards sandbox webhooks to a local URL. Copy the public URL.
Register a sandbox endpoint for that URL
In the sandbox dashboard, add a webhook endpoint at <tunnel-url>/api/webhooks/polar (format raw, the nine events above) and put its secret in POLAR_WEBHOOK_SECRET. A tunnel that changes its hostname on every start needs the endpoint URL updated each time; the secret stays the same for that endpoint.
Run the loop
Start pnpm dev with BILLING_PROVIDER=polar and POLAR_SERVER=sandbox, open /dashboard/billing, click Upgrade, and pay on the Polar-hosted checkout with the test card 4242 4242 4242 4242 (Polar sandbox accepts Stripe test-mode card numbers). checkout.updated and the subscription.* events arrive through the tunnel, the Subscription row is written, and the next read resolves to entitled. Cancel and resume from the dashboard to see subscription.canceled and subscription.uncanceled; use the portal to change plans.
Keeping the catalog honest
pnpm billing:check-prices reads BILLING_PROVIDER and compares each paid price you declare in billingCatalogDeclaration against its live Stripe Price, Polar Product, or Creem product. Creem product prices are integer cents; the catalog amount is major units, and the check converts explicitly. The Creem check skips when Creem is not configured. For Stripe it fails on amount/currency drift and on archived or missing Prices (interval drift is advisory unless --strict). For Polar it compares amount, currency, billing interval, archived state, and the trial length against trialDays. Run it after you create or re-point a price or product (or change a declared amount/currency) to confirm the catalog and the provider still agree before you click Upgrade — an unmapped product is what produces the configuration_error phase. It's a test/script-only guard and never runs on a request; it skips cleanly when the active provider is not configured or only placeholder values are set.
Reconciliation
Webhooks are the primary provider-to-database sync channel, but they can be missed: a broken endpoint, a wrong signing secret, or a retry window that ran out. pnpm billing:reconcile re-fetches every non-terminal Subscription row owned by the active provider and writes drifted rows through the same canonical recency-guarded upsert the webhook handlers use; a subscription the provider no longer knows is marked canceled. pnpm billing:reconcile --dry-run reports drift without writing. Rows from a non-active provider are never contacted. Run it after fixing a webhook outage, or periodically as a safety net. The read path also self-heals: when getBillingState finds a row marked stale it refreshes that one subscription from its provider. The support console's Compare and Resync actions do the same for one organization, with an audit trail.
Connecting Creem
Creem is a merchant of record, same boundary as Polar: it moves money, and SyntaxKit keeps the catalog, entitlements, and ledger. The adapter is packages/payments/src/creem/ and uses the creem SDK (not @creem_io/better-auth or @creem_io/nextjs, which grant access themselves and fight the kit's state machine).
Set BILLING_PROVIDER=creem, CREEM_API_KEY, CREEM_WEBHOOK_SECRET, and CREEM_SERVER=test (or prod, the default). Point CREEM_PRODUCT_ID_* at products you create in the Creem dashboard. Do not invent product ids. Register the webhook at /api/webhooks/creem. Creem signs with the creem-signature header (HMAC-SHA256 of the raw body). Dedup uses the event id (evt_…).
Seats are not advertised. Checkout never sends units other than the SDK default of 1. A quantity above 1 fails closed and is not retried without that quantity. Webhook samples often omit items; those persist quantity 1 and role base. A flat catalog price that reports a unit count other than 1 is drift and is not stored as quantity 1.
Creem unpaid is treated like past_due for recovery UI and persists as the kit's grace past_due, not as a terminal cancel. subscription.scheduled_cancel stays entitled until current_period_end_date. subscription.paused and subscription.expired end access. Succeeded full refund.created events and all dispute.created events end the entitlement. Partial refunds are audited without ending access; cumulative refunds that return the full transaction also revoke it. When the payload lacks the transaction's cumulative refunded amount, the handler reads it from the live Creem transaction. Partial refunds are recorded with their amount and transaction, so a live total that is missing or still below the refunds known to have settled fails the delivery for a retry instead of being read as partial. Creem sends no follow-up event when a pending refund settles, so a pending refund that would complete a full refund fails the delivery and Creem's retries re-check the live transaction; if it never settles, the event stays failed in the support console. A pending refund that cannot complete a full refund is acknowledged without changing access. Credits and license keys are acknowledged and ignored. Switching to Creem does not migrate existing customers.
The installed SDK cannot suppress a product-level trial. When the kit denies a trial (including a second subscription), checkout fails closed instead of granting another one.
Payment Methods And Tax
Two production concerns deserve a one-paragraph mental model before launch: which payment methods appear at checkout, and whether sales tax / VAT is collected on top of the price. With Polar both are the merchant of record's problem: Polar's checkout decides the payment methods and Polar collects and remits tax, so this section is Stripe-specific.
Payment methods are Dashboard-driven
createCheckoutSession does not set payment_method_types. Stripe takes the list from your Dashboard at Settings -> Payment methods and renders only what you've enabled. Cards are on by default; you can toggle Apple Pay, Google Pay, Link, Cash App, SEPA Direct Debit, iDEAL, Bancontact, and the rest without redeploying. Pinning the list in code (the older payment_method_types: ["card"] pattern) actively suppresses everything else, including wallets, so the kit deliberately leaves it off.
Tax is opt-in via one env var
Stripe Tax is the production answer to VAT (EU/UK), GST (AU/NZ/CA), and US sales tax. It's off by default in the kit because turning it on without a Stripe Tax registration causes Checkout to fail. Two steps to enable it:
Activate Stripe Tax in the Dashboard
Go to Settings -> Tax in your Stripe account, activate Stripe Tax, and add at least one tax registration for a jurisdiction where you have a tax obligation. Stripe will only attempt to compute tax for jurisdictions you've registered.
Set the env var
Add STRIPE_AUTOMATIC_TAX="true" to your runtime environment and redeploy. Every subsequent Checkout session enables automatic_tax, asks for a billing address, lets B2B buyers enter their VAT / tax ID via tax_id_collection, and writes the address + name back to the Stripe customer (customer_update) so the next renewal invoice computes tax correctly without re-asking.
When the flag is unset, Checkout still collects an address opportunistically (billing_address_collection: "auto") but does not enforce one or run tax calculation. If billing is not configured (isBillingEnabled is false), none of this applies.
Checkout And Portal
Two procedures cover everything users do on the billing page. Both are gated by withPermission(billing: ["manage"]) so non-admin members can read state but not start checkout or open the portal. Neither procedure branches on the provider: they call the neutral facades in @syntaxkit/payments/server, which dispatch to the active adapter.
billing.createCheckout
Phases of the handler:
- The client sends
{ planSlug, interval }, never a price or product ID. assertBillingEnabled()throwsBAD_REQUESTif billing is not configured in this environment.getCurrentOrganizationBilling(headers)resolves the active org andBillingState.- Classify eligibility with
getCheckoutConflict(billing): one derived check on the resolvedphase(no raw status matching). - Reject with
CONFLICTwhen it returnsconfiguration_error(catalog or provider mismatch),active(already subscribed, use the portal), orrecover(a non-terminal subscription must be recovered first);nulllets checkout proceed. resolveForward({ planSlug, interval })resolves the server-only provider price reference (Stripe Price ID or Polar Product ID) and the trial from the catalog for the active provider (BAD_REQUESTon an unknown plan/interval).createCheckoutSession({ organizationId, providerPriceId, email, trialDays, successUrl, cancelUrl })lets the adapter ensure the provider customer exists (Organization.stripeCustomerId,polarCustomerId, orcreemCustomerId) and returns a hosted URL. Provider conflicts (a second customer with the same email, an already active subscription) are thrown asBillingCheckoutConflictErrorand mapped by the use-case to an oRPCCONFLICTwith the adapter's explanation, rather than a silent wrong customer or an opaque 500.
The dashboard does window.location.assign(url). After payment, the provider redirects back to getCheckoutSuccessUrl() (which appends ?success=true); on cancel, getCheckoutCancelUrl() appends ?canceled=true. The dashboard reads those query params to show toasts.
billing.createPortal
assertBillingEnabled().canOpenBillingPortal(billing)returns true whenever the org has a stored provider customer and the phase can reach the portal:entitled/grace_period(with the plan'sbillingPortal: true), plusrecoverableandconfiguration_errorfor a catalog mismatch — so a customer whose payment failed or whose plan fell out of the catalog can always get back to the provider to pay or fix their subscription. Denied forfree/paywalled/lapsed, for customers with no stored provider customer, and forbilling_provider_mismatch(the active adapter cannot open another provider's customer).createPortalSession({ organizationId, returnUrl })returns a hosted URL.
The portal is the universal "manage billing" surface: change plan, change payment method, view invoices, cancel. The kit also offers in-app cancel + resume (billing.cancel, billing.resume) that toggle cancel-at-period-end directly through the API of the provider that owns the row for users who don't want to leave the dashboard.
Trial Abuse Boundary
Trials are first-subscription-only, per organization. The Stripe and Polar adapters (packages/payments/src/stripe/checkout.ts, packages/payments/src/polar/checkout.ts) only attach a trial when the organization has no prior Subscription row — they count prisma.subscription.count({ where: { organizationId } }) and skip the trial when that is non-zero. Any historical row disqualifies the org regardless of status or provider (canceled, expired, or otherwise), so the classic "cancel and re-checkout for another free trial" loop is dead: an org gets exactly one trial, ever. With Polar the rule is enforced explicitly at checkout: the kit passes allow_trial=false for an org that already had a subscription, otherwise a day-based trial of the catalog's trialDays, so a product-level trial configured in Polar cannot be re-earned. Creem has no equivalent control in the installed SDK, so a denied trial fails closed rather than being silently granted.
That guard is org-scoped, and organizations are free to create. A determined user can therefore still farm unlimited trials by spinning up a fresh organization per trial — each new org has no subscription history, so each qualifies for its own trial. This is inherent to org-scoped billing (the same team-level model that makes a paid plan an organization entitlement rather than a user one), not a gap in the checkout guard. With Polar, the one-customer-per-email rule incidentally limits this to one organization per billing email.
For most B2B SaaS this is a non-issue: standing up a real, separately-invoiced organization per trial is enough friction on its own. Only tighten the boundary if a trial is genuinely expensive to grant (heavy compute, paid third-party calls) and multi-org farming is a real cost.
Two mitigations fit the kit without abandoning the org-scoped model:
Gate trials per user
Track trial eligibility on the user who creates the org, not only on the org. Before attaching a trial in the adapter's checkout, check whether any organization that user already owns has trialed, and drop the trial if so. Simple, but it also denies a legitimate second-org trial and only binds accounts the same user is signed into.
Gate trials per payment fingerprint
Let the payment method do the de-duplication. Collect a card up front and key trial eligibility on Stripe's card fingerprint (or lean on Stripe's built-in trial-abuse prevention) so the same card can't start a second trial across orgs. Catches farming across separate accounts that per-user gating misses, at the cost of requiring a card before the trial. Stripe only.
The Webhook Path (Billing-Specific)
The webhook deep dive lives at Webhooks And Async Workflows, which covers the Stripe route, the Polar route, and the shared ledger. What follows is the billing-specific dispatch table for Stripe: each row is one Stripe event the kit handles. The Polar table is in Lifecycle semantics above.
| Event | Persists | Side effects |
|---|---|---|
checkout.session.completed | Organization.stripeCustomerId (when missing) | None |
customer.subscription.created | Subscription upsert + Organization.currentSubscriptionId | Welcome email + subscription_started analytics |
customer.subscription.updated | Subscription (status, periods, cancelAtPeriodEnd, trialEnd, planSlug) | None (status changes drive UI on next read) |
customer.subscription.deleted | Subscription.status = "canceled" + sync org pointer | Cancellation email |
customer.subscription.trial_will_end | None | Trial-ending email |
invoice.payment_action_required | None | "Action required" email with hosted invoice URL |
invoice.payment_failed | None (status arrives via subscription.updated) | Payment-failed email |
invoice.payment_succeeded | None | Receipt email |
invoice.finalized | None | Log only |
The two-layer idempotency story is shared by Stripe, Polar, and Creem: StripeWebhookEvent.eventId is claimed atomically before any handler runs (provider re-deliveries are no-ops); each email and analytics dispatch claims its own (kind, key) row in OutboundEffect so the same business action can never fire twice. See the linked Webhooks page for the failure-mode walkthrough.
Entitlements And Gating
How to actually use this from your code.
Read the state
Inside an oRPC procedure, use the helper:
import { getCurrentOrganizationBilling } from "@/lib/billing";
const { organization, billing } = await getCurrentOrganizationBilling(headers);Outside an oRPC procedure (e.g. a one-off script, a non-procedure server action), call getBillingState(orgId) from @syntaxkit/payments/server directly. The resolved BillingState carries subscription.provider and subscription.providerSubscriptionId so UI can label the provider; the browser-safe getBillingProviderCapabilities(provider) from @syntaxkit/payments/client tells you which actions to offer.
Gate a feature
import { assertBillingFeature } from "@/lib/billing";
assertBillingFeature(
billing,
"webSearch",
"Upgrade to Ultra to enable web search."
);assertBillingFeature throws CONFLICT when there's a blocking billing issue (catalog or provider mismatch) and FORBIDDEN when the feature isn't on the plan. The optional third argument is the upgrade copy that flows back to the user.
Gate a limit
For caps that gate a side-effecting action (an AI response, a billable API call), use a reservation: it counts the active window AND inserts the usage row inside one advisory-locked prisma.$transaction, so concurrent callers can't both pass a stale "under cap" check and then both write usage rows.
import { reserveAiUsageEvent } from "@/lib/billing";
const usage = await reserveAiUsageEvent(billing, organization.id, {
kind: "chat_send",
chatId: null,
createdByUserId: user.id,
});
try {
// ... call the model / do the billable work ...
} catch (error) {
// Best-effort refund: the monthly slot is freed for the next request.
await prisma.aiUsageEvent.delete({ where: { id: usage.id } }).catch(() => {});
throw error;
}For predicate-style checks that don't need atomicity (rendering an "Upgrade" banner, gating a member invitation form), keep using the assert helpers:
import {
assertWithinAiResponseLimit,
assertWithinMemberLimit,
} from "@/lib/billing";
await assertWithinAiResponseLimit(billing, organization.id);
await assertWithinMemberLimit(billing, organization.id);All three throw CONFLICT on a blocking issue or when the cap is reached. Add your own with the same shape.
Surface upsells
The thrown FORBIDDEN message is the upgrade copy. On the client, useChat's onError (or any other error boundary) toasts it directly:
const { sendMessage } = useChat({
onError: (error) => toast.error(error.message),
// ...
});That gives you "Upgrade to Ultra to enable web search." in the UI without any extra wiring.
The Dashboard Billing Page
/dashboard/billing has four UI blocks:
| Block | What it does |
|---|---|
<CurrentPlanCard> | Plan badge, status, trial-end line, cancel / resume button. Shows a "Billing Attention Needed" alert when hasBlockingBillingIssue is true and a "payment recovery" line for grace_period. |
| Monthly / yearly toggle | Two-button switch that drives which interval is sent to checkout (the server resolves the matching price). |
<PlanCard> grid | One column per plan: feature list, recommended badge, CTA. New subscribers click through to createCheckout; existing subscribers click through to createPortal for change / downgrade. |
| Portal button ("Manage in Stripe") | createPortal for the universal entry point, regardless of which plan. The button names the provider that owns the subscription. |
Marketing pricing (PricingSection on the homepage) renders the same billingCatalogDeclaration but only shows monthly amounts and CTAs go to signup, not checkout. Checkout requires an active org.
Customizing The Catalog (Worked Example: Adding A Team Tier)
The previous section listed every knob. This one preserves the most-asked extension tutorial end-to-end: adding a new "Team" tier alongside the shipped Pro and Ultra plans. Its prices and entitlements are illustrative, not part of SyntaxKit's default catalog.
In your provider
Stripe: create a new Product called "Team" and add two recurring Prices: monthly and yearly. Copy both price ids. Polar: create two products, "Team monthly" and "Team yearly", each with one recurring price. Copy both product ids. Pick the amount you want; you can copy Pro's structure or charge any number you like. You only need to do this for the provider you run, but declaring both env names in the catalog keeps the plan portable.
Env vars
Add the new references to your environment for the active provider:
# Stripe
STRIPE_PRICE_ID_TEAM_MONTHLY="price_..."
STRIPE_PRICE_ID_TEAM_YEARLY="price_..."
# Polar
POLAR_PRODUCT_ID_TEAM_MONTHLY="..."
POLAR_PRODUCT_ID_TEAM_YEARLY="..."Mirror the entries in apps/web/.env.example. The boot doctor derives its required billing env vars from the catalog declaration and the active provider, so once the team prices below name these env vars they are validated automatically, with no list to edit by hand.
Catalog
Add a team entry under billingCatalogDeclaration.plans, alongside pro and ultra. Each price names its server-only stripePriceEnv and/or polarProductEnv. Pick a trialDays per price (7 days, 14 days, or 0 to disable). Optionally set recommended: true on Team and remove it from Pro:
team: {
name: "Team",
description: "For growing teams",
recommended: true,
prices: [
{
interval: "month", amount: 99, currency: "USD", trialDays: 7,
stripePriceEnv: "STRIPE_PRICE_ID_TEAM_MONTHLY",
polarProductEnv: "POLAR_PRODUCT_ID_TEAM_MONTHLY",
},
{
interval: "year", amount: 990, currency: "USD", trialDays: 7,
stripePriceEnv: "STRIPE_PRICE_ID_TEAM_YEARLY",
polarProductEnv: "POLAR_PRODUCT_ID_TEAM_YEARLY",
},
],
entitlements: {
features: createBillingFeatureFlags({ billingPortal: true, webSearch: true }),
limits: { maxMembers: 10, monthlyAiResponses: 1000 },
},
},A new feature flag (optional)
If Team unlocks a feature Pro doesn't (or vice versa), add a key to BillingFeatureFlags in packages/payments/src/types.ts, default it false in createBillingFeatureFlags, and set it true on the right plans. Then call assertBillingFeature(billing, "newFeature", "...upgrade copy") wherever it's enforced.
A new flag is deliberately not advertised until you opt it in. This is the "advertise == enforce" guard. To render it as a marketing bullet, in packages/payments/src/catalog/feature-bullets.ts add the key to ENFORCED_FEATURE_KEYS and ADVERTISABLE_FEATURE_KEYS, add its bullet key(s) to FeatureBullet/FeatureBulletKey, and handle it in the flagBullet switch (the compiler will require this once it is in the allowlist). Then add the bullet key(s) to the BillingFeatures namespace in apps/web/messages/en.json + de.json.
A new limit (optional)
If Team has a different ceiling on something the kit doesn't already track, add a key to BillingLimitConfig, set it on each plan, and write an assertWithin*Limit helper modeled on assertWithinMemberLimit in packages/api/src/lib/billing.ts.
UI and verification
The dashboard <PlanCard> grid auto-renders the new entry; no data-wiring change is needed. The marketing PricingSection may need a column tweak because the shipped Free, Pro, and Ultra cards already fill the default three-column grid. Run pnpm billing:check-prices to confirm the new references match the catalog.
That's the entire change. The provider holds the truth about money; the catalog file holds the truth about plans; entitlement helpers hold the truth about access. Add a tier in three of those layers and the rest follows.
Usage Billing
The shipped catalog is flat: a base subscription and an included allowance, not metered overage. Shipped Pro and Ultra do not declare a meter, so a default Stripe install does not gain new required secrets and keeps today's hard cap.
A fork opts in on a paid plan:
meter: {
stripeEventNameEnv: "STRIPE_METER_EVENT_NAME_TEAM",
stripePriceEnv: "STRIPE_PRICE_ID_TEAM_METER",
// Optional, when month and year need different metered Prices:
// stripePriceEnvByInterval: { year: "STRIPE_PRICE_ID_TEAM_METER_YEARLY" },
},The setup doctor requires those env vars, plus CRON_SECRET, only when BILLING_PROVIDER=stripe and a meter is declared. Empty values leave the hard cap in place.
Included responses stay monthlyAiResponses, counted by getAiUsageWindow. On this branch that window is the subscription period for an entitled org. The monthly reset inside a yearly invoice is #426 (fix/monthly-ai-quota-window) and is not part of this change. Do not copy that algorithm. Responses past the allowance are overage: quantity 1 each, reported after the model call succeeds, never at reserve time. Stripe invoices those events on the subscription period that contains the event timestamp, so a yearly subscription bills overage on the yearly period even after #426 splits the included cap by month. Included responses are not sent to Stripe.
Stripe is the only provider that reports usage (stripe.billing.meterEvents.create, not legacy usage records). Checkout adds the metered price as a second line item with no quantity. Entitlement selection ignores usage_type === "metered" and never uses items[0]. Overage is allowed only after a webhook has stored hasUsageMeter because that metered price is on the subscription. Until then, and whenever Polar or Creem is active (reportUsage: false), the chat keeps the CONFLICT hard cap. Never unpaid overage.
The retry worker is GET /api/cron/usage-reports (apps/web/vercel.json). Vercel sends Authorization: Bearer ${CRON_SECRET}. If CRON_SECRET is unset the route returns 401. One outbox row per AI usage event; a retry of a reported row does not call Stripe. Stripe's identifier is only unique for about 24 hours — the durable key is that local status. after() may kick a drain and must not be treated as the retry mechanism. The chat stream does not wait on Stripe. Refunds of usage that was already reported do not send a negative meter event. Polar and Creem do not report usage.
Where To Go Next
Webhooks And Async Workflows
The full webhook story for Stripe, Polar, and Creem: signature verification, two-layer idempotency, stale processing recovery.
Going To Production
Switch from Stripe test mode to live mode, or from the Polar sandbox to production, safely.
Upgrading To V1.1
The additive migration, BILLING_PROVIDER, and how to merge the provider boundary into a customized fork.
Organizations
Where assertWithinMemberLimit lives in the day-to-day flow: invitations, roles, seat checks.
API
How withPermission(billing:[manage]) is wired and where to add your own gated procedures.