coldstart

Billing

Stripe or Polar, 6 models, from database to checkout button

Coldstart generates a complete billing flow. When you select a billing model and provider, every layer gets the right code — database schema, webhook handlers, API routes, middleware, and client-side UI.

Choosing a provider

Full control. Checkout Sessions, Customer Portal, webhook signature verification via constructEvent.

Best for: production apps, enterprise, complex billing logic.

Required environment variables
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...

Generated endpoints:

  • GET /billing/plans — list Stripe prices
  • POST /billing/checkout — create Checkout Session (linked to user)
  • POST /billing/portal — Customer Portal (server-side customer lookup)
  • POST /webhooks/stripe — signature-verified webhook handler

Simpler setup, hosted checkout, no VAT/tax management needed.

Best for: indie projects, MVPs, side projects.

Required environment variables
POLAR_ACCESS_TOKEN=...
POLAR_WEBHOOK_SECRET=...

Generated endpoints:

  • GET /billing/plans — list Polar products
  • GET /billing/checkout — redirect to Polar checkout (with userId metadata)
  • POST /webhooks/polar — HMAC signature-verified webhook handler

Mobile billing always uses RevenueCat regardless of web provider — it handles Apple/Google store payments.

Billing models

ModelWhat's generatedMiddleware
FreemiumisPremium flag on user billingrequirePremium
SubscriptionsubscriptionStatus trackingrequireSubscription
One-timeSingle checkout, permanent active statusrequirePayment
CreditsBalance + transaction log tablerequireCredits(n) + deductCredits()
Per-seatmaxSeats + auto multi-tenantcheckSeatLimit + requireOrgMember
Multi-tiertier field (free/basic/pro/enterprise)requireTier(minTier)

The full flow

Database schema

packages/db/src/billing-schema.ts — a userBilling table with fields adapted to your model:

packages/db/src/billing-schema.ts (subscription + Stripe)
export const userBilling = pgTable("user_billing", {
  id: text("id").primaryKey(),
  userId: text("user_id").references(() => user.id).unique(),
  stripeCustomerId: text("stripe_customer_id"),
  subscriptionId: text("subscription_id"),
  subscriptionStatus: text("subscription_status").default("inactive"),
  createdAt: timestamp("created_at").defaultNow(),
  updatedAt: timestamp("updated_at").defaultNow(),
});

For credits: adds credits integer + a creditTransaction log table. For multi-tier: adds tier enum. For per-seat: adds maxSeats.

Webhooks write to the database

No placeholder comments — real DB operations:

Verifies stripe-signature via constructEvent, extracts userId from subscription metadata, updates userBilling:

apps/api/src/routes/webhooks.ts (excerpt)
case "customer.subscription.created":
case "customer.subscription.updated": {
  const sub = event.data.object as Stripe.Subscription;
  const userId = sub.metadata.userId;
  await db.update(userBilling).set({
    subscriptionStatus: "active",
    updatedAt: new Date(),
  }).where(eq(userBilling.userId, userId));
  break;
}

Verifies HMAC signature, extracts userId from event metadata:

apps/api/src/routes/webhooks.ts (excerpt)
case "subscription.created":
case "subscription.updated": {
  await db.update(userBilling).set({
    subscriptionStatus: "active",
    updatedAt: new Date(),
  }).where(eq(userBilling.userId, userId));
  break;
}

Bearer token auth, handles mobile purchase events:

apps/api/src/routes/webhooks.ts (excerpt)
case "INITIAL_PURCHASE":
case "RENEWAL": {
  await db.update(userBilling).set({
    subscriptionStatus: "active",
    updatedAt: new Date(),
  }).where(eq(userBilling.userId, userId));
  break;
}

Middleware guards your routes

Billing middleware queries the userBilling table — no unsafe type casts:

Protecting routes
// Require active subscription
app.use("/api/premium/*", requireSubscription);

// Require Pro tier or higher
app.use("/api/advanced/*", requireTier("pro"));

// Require 10 credits (and deduct after)
app.use("/api/generate/*", requireCredits(10));

Web: checkout and premium gates

The pricing page has functional buttons that call /billing/checkout and redirect to payment:

apps/web/src/hooks/use-billing.ts
const { isPremium, tier, credits, isLoading } = useBilling();
Gating premium content
<PremiumGate>
  <p>Only visible to paying users.</p>
</PremiumGate>

Success and cancel pages handle post-checkout redirect.

Mobile: RevenueCat paywall

initPurchases() is called on app mount. loginPurchases(userId) links RevenueCat to your Better Auth user. The usePremium() hook checks active entitlements:

apps/mobile/lib/premium.tsx
const { isPremium, isLoading, refresh } = usePremium();

On this page