coldstart

Billing

Stripe, Polar, or RevenueCat — 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.

Provider comparison

StripePolarRevenueCat
Best forProduction apps, enterpriseIndie projects, MVPsMobile (iOS/Android)
ComplexityHigher — full API controlLower — hosted checkoutMedium — SDK-based
CheckoutCheckout Sessions (your domain)Hosted by PolarNative App Store / Play Store
Webhooksstripe-signature verificationHMAC signature verificationBearer token auth
Tax/VATYou manage (or Stripe Tax)Handled by PolarHandled by Apple/Google
Customer portalBuilt-in (manage subscriptions)Polar dashboardApp Store subscriptions
When usedWeb billing (selected during init)Web billing (selected during init)Always for mobile, regardless of web provider

Mobile billing always uses RevenueCat regardless of your web provider — Apple and Google require in-app purchases to go through their stores.

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

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();

RevenueCat for mobile (deep dive)

Every mobile project with billing gets a full RevenueCat integration, regardless of your web billing provider.

Setup and initialization

RevenueCat is initialized in your app's root layout. Two functions handle setup:

apps/mobile/lib/purchases.ts
import Purchases from "react-native-purchases";

// Called once on app mount
export const initPurchases = () => {
  Purchases.configure({ apiKey: process.env.EXPO_PUBLIC_REVENUECAT_API_KEY! });
};

// Called after sign-in to link RevenueCat to your Better Auth user
export const loginPurchases = async (userId: string) => {
  await Purchases.logIn(userId);
};

loginPurchases(userId) links the RevenueCat anonymous user to your Better Auth user ID. This ensures purchases made on mobile sync to the correct user in your database via webhooks.

Displaying the paywall

For subscription, freemium, and multi-tier models, Coldstart generates a paywall screen using RevenueCat's native UI:

apps/mobile/app/paywall.tsx
import RevenueCatUI from "react-native-purchases-ui";

const PaywallScreen = () => {
  const handlePresent = async () => {
    const result = await RevenueCatUI.presentPaywallIfNeeded({
      requiredEntitlementIdentifier: "premium",
    });
    if (result !== RevenueCatUI.PAYWALL_RESULT.NOT_PRESENTED) {
      router.back();
    }
  };
  // ...
};

For credits and one-time models, a custom purchase screen is generated instead.

Checking entitlements

The PremiumProvider wraps your app and exposes the usePremium() hook:

apps/mobile/lib/premium.tsx
export const PremiumProvider = ({ children }: { children: ReactNode }) => {
  // Checks Purchases.getCustomerInfo() for active entitlements
  // Listens for real-time updates via addCustomerInfoUpdateListener
  // Skips checks in __DEV__ mode for easier local development
};

// In any component:
const { isPremium, isLoading, refresh } = usePremium();

Webhook flow (mobile to API)

Mobile purchases sync to your database through this flow:

  1. User purchases on iOS/Android via RevenueCat paywall
  2. RevenueCat sends a webhook to POST /webhooks/revenuecat on your API
  3. The webhook handler verifies the Bearer token and extracts the userId from the event
  4. Your userBilling table is updated with the new subscription status
  5. The usePremium() hook on mobile updates via addCustomerInfoUpdateListener

Configure the RevenueCat webhook URL in your RevenueCat dashboard: Project Settings > Webhooks > New Webhook. The URL should be https://your-api.com/webhooks/revenuecat with a Bearer token matching your REVENUECAT_WEBHOOK_SECRET env var.

Testing billing locally

Use the Stripe CLI to forward webhooks to your local API:

Terminal
# Install Stripe CLI (one-time)
brew install stripe/stripe-cli/stripe

# Login to Stripe
stripe login

# Forward webhooks to your local API
stripe listen --forward-to https://api.my-app.localhost/webhooks/stripe

The CLI outputs a webhook signing secret (whsec_...) — use it as STRIPE_WEBHOOK_SECRET in apps/api/.env.

Trigger a test event
stripe trigger checkout.session.completed

Polar doesn't have a local CLI, but you can test webhooks using their dashboard:

  1. In dashboard.polar.sh, go to Settings > Webhooks
  2. Use a tunnel like ngrok or cloudflared to expose your local API
  3. Add the tunnel URL as webhook endpoint: https://your-tunnel.ngrok.io/webhooks/polar
  4. Use Polar's Send Test Event button to verify your handler

RevenueCat has limited local testing options:

  • Use Sandbox mode in RevenueCat dashboard for test purchases
  • On iOS simulator: purchases are skipped in __DEV__ mode by default
  • For webhook testing: use a tunnel (ngrok/cloudflared) and configure the tunnel URL in RevenueCat dashboard
  • RevenueCat dashboard has a Test Webhook button under project webhooks

Switching providers

Billing providers are not hot-swappable. Switching from Stripe to Polar (or vice versa) requires re-running the scaffolder:

Terminal
coldstart add billing --provider polar --model subscription

This regenerates the billing schema (with different customer ID fields), webhook handlers, checkout routes, and client-side code. Existing billing data in your database will need to be migrated manually.

On this page