Skip to content
Build With SyntaxKit

Billing

Org-scoped Stripe subscriptions with hosted Checkout and Customer Portal. One catalog file defines tiers, prices, features, and limits. Gate access from oRPC with BillingState phases and entitlements.

Last updated on

13 min read

On This Page

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.

Two halves:

  1. Sync. The dashboard calls billing.createCheckout. The user pays on Stripe-hosted Checkout. On return, a query param drives a success/cancel toast.
  2. Async. Stripe webhooks report what actually happened. processWebhookEvent claims the event id (retries are no-ops), upserts Subscription, syncs Organization.currentSubscriptionId, and sends email/analytics through OutboundEffect (each side effect at most once). The next getBillingState read resolves to entitled.

Subscriptions are org-scoped, not user-scoped. Card details never touch your domain. Polar and LemonSqueezy adapters are on the roadmap; BillingState and entitlements are already provider-agnostic.

Package Layout

catalog/declaration.tsbillingCatalogDeclaration: source of truth for 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
Stripe-hosted checkoutcreateCheckoutSession returns a hosted URL
Configurable trialprices[].trialDays per plan price (default 7)
Stripe Customer PortalcreatePortalSession for plan, payment method, invoices
Cancel + resume in-appcancelSubscription / resumeSubscription toggle cancel_at_period_end
Monthly + yearly intervalsEach plan declares any subset of prices; 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", ...) on protected actions
Per-plan limitsmaxMembers, monthlyAiResponses; add your own in BillingLimitConfig
Lifecycle emailsWelcome, payment failed/succeeded, action required, trial ending, canceled
Dashboard-driven payment methodsCheckout does not pin payment_method_types; enable methods in Stripe Settings
Promotion codesallow_promotion_codes: true on every session
Opt-in Stripe TaxSTRIPE_AUTOMATIC_TAX=true enables tax, tax ID collection, and address write-back
Optional baseline / no free tierDeclare a baseline, or omit it for a hard paywall (ADR 0002)
Optional live-Stripe testspnpm test:stripe runs *.live.test.ts against test-mode keys

Pricing Plans And Catalog

One TypeScript object in packages/payments/src/catalog/declaration.ts, built with defineBillingCatalog. Two parts (ADR 0002):

  • baseline (optional). What an org with no active subscription gets.
  • plans. Purchasable tiers.

Each price points at a server-only env var (stripePriceEnv) that holds the Stripe price ID. The ID is never written in the catalog file or shipped to the client.

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 },
      },
    },
  },
});

Plans have no features: string[]. Marketing and dashboard bullets are a projection of entitlements (packages/payments/src/catalog/feature-bullets.ts), localized under BillingFeatures in apps/web/messages. Advertise equals enforce: a plan can only show what the server gates. See ADR 0003.

Dropping the free tier (paid-only)

Two shapes:

// (a) Locked floor: orgs can sign in, but everything stays 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 `paywalled`
// and the dashboard sends them to billing until they subscribe.
defineBillingCatalog({
  plans: { pro: { /* ... */ } },
});

For trial-only, omit (or lock) the baseline and set trialDays on every paid plan. New orgs must start a trial via checkout.

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 amount values are display-only. Stripe charges the linked Price. The catalog amount is for marketing and the dashboard grid only.

Knobs in the kit

Change the catalog file, restart, and marketing, dashboard, checkout, and entitlement checks all update.

KnobWhereExamples
Trial length per priceplans.<plan>.prices[].trialDays0, 7 (default), 14, 30
Pricing intervalsplans.<plan>.prices[]Drop yearly, add quarterly, year-only
Number of tierskeys of plansAdd team, enterprise
Plan name + taglineplans.<plan>.{ name, description }Card copy
Feature bulletsDerived from entitlements (feature-bullets.ts); copy in BillingFeaturesEdit flags/limits to change which bullets appear; edit messages for wording
Recommended badgeplans.<plan>.recommended: trueHighlight one tier
Baseline (free / locked floor)baselineEdit limits, rename Free, set visibleAsPlan: false, or omit for a paywall
Per-plan feature flagsplans.<plan>.entitlements.featuresmultiModelAccess, webSearch, billingPortal, your keys
Per-plan limitsplans.<plan>.entitlements.limitsmaxMembers, monthlyAiResponses; add your own
Default AI modelCHAT_DEFAULT_MODEL_ID in packages/shared/src/schemas/chat.tsModel free users get
Display amountsplans.<plan>.prices[].amountMarketing only; Stripe charges the Price

Paths above are under billingCatalogDeclaration.

Knobs in the Stripe Dashboard

No code changes. These live in your Stripe account.

KnobWhere in Stripe
Payment methods (Apple Pay, Link, Cash App, SEPA, iDEAL, …)Settings → Payment methods. The kit does not pin payment_method_types.
CurrenciesOn each Price
Tax / VAT collectionSettings → Tax, then set STRIPE_AUTOMATIC_TAX=true (see Payment Methods And Tax)
Invoice brandingSettings → Branding
Customer-facing email templatesSettings → Customer emails
Webhook event subscriptionsDevelopers → Webhooks

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. Yearly checkout charges 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 env var (STRIPE_PRICE_ID_PRO_QUARTERLY) at it via stripePriceEnv. Keep interval: "year" for the display toggle if you want; Stripe still charges every three months (interval_count: 3). The dashboard toggle follows the declared intervals.

Catalog and Stripe must stay aligned. Run pnpm billing:check-prices (also in the stripe-live CI job). It compares each declared paid price to the live Stripe Price and fails on amount/currency drift, archived Prices, or missing Prices. Interval mismatches (for example quarterly billed as yearly) warn by default; set BILLING_PRICE_SYNC_STRICT_INTERVAL=1 to fail. This is test/script-only, not request-path. Runtime drift.ts is separate: it flags webhook price IDs that are not in the catalog.

Subscription Phases

resolveBillingState turns the persisted Subscription row into a BillingPhase. That phase answers three questions: does the org have access, is billing blocked, and what should the UI show?

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.
PhaseWhenAccessBlocking?UI
freeNo Subscription row, catalog has a baselineBaseline entitlementsNoBaseline limits and flags
paywalledNo Subscription row, catalog has no baselineLocked floor (all off, limits 0)No (fails closed)Dashboard routes to billing
entitledStatus active or trialing, price in catalogPaid planNoFull access; Current Plan badge
grace_periodStatus past_due, price in catalogPaid plan still onNoPaid features keep working; recover-payment banner
recoverableStatus unpaid / incomplete / paused (slot occupied)Baseline floorNoRecover payment in Stripe; no in-app checkout/portal until resolved
lapsedStatus canceled / incomplete_expired (slot free)Baseline floorNoReactivate CTA starts a new checkout
configuration_errorActive or past_due, but price missing from the catalogBaseline floorYesBilling Attention Needed; checkout and feature gates throw CONFLICT

Baseline floor means: the declared baseline if present, otherwise the synthetic locked floor (everything off, limits 0).

Remember two rules:

  • grace_period keeps paid access. The first failed charge does not downgrade; Stripe drives recovery and the kit reflects it.
  • configuration_error is the only blocking phase. Every other phase decides free vs paid and lets the request through.

Checkout And Portal

First-time Stripe setup (account, products, env vars, webhook, Stripe CLI) lives on Setup: Billing (Stripe). When pnpm setup:doctor reports Stripe billing: OK, come back here. For test-to-live cutover, see Going To Production: Pricing And Stripe Live Mode.

Two procedures cover the billing page. Both require withPermission(billing: ["manage"]): members can read state; only admins start checkout or open the portal.

billing.createCheckout

  1. Client sends { planSlug, interval } (never a Stripe price ID).
  2. assertBillingEnabled() throws BAD_REQUEST if billing is not configured.
  3. getCurrentOrganizationBilling(headers) resolves the active org and BillingState.
  4. getCheckoutConflict(billing) checks eligibility from phase (not raw Stripe status).
  5. Reject with CONFLICT when the result is configuration_error, active (already subscribed; use the portal), or recover (fix the existing subscription first). null means proceed.
  6. resolveForward({ planSlug, interval }) maps to the server-only price ID and trial (BAD_REQUEST on unknown plan/interval).
  7. getOrCreateCustomer(orgId) ensures Organization.stripeCustomerId.
  8. createCheckoutSession(...) returns the hosted Checkout URL.

The dashboard does window.location.assign(url). Success returns to getCheckoutSuccessUrl() (?success=true); cancel returns to getCheckoutCancelUrl() (?canceled=true). The page reads those params for toasts.

billing.createPortal

  1. assertBillingEnabled().
  2. canOpenBillingPortal(billing) is true only when phase is entitled or grace_period and the plan has billingPortal: true.
  3. createPortalSession({ customerId, returnUrl }) returns the hosted portal URL.

The portal covers plan changes, payment method, invoices, and cancel. The kit also exposes in-app cancel/resume (billing.cancel, billing.resume) that toggle cancel_at_period_end without leaving the dashboard.

The dashboard billing page

/dashboard/billing has four blocks:

BlockWhat it does
<CurrentPlanCard>Plan badge, status, trial-end line, cancel/resume. Shows Billing Attention Needed when hasBlockingBillingIssue is true, and a recover-payment line for grace_period.
Monthly / yearly toggleSets which interval checkout receives; the server picks the matching price.
<PlanCard> gridOne column per plan: features, recommended badge, CTA. New subscribers go to createCheckout; existing subscribers go to createPortal.
Manage in StripeAlways opens createPortal.

Marketing pricing (PricingSection) reads the same catalog but shows monthly amounts and sends CTAs to signup. Checkout needs an active org.

Payment Methods And Tax

Payment methods are Dashboard-driven

createCheckoutSession does not set payment_method_types. Stripe uses Settings → Payment methods and renders what you enable. Cards are on by default; toggle Apple Pay, Google Pay, Link, Cash App, SEPA, iDEAL, Bancontact, and others without redeploying. Pinning payment_method_types: ["card"] in code suppresses wallets, so the kit leaves the list unset.

Tax is opt-in via one env var

Stripe Tax covers VAT, GST, and US sales tax. It is off by default: enabling it without a Stripe Tax registration makes Checkout fail. Two steps:

Activate Stripe Tax in the Dashboard

Go to Settings → Tax, activate Stripe Tax, and add at least one tax registration for a jurisdiction where you have a tax obligation. Stripe only computes tax where you are registered.

Set the env var

Set STRIPE_AUTOMATIC_TAX="true" and redeploy. Checkout then enables automatic_tax, collects a billing address, lets B2B buyers enter a VAT/tax ID (tax_id_collection), and writes address + name back to the Stripe customer so renewals compute tax without asking again.

When the flag is unset, Checkout may still collect an address (billing_address_collection: "auto") but does not enforce one or run tax. If billing is off (isBillingEnabled is false), none of this applies.

The Webhook Path

Deep dive: Webhooks And Async Workflows: The Stripe Webhook. Below is the billing dispatch table.

EventPersistsSide effects
checkout.session.completedOrganization.stripeCustomerId (when missing)None
customer.subscription.createdSubscription upsert + Organization.currentSubscriptionIdWelcome email + subscription_started analytics
customer.subscription.updatedSubscription fields (status, periods, cancel flag, trial, plan)None (UI updates on next read)
customer.subscription.deletedSubscription.status = "canceled" + sync org pointerCancellation email
customer.subscription.trial_will_endNoneTrial-ending email
invoice.payment_action_requiredNoneAction-required email with hosted invoice URL
invoice.payment_failedNone (status arrives via subscription.updated)Payment-failed email
invoice.payment_succeededNoneReceipt email
invoice.finalizedNoneLog only

Idempotency has two layers:

  1. StripeWebhookEvent.eventId is claimed before any handler runs. Re-deliveries are no-ops.
  2. Each email/analytics dispatch claims (kind, key) in OutboundEffect, so the same business action cannot fire twice.

See the linked Webhooks page for failure modes.

Entitlements And Gating

Read the state

Inside an oRPC procedure:

import { getCurrentOrganizationBilling } from "@/lib/billing";

const { organization, billing } = await getCurrentOrganizationBilling(headers);

Outside a procedure (scripts, non-procedure server actions), call getBillingState(orgId) from @syntaxkit/payments/server.

Gate a feature

import { assertBillingFeature } from "@/lib/billing";

assertBillingFeature(
  billing,
  "webSearch",
  "Upgrade to Pro to enable web search."
);

Throws CONFLICT on a blocking billing issue (catalog mismatch) and FORBIDDEN when the feature is off the plan. The optional third argument is the upgrade copy shown to the user.

Gate a limit

For caps on side-effecting work (AI response, billable API call), use a reservation. It counts the active window and inserts the usage row in one advisory-locked prisma.$transaction, so two concurrent callers cannot both pass a stale under-cap check.

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: free the monthly slot for the next request.
  await prisma.aiUsageEvent
    .delete({ where: { id: usage.id } })
    .catch(() => {});
  throw error;
}

For checks that do not need atomicity (Upgrade banner, member-invite form), use 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 helpers with the same shape.

Surface upsells

The thrown FORBIDDEN message is the upgrade copy. On the client, toast it from useChat's onError (or any error boundary):

const { sendMessage } = useChat({
  onError: (error) => toast.error(error.message),
  // ...
});

That surfaces "Upgrade to Pro to enable web search." with no extra wiring.

Customizing The Catalog

Worked example: add a Team tier between Free and Pro.

In Stripe

Create a Product named Team with monthly and yearly recurring Prices. Copy both price ids.

Env vars

STRIPE_PRICE_ID_TEAM_MONTHLY="price_..."
STRIPE_PRICE_ID_TEAM_YEARLY="price_..."

Mirror them in apps/web/.env.example. The boot doctor derives required billing env vars from the catalog, so naming these in stripePriceEnv is enough. No hand-maintained list.

Catalog

Add team under billingCatalogDeclaration.plans next to pro. Each price names its stripePriceEnv. Set trialDays per price (7, 14, or 0). Optionally move recommended: true from Pro to Team:

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)

Add a key to BillingFeatureFlags in packages/payments/src/types.ts, default it false in createBillingFeatureFlags, set it true on the right plans, then call assertBillingFeature(billing, "newFeature", "...upgrade copy") where you enforce it.

A new flag is not advertised until you opt it in (advertise equals enforce). To show a marketing bullet: add the key to ENFORCED_FEATURE_KEYS and ADVERTISABLE_FEATURE_KEYS in feature-bullets.ts, extend FeatureBullet / FeatureBulletKey, handle it in the flagBullet switch, and add copy under BillingFeatures in en.json and de.json.

A new limit (optional)

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 picks up the new plan automatically. Marketing PricingSection may need a column tweak for three plans (default grid is lg:grid-cols-3).

Stripe owns money. The catalog owns plans. Entitlement helpers own access. Add the tier in those three places; the rest follows.

Where To Go Next

Also useful: Organizations, API, and Setup: Billing (Stripe).

Was this page helpful?

On this page