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
| Stripe | Polar | RevenueCat | |
|---|---|---|---|
| Best for | Production apps, enterprise | Indie projects, MVPs | Mobile (iOS/Android) |
| Complexity | Higher — full API control | Lower — hosted checkout | Medium — SDK-based |
| Checkout | Checkout Sessions (your domain) | Hosted by Polar | Native App Store / Play Store |
| Webhooks | stripe-signature verification | HMAC signature verification | Bearer token auth |
| Tax/VAT | You manage (or Stripe Tax) | Handled by Polar | Handled by Apple/Google |
| Customer portal | Built-in (manage subscriptions) | Polar dashboard | App Store subscriptions |
| When used | Web 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.
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
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();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:
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:
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:
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:
- User purchases on iOS/Android via RevenueCat paywall
- RevenueCat sends a webhook to
POST /webhooks/revenuecaton your API - The webhook handler verifies the Bearer token and extracts the
userIdfrom the event - Your
userBillingtable is updated with the new subscription status - The
usePremium()hook on mobile updates viaaddCustomerInfoUpdateListener
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:
# 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/stripeThe CLI outputs a webhook signing secret (whsec_...) — use it as STRIPE_WEBHOOK_SECRET in apps/api/.env.
stripe trigger checkout.session.completedPolar doesn't have a local CLI, but you can test webhooks using their dashboard:
- In dashboard.polar.sh, go to Settings > Webhooks
- Use a tunnel like
ngrokorcloudflaredto expose your local API - Add the tunnel URL as webhook endpoint:
https://your-tunnel.ngrok.io/webhooks/polar - 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:
coldstart add billing --provider polar --model subscriptionThis 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.