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.
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...Generated endpoints:
GET /billing/plans— list Stripe pricesPOST /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.
POLAR_ACCESS_TOKEN=...
POLAR_WEBHOOK_SECRET=...Generated endpoints:
GET /billing/plans— list Polar productsGET /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
| Model | What's generated | Middleware |
|---|---|---|
| Freemium | isPremium flag on user billing | requirePremium |
| Subscription | subscriptionStatus tracking | requireSubscription |
| One-time | Single checkout, permanent active status | requirePayment |
| Credits | Balance + transaction log table | requireCredits(n) + deductCredits() |
| Per-seat | maxSeats + auto multi-tenant | checkSeatLimit + requireOrgMember |
| Multi-tier | tier 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:
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:
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:
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:
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:
// 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:
const { isPremium, tier, credits, isLoading } = useBilling();<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:
const { isPremium, isLoading, refresh } = usePremium();