Billing
Stripe subscriptions, plans, the customer portal, and the entitlement layer.
SyntaxKit ships a complete B2B subscription billing layer on top of Stripe, the most trusted payments platform for SaaS. Subscriptions are scoped to organizations, not users, so a Pro plan is a team-level entitlement. Checkout uses Stripe-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. Polar and LemonSqueezy adapters are on the roadmap; the kit's BillingState and entitlements layer is already provider-agnostic.
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.
How A Subscription Comes To Life
The flow has two halves. The synchronous half (top of the diagram) creates a Stripe-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 Stripe's webhook telling the kit what actually happened. processWebhookEvent claims the event id (so retries don't double-process), upserts the Subscription row, calls syncCurrentOrganizationSubscription to update 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 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
What's Wired In
| Capability | How it's enabled |
|---|---|
| Org-scoped subscriptions | Organization.stripeCustomerId + currentSubscriptionId on the org row |
| Stripe-hosted checkout | createCheckoutSession in stripe/checkout.ts returns a hosted URL |
| Configurable trial | billingCatalogDeclaration.plans.<plan>.prices[].trialDays; defaults to 7 days, tunable per price |
| Stripe Customer Portal | createPortalSession; one button covers plan changes, payment method, invoices |
| Cancel + resume in-app | cancelSubscription and resumeSubscription toggle cancel_at_period_end |
| Monthly + yearly intervals | Each plan declares any subset of prices; quarterly or other Stripe intervals work too |
| Idempotent webhook | StripeWebhookEvent.eventId claim + OutboundEffect.(kind, key) per dispatch |
| 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, payment succeeded, payment action required, trial ending, canceled |
| Dashboard-driven payment methods | Checkout does not pin payment_method_types; cards, wallets (Apple Pay, Google Pay, Link), and regional methods (SEPA, iDEAL, Bancontact, etc.) appear automatically based on Settings -> Payment methods |
| Promotion codes | 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-Stripe tests | pnpm test:stripe runs *.live.test.ts against real test-mode keys |
Why These Choices
Stripe-only at launch
Battle-tested, B2B-native, every operator already knows it. Polar and LemonSqueezy adapters are planned next; the kit's BillingState and entitlements layer is already provider-agnostic, so adding a provider is swapping the Stripe wrapper, not rewriting consumers.
Hosted checkout over a custom page
Stripe absorbs PCI scope, 3DS / SCA, Apple Pay, Link, Cash App, and tax collection. Customers trust the Stripe domain. There is no card form to maintain, audit, or break.
Custom thin layer over the Better Auth Stripe plugin
The Better Auth Stripe plugin is in beta and user-scoped. Billing here is product-critical and org-scoped. A small in-repo wrapper means zero version-lock risk and exactly the entitlements model the kit needs.
Server-only price IDs
Stripe price IDs live in server-only STRIPE_PRICE_ID_* env vars and never reach the client bundle. The browser sends a plan slug + interval to checkout; the server resolves the price ID from the catalog. The marketing page and dashboard render display amounts (no price 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 (stripePriceEnv) that holds its Stripe price ID. The ID itself is 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: "Get started without a credit card",
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 teams shipping AI features",
recommended: true,
prices: [
{
interval: "month",
amount: 29, currency: "USD", trialDays: 7,
stripePriceEnv: "STRIPE_PRICE_ID_PRO_MONTHLY",
},
{
interval: "year",
amount: 290, currency: "USD", trialDays: 7,
stripePriceEnv: "STRIPE_PRICE_ID_PRO_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.
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 | |
|---|---|---|
| Members | Up to 3 | Unlimited |
| AI responses / month | 100 | Unlimited |
| Multi-model access | No | Yes |
| Web search | No | Yes |
| Billing portal | N/A | Yes |
| Trial | N/A | 7 days (per price, configurable) |
Yearly amounts are display-only. Stripe charges whatever the linked Price 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 Stripe Price.
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 |
| Pricing intervals | billingCatalogDeclaration.plans.<plan>.prices[] | Drop yearly, add quarterly, year-only, etc. |
| Number of tiers | keys of billingCatalogDeclaration.plans | Pro 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 | CHAT_DEFAULT_MODEL_ID in packages/shared/src/schemas/chat.ts | Which model free users get |
| Display amounts | billingCatalogDeclaration.plans.<plan>.prices[].amount | Marketing display only; Stripe charges the actual Price |
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 |
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.
Want a Team tier between Free and Pro?
Add a team: entry to billingCatalogDeclaration with its own prices and entitlements. Full walk-through in Customizing The Catalog.
Want quarterly billing instead of yearly?
Create a quarterly recurring Price in Stripe, 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.
Catalog and Stripe can't silently disagree. The catalog stays the source of truth for the displayed amount/currency; to make sure those never drift from what Stripe charges, run pnpm billing:check-prices (also wired into the stripe-live CI job). It compares each declared paid price against its live Stripe Price and fails on amount/currency drift, archived, or missing Prices, handling zero-decimal currencies correctly. Because the "quarterly billed as yearly" pattern above intentionally diverges the displayed interval from Stripe's, an interval difference is an advisory warning unless you set BILLING_PRICE_SYNC_STRICT_INTERVAL=1. 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 IDs.
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 in Stripe. |
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 | Baseline floor only | Yes | "Billing Attention Needed" card; new checkout / feature gates throw CONFLICT. |
The "baseline floor" is the declared baseline when one exists, or the synthetic locked floor (everything off, limits 0) on a hard-paywall catalog.
Two callouts worth burning into memory:
grace_periodkeeps paid access. The kit does not abruptly downgrade on the first failed charge; Stripe 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.
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 Stripe billing: 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: 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.
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. The Setup: Billing (Stripe) page gets you the account, products, and env vars; come back here once pnpm setup:doctor reports Stripe billing: OK.
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. processWebhookEvent 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 processWebhookEvent 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/*.eml) 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.
Keeping the catalog honest
pnpm billing:check-prices compares each paid price you declare in billingCatalogDeclaration against its live Stripe Price and fails on amount/currency drift, archived, or missing Prices. Run it after you create or re-point a test-mode Price (or change a declared amount/currency) to confirm the catalog and Stripe still agree before you click Upgrade — a mismatch is what produces the configuration_error phase. It's a test/script-only guard and never runs on a request; see the catalog callout above for the full drift story.
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.
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.
billing.createCheckout
Phases of the handler:
- The client sends
{ planSlug, interval }, never a Stripe price 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 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 Stripe price ID and trial from the catalog (BAD_REQUESTon an unknown plan/interval).getOrCreateCustomer(orgId)ensuresOrganization.stripeCustomerIdis set.createCheckoutSession({ priceId, customerId, trialDays, successUrl, cancelUrl })returns a Stripe-hosted URL.
The dashboard does window.location.assign(url). After payment, Stripe 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 Stripe customer and the phase can reach the portal:entitled/grace_period(with the plan'sbillingPortal: true), plusrecoverableandconfiguration_error— so a customer whose payment failed or whose plan fell out of the catalog can always get back to Stripe to pay or fix their subscription. Onlyfree/paywalled/lapsedand customers with no stored Stripe customer are denied.createPortalSession({ customerId, returnUrl })returns a Stripe-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 for users who don't want to leave the dashboard.
Trial Abuse Boundary
Trials are first-subscription-only, per organization. createCheckoutSession (packages/payments/src/stripe/checkout.ts) only attaches trial_period_days when the organization has no prior Subscription row — it counts prisma.subscription.count({ where: { organizationId } }) and skips the trial when that is non-zero. Any historical row disqualifies the org regardless of status (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.
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 Pro plan an organization entitlement rather than a user one), not a gap in the checkout guard.
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 createCheckoutSession, 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.
The Webhook Path (Billing-Specific)
The webhook deep dive lives at Webhooks And Async Workflows: The Stripe Webhook. What follows is the billing-specific dispatch table: each row is one Stripe event the kit handles.
| 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: StripeWebhookEvent.eventId is claimed atomically before any handler runs (Stripe 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.
Gate a feature
import { assertBillingFeature } from "@/lib/billing";
assertBillingFeature(
billing,
"webSearch",
"Upgrade to Pro to enable web search."
);assertBillingFeature throws CONFLICT when there's a blocking billing issue (catalog 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 Pro 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. |
| "Manage in Stripe" button | createPortal for the universal entry point, regardless of which plan. |
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 walks through the most-asked extension end-to-end: a new "Team" tier between Free and Pro.
In Stripe
Create a new Product called "Team" and add two recurring Prices: monthly and yearly. Pick the amount you want; you can copy Pro's structure or charge any number you like. Copy both price ids.
Env vars
Add the new price ids to your environment:
STRIPE_PRICE_ID_TEAM_MONTHLY="price_..."
STRIPE_PRICE_ID_TEAM_YEARLY="price_..."Mirror the entries in apps/web/.env.example. The boot doctor derives its required billing env vars from the catalog declaration, 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. Each price names its server-only stripePriceEnv. 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" },
{ interval: "year", amount: 990, currency: "USD", trialDays: 7, stripePriceEnv: "STRIPE_PRICE_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
The dashboard <PlanCard> grid auto-renders the new entry; no UI code change needed. The marketing PricingSection may need a column tweak for three plans (the default grid is lg:grid-cols-3 so two-tier setups already leave room).
That's the entire change. Stripe 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.
Where To Go Next
Webhooks And Async Workflows
The full Stripe webhook story: signature verification, two-layer idempotency, stale processing recovery.
Going To Production
Switch from Stripe test mode to live mode safely: keys, price ids, webhook endpoint, smoke tests.
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.