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

A subscription is born: marketing pricing leads to signup, the dashboard calls billing.createCheckout, the user pays on Stripe-hosted Checkout, Stripe fires webhook events, processWebhookEvent claims and dispatches them, the Subscription row is upserted and the Organization pointer is synced, welcome email and analytics are deduped via OutboundEffect, and the next request sees the entitled phase.

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

catalog/declaration.tsbillingCatalogDeclaration: source of truth, holding an optional baseline plus purchasable plans (tiers, prices, features, limits)
types.tsBillingState, BillingPhase, BillingFeatureFlags, BillingLimitConfig, helpers
entitlements.tsresolvePlan, resolveEntitlements, resolveBillingState, hasBillingFeature
index.tsClient-safe barrel: catalog + entitlement resolution, no Stripe SDK
server.tsServer barrel: adds stripe client, checkout, portal, subscription, webhook

What's Wired In

CapabilityHow it's enabled
Org-scoped subscriptionsOrganization.stripeCustomerId + currentSubscriptionId on the org row
Stripe-hosted checkoutcreateCheckoutSession in stripe/checkout.ts returns a hosted URL
Configurable trialbillingCatalogDeclaration.plans.<plan>.prices[].trialDays; defaults to 7 days, tunable per price
Stripe Customer PortalcreatePortalSession; one button covers plan changes, payment method, invoices
Cancel + resume in-appcancelSubscription and resumeSubscription toggle cancel_at_period_end
Monthly + yearly intervalsEach plan declares any subset of prices; quarterly or other Stripe intervals work too
Idempotent webhookStripeWebhookEvent.eventId claim + OutboundEffect.(kind, key) per dispatch
Seven-phase state machinefree / paywalled / entitled / grace_period / recoverable / lapsed / configuration_error
Per-feature gatesassertBillingFeature(billing, "webSearch", ...) style on every protected action
Per-plan limitsmaxMembers, monthlyAiResponses; add your own in BillingLimitConfig
Lifecycle emailsWelcome, payment failed, payment succeeded, payment action required, trial ending, canceled
Dashboard-driven payment methodsCheckout 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 codesallow_promotion_codes: true on every session so Stripe coupons work without per-call wiring
Opt-in Stripe TaxSTRIPE_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 tierDeclare 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 testspnpm test:stripe runs *.live.test.ts against real test-mode keys

Why These Choices

Stripe logo

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

FreePro
MembersUp to 3Unlimited
AI responses / month100Unlimited
Multi-model accessNoYes
Web searchNoYes
Billing portalN/AYes
TrialN/A7 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

KnobWhereExamples
Trial length per pricebillingCatalogDeclaration.plans.<plan>.prices[].trialDays0 (no trial), 7 (default), 14, 30
Pricing intervalsbillingCatalogDeclaration.plans.<plan>.prices[]Drop yearly, add quarterly, year-only, etc.
Number of tierskeys of billingCatalogDeclaration.plansPro today; add team, enterprise, etc.
Plan name + taglinebillingCatalogDeclaration.plans.<plan>.{ name, description }Marketing copy on cards
Feature bulletsDerived from entitlements (feature-bullets.ts); copy in the BillingFeatures i18n namespaceBullets project the entitlements and are localized; edit limits/flags to change which bullets appear, edit the messages to change wording
Recommended badgebillingCatalogDeclaration.plans.<plan>.recommended: trueHighlights one tier in the grid
Baseline (free / locked floor)billingCatalogDeclaration.baselineEdit limits, rename "Free" to "Starter", set visibleAsPlan: false, or omit for a paywall
Per-plan feature flagsbillingCatalogDeclaration.plans.<plan>.entitlements.featuresmultiModelAccess, webSearch, billingPortal, your own keys
Per-plan limitsbillingCatalogDeclaration.plans.<plan>.entitlements.limitsmaxMembers, monthlyAiResponses; add your own
Default AI modelCHAT_DEFAULT_MODEL_ID in packages/shared/src/schemas/chat.tsWhich model free users get
Display amountsbillingCatalogDeclaration.plans.<plan>.prices[].amountMarketing display only; Stripe charges the actual Price

Knobs in the Stripe Dashboard

These do not require code changes. They live in your Stripe account.

KnobWhere 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.
CurrenciesOn the Price itself (per Price, not global)
Tax / VAT collectionSettings -> Tax in Stripe + set STRIPE_AUTOMATIC_TAX=true (see Payment Methods And Tax)
Invoice branding (logo, colors, footer)Settings -> Branding
Customer-facing email templatesSettings -> Customer emails
Webhook event subscriptionsDevelopers -> 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 transitions: a new org starts at free; checkout completion moves it to entitled; payment past_due moves entitled to grace_period (paid access stays on); paying the invoice returns to entitled; a cancellation that runs out the period moves entitled to lapsed; dunning expiring moves grace_period to lapsed; an unpaid, incomplete, or paused subscription is recoverable; a price not in the catalog moves entitled or grace_period to configuration_error; re-checkout moves lapsed back to entitled.
PhaseWhen assignedHas access?Blocking?What buyers see
freeNo persisted Subscription row and the catalog declares a baselineBaseline entitlements onlyNoBaseline plan limits and feature flags.
paywalledNo 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.
entitledStatus is active or trialing and the price is in the catalogYes (paid plan)NoFull access; "Current Plan" badge.
grace_periodStatus is past_due; price still in catalogYes (paid plan)NoPaid features keep working; banner asks for payment recovery.
recoverableSubscription exists but status is unpaid / incomplete / paused (slot still occupied)Baseline floor onlyNoBaseline behavior; in-app checkout stays blocked, but the billing portal is available so the user can recover the payment in Stripe.
lapsedSubscription is terminal: status is canceled / incomplete_expired (slot free)Baseline floor onlyNoBaseline behavior; "Reactivate" CTA starts a new checkout.
configuration_errorActive or past_due subscription whose price is not in billingCatalogDeclarationBaseline floor onlyYes"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_period keeps paid access. The kit does not abruptly downgrade on the first failed charge; Stripe drives recovery, the kit reflects it.
  • configuration_error is 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 login

This 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 1 — start the app

pnpm dev

The web app comes up on http://localhost:3000.

Terminal 2 — forward webhooks

Leave pnpm dev running and, in a second terminal, start the listener:

stripe listen --forward-to localhost:3000/api/webhooks/stripe

It 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_succeeded

Each 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 numberBehavior
4242 4242 4242 4242Succeeds immediately. The default "happy path" card.
4000 0000 0000 0002Declined (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:

  1. The client sends { planSlug, interval }, never a Stripe price ID.
  2. assertBillingEnabled() throws BAD_REQUEST if billing is not configured in this environment.
  3. getCurrentOrganizationBilling(headers) resolves the active org and BillingState.
  4. Classify eligibility with getCheckoutConflict(billing): one derived check on the resolved phase (no raw status matching).
  5. Reject with CONFLICT when it returns configuration_error (catalog mismatch), active (already subscribed, use the portal), or recover (a non-terminal subscription must be recovered first); null lets checkout proceed.
  6. resolveForward({ planSlug, interval }) resolves the server-only Stripe price ID and trial from the catalog (BAD_REQUEST on an unknown plan/interval).
  7. getOrCreateCustomer(orgId) ensures Organization.stripeCustomerId is set.
  8. 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

  1. assertBillingEnabled().
  2. 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's billingPortal: true), plus recoverable and configuration_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. Only free / paywalled / lapsed and customers with no stored Stripe customer are denied.
  3. 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.

EventPersistsSide effects
checkout.session.completedOrganization.stripeCustomerId (when missing)None
customer.subscription.createdSubscription upsert + Organization.currentSubscriptionIdWelcome email + subscription_started analytics
customer.subscription.updatedSubscription (status, periods, cancelAtPeriodEnd, trialEnd, planSlug)None (status changes drive UI on next read)
customer.subscription.deletedSubscription.status = "canceled" + sync org pointerCancellation email
customer.subscription.trial_will_endNoneTrial-ending email
invoice.payment_action_requiredNone"Action required" email with hosted invoice URL
invoice.payment_failedNone (status arrives via subscription.updated)Payment-failed email
invoice.payment_succeededNoneReceipt email
invoice.finalizedNoneLog 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:

BlockWhat 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 toggleTwo-button switch that drives which interval is sent to checkout (the server resolves the matching price).
<PlanCard> gridOne 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" buttoncreatePortal 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

On this page