The best billing model for an AI SaaS isn't a subscription. It's credits. Users buy 10 reports, spend 1 per generation, watch the balance count down, and buy more before they run out. Revenue arrives upfront. Churn stops being a monthly event. OpenAI uses this model. Midjourney uses it. Runway uses it. Here's the complete Next.js + Stripe + PostgreSQL implementation — including the atomic credit deduction that prevents users from getting two AI generations for one credit when they double-click Generate.
Why Credit-Based Billing Outperforms Subscriptions for AI SaaS Products
Credit-based billing collects revenue before usage, eliminates monthly churn events, and creates natural upsell moments at peak user intent — making it financially superior to subscriptions for AI tools where per-generation cost is known.
I use this for an AI report generation SaaS where each report costs $14 in optimised API spend. One credit equals one report. Users buy 5 credits for $99, use 1, and see "4 credits remaining" — the exact moment they're most likely to buy again. Credits align price with discrete AI outputs: a report, an ad copy set, a video clip. Subscriptions ask users to commit to monthly value they haven't received. Credits ask them to pay for specific value they're about to use.
Credit-based billing also solves the unit economics problem that kills AI subscriptions. When your AI cost per generation is $14 and you charge $29/month for unlimited access, a power user running 20 reports costs you $280 while paying $29. Credits force high-usage users to pay correctly while low-usage users buy only what they need — no angry cancellation emails from users who used the product twice in a month. Revenue arrives upfront: a user buying 20 credits at $29.99 pays before generating a single report. Cash flow is positive from day one, not deferred to month-end billing cycles.
The upsell trigger is uniquely powerful. "You have 2 credits left — buy 10 more?" appears at the exact moment of highest purchase intent: right before the user runs out. Subscription upsells happen on a billing calendar. Credit upsells happen when the user is mid-workflow and needs the product to keep working. That timing difference materially affects conversion rates on AI tools where generation is the core action.
| Monthly Subscription | Pay-Per-Use | Credit-Based (this guide) | |
|---|---|---|---|
| Revenue timing | End of month | After usage | Before usage — upfront |
| Churn risk | Monthly cancellation window | None | None — unused credits stay |
| Pricing model | Flat access fee | Per API call | Bundled units at a discount |
| Upsell opportunity | Annual plan | None | "2 credits left — buy 10?" |
| High-usage users | Subsidised by others | Correctly charged | Correctly charged |
| Low-usage users | Overpay (frustrated) | Underpay (no lock-in) | Pay for what they need |
| Cash flow | Deferred, monthly | Immediate but small | Immediate, larger batches |
| Industry examples | Netflix, Slack | AWS, Twilio | OpenAI, Midjourney, Runway |
A subscription asks users to commit to monthly value they haven't yet received. A credit purchase asks users to pay for specific value they're about to use. One feels like an obligation; the other feels like a decision. For an AI SaaS where each generation produces a discrete output — a report, an ad copy set, a video — credits map the payment directly to the outcome. That alignment makes pricing feel fair to users and keeps cash flow positive for the business from the first sale.
If usage is continuous and unpredictable — tokens per session rather than discrete generations — the Stripe Meter API guide for AI SaaS metered billing may fit better. Credits work best when each AI action produces a discrete, countable output.
The Credit Ledger Schema: Why You Need Both a Balance and a Transaction Log
Credit billing requires a balance field for fast reads and an append-only CreditTransaction table as the source of truth — the ledger can reconstruct the balance if corrupted, provides a full audit trail, and anchors idempotency via a @unique constraint on stripePaymentIntentId.
The Prisma schema
// prisma/schema.prisma
enum TransactionType {
PURCHASE
CONSUMPTION
REFUND
BONUS
}
model User {
id String @id @default(cuid())
creditBalance Int @default(0) // fast read for UI — NOT the source of truth
creditTransactions CreditTransaction[]
}
model CreditTransaction {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
type TransactionType
amount Int // positive = purchase/refund, negative = consumption
description String
stripePaymentIntentId String? @unique // webhook idempotency — prevents double grants
orderId String? // links CONSUMPTION to AI job
createdAt DateTime @default(now())
@@index([userId, createdAt])
}
Why the append-only ledger matters in production
Three scenarios where the ledger saves you:
- User disputes a charge: the ledger shows timestamp, orderId, and type=CONSUMPTION — proof the credit was used for a specific generation.
- Webhook fires twice: Stripe guarantees at-least-once delivery. The second insert fails on
@unique stripePaymentIntentId— credits granted once. - creditBalance drifts out of sync:
SELECT SUM(amount) FROM CreditTransaction WHERE userId = Xreconstructs the correct balance without touching the User row.
The creditBalance field on User is a denormalised cache for fast UI reads — never treat it as the source of truth. Every credit movement writes to both: update the balance for speed, insert a ledger row for auditability. On a dispute, the ledger wins. On a reconciliation job, sum the ledger and compare to the balance column; if they diverge, the ledger is correct.
The orderId field links CONSUMPTION rows to specific AI jobs. When a user claims "I didn't use that credit," query CreditTransaction WHERE orderId = X AND type = CONSUMPTION and you have timestamp, description, and the generation record. The BONUS transaction type handles promotional credits — admin grants, referral bonuses, compensation for outages — without special-casing the balance update logic.
This schema extends the multi-tenant Prisma + PostgreSQL pattern — the User model is already established there. For per-organisation credit pools, that architecture guide covers team-level credit wallets.
Buying Credits: Stripe Checkout One-Time Payment and Webhook Integration
Implement credit purchases as Stripe Checkout one-time payments with mode: 'payment', pass userId and creditsToAdd as strings in session metadata, then grant credits in checkout.session.completed using the paymentIntentId as an idempotency key.
Why one-time payment, not subscription
mode: 'payment' means a single charge with no recurring logic and no cancellation handling. Users click Buy Credits whenever they need more — there is no billing period. That simplicity is the point. Stripe metadata values must be strings, not numbers — pass creditsToAdd: pkg.credits.toString(), then parseInt(creditsToAdd, 10) in the webhook. Forgetting this causes silent failures where credits parse as NaN.
Create four Products in Stripe Dashboard with one-time Prices matching the package tiers: Starter (5 credits, $9.99), Professional (20, $29.99), Business (50, $59.99), Enterprise (100, $99.99). Store price IDs as environment variables — never hardcode price IDs in application logic. When you adjust pricing, update Stripe and env vars without touching generation code.
// ── Section A: app/api/credits/checkout/route.ts ──
import Stripe from 'stripe';
import { auth } from '@/lib/auth';
import { NextResponse } from 'next/server';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-03-31.basil',
});
const CREDIT_PACKAGES = {
starter: { credits: 5, priceId: process.env.STRIPE_PRICE_STARTER! },
professional: { credits: 20, priceId: process.env.STRIPE_PRICE_PRO! },
business: { credits: 50, priceId: process.env.STRIPE_PRICE_BUSINESS! },
enterprise: { credits: 100, priceId: process.env.STRIPE_PRICE_ENTERPRISE! },
} as const;
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { packageId } = await req.json();
const pkg = CREDIT_PACKAGES[packageId as keyof typeof CREDIT_PACKAGES];
if (!pkg) return NextResponse.json({ error: 'Invalid package' }, { status: 400 });
const checkoutSession = await stripe.checkout.sessions.create({
mode: 'payment', // one-time purchase — NOT subscription
line_items: [{ price: pkg.priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?credits=added`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
metadata: {
userId: session.user.id,
creditsToAdd: pkg.credits.toString(), // Stripe metadata = strings only
packageId,
},
});
return NextResponse.json({ url: checkoutSession.url });
}
// ── Section B: app/api/webhooks/stripe/route.ts ──
import { headers } from 'next/headers';
import { db } from '@/lib/db';
export async function POST(req: Request) {
const body = await req.text();
const sig = headers().get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
if (event.type === 'checkout.session.completed') {
const cs = event.data.object as Stripe.Checkout.Session;
const { userId, creditsToAdd } = cs.metadata!;
const paymentIntentId = cs.payment_intent as string;
await db.$transaction(async (tx) => {
const existing = await tx.creditTransaction.findUnique({
where: { stripePaymentIntentId: paymentIntentId },
});
if (existing) return; // webhook retry — safely ignore
const credits = parseInt(creditsToAdd, 10);
await tx.user.update({
where: { id: userId },
data: { creditBalance: { increment: credits } },
});
await tx.creditTransaction.create({
data: {
userId,
type: 'PURCHASE',
amount: credits,
description: `Purchased ${credits} credits`,
stripePaymentIntentId: paymentIntentId,
},
});
});
}
return NextResponse.json({ received: true });
}
The Atomic Credit Deduction: Solving the Race Condition That Double-Charges Users
The only way to prevent concurrent requests from both seeing sufficient credits and both succeeding is a PostgreSQL row-level lock using SELECT FOR UPDATE inside a Prisma transaction — the lock forces concurrent requests to queue, so the second request reads the post-deduction balance and stops cleanly.
The race condition: Every credit billing tutorial shows read balance → check → deduct. Under concurrent load: Request A reads creditBalance = 1 → proceeds. Request B reads creditBalance = 1 → proceeds (same millisecond). Request A decrements → 0. Request B decrements → -1. The user got 2 generations for 1 credit. A user opening two browser tabs and clicking Generate simultaneously triggers this on any non-atomic implementation. Fix: SELECT FOR UPDATE acquires a row-level lock. Request B waits until Request A commits, then reads 0 → returns false → no AI call.
// ── Section A: lib/billing/credits.ts — deductCredit ──
import { db } from '@/lib/db';
/**
* Atomically check AND deduct 1 credit using PostgreSQL FOR UPDATE.
* Returns true if credit was deducted, false if insufficient balance.
*/
export async function deductCredit(
userId: string,
orderId: string,
description = 'AI Generation'
): Promise<boolean> {
return db.$transaction(async (tx) => {
const [user] = await tx.$queryRaw<{ creditBalance: number }[]>`
SELECT "creditBalance" FROM "User"
WHERE id = ${userId}
FOR UPDATE
`;
if (!user || user.creditBalance < 1) return false;
await tx.user.update({
where: { id: userId },
data: { creditBalance: { decrement: 1 } },
});
await tx.creditTransaction.create({
data: {
userId, type: 'CONSUMPTION',
amount: -1, description, orderId,
},
});
return true;
});
}
// ── Section B: refundCredit ──
export async function refundCredit(userId: string, orderId: string): Promise<void> {
await db.$transaction(async (tx) => {
await tx.user.update({
where: { id: userId },
data: { creditBalance: { increment: 1 } },
});
await tx.creditTransaction.create({
data: {
userId, type: 'REFUND',
amount: 1,
description: 'Credit refunded: generation failed',
orderId,
},
});
});
}
// ── Section C: app/api/generate/route.ts — middleware wiring ──
import { deductCredit, refundCredit } from '@/lib/billing/credits';
import { auth } from '@/lib/auth';
import { nanoid } from 'nanoid';
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) return new Response('Unauthorized', { status: 401 });
const orderId = nanoid();
const credited = await deductCredit(session.user.id, orderId);
if (!credited) {
return new Response('Insufficient credits', { status: 402 });
}
try {
const result = await runAIGeneration(req);
return Response.json({ result });
} catch (error) {
await refundCredit(session.user.id, orderId);
return new Response('Generation failed — credit refunded', { status: 500 });
}
}
HTTP 402 Payment Required is the semantically correct status for insufficient credits — not 403 or 400. Browsers and API clients understand 402 as "payment needed to proceed." Return it with a JSON body containing the current balance and a link to the pricing page so the frontend can open the Buy Credits modal without parsing error strings.
Call refundCredit() in the catch block when the AI API fails after deduction so users aren't charged for a failed generation. The orderId ties the CONSUMPTION and REFUND ledger rows together — query both to verify the net effect is zero on failed jobs. Never refund outside a transaction; always pair balance increment with a REFUND ledger insert.
For multi-credit tools (2 credits for ad copy, 5 for full campaigns), pass the credit cost as a parameter to deductCredit() and check balance < cost inside the locked transaction. The FOR UPDATE pattern scales to any deduction amount — the lock semantics are identical whether you deduct 1 or 5 credits.
Pricing Credits for an AI SaaS: The Margin Math and Package Psychology
Price each credit at 2–3× the AI generation cost to preserve margin after refunds and promotions — at $14 AI cost per report, a credit priced at $20–25 yields 30–44% gross margin, with four tiers that use anchoring psychology to drive users toward the Professional package.
The margin math
AI cost per generation (optimised): $14. Credit at $20 → $6 gross margin (30%). Credit at $25 → $11 gross margin (44%). Never price below 1.5× AI cost — refunds, support time, and Stripe fees erode margin fast. Minimum viable rule: price credits at 2× AI cost.
The four-package anchoring strategy
- Starter: 5 credits × $9.99 = $2.00/credit — low entry, acquisition
- Professional: 20 credits × $29.99 = $1.50/credit — highest purchase rate
- Business: 50 credits × $59.99 = $1.20/credit — makes Professional look attainable
- Enterprise: 100 credits × $99.99 = $1.00/credit — signals ceiling, best ROI
Business and Enterprise make Professional look cheap by anchoring high. Most users buy Professional on instinct — which has strong margin per sale. The Starter tier exists for acquisition: users who hesitate at $29.99 try $9.99 first, experience the product, and upgrade on their second purchase. Never make Starter too generous — 5 credits at $2.00 each is deliberately the worst per-credit rate to push upgrades.
Variable credits per tool (multi-tool platforms)
On a 10-tool AI content platform, credit costs vary by tool complexity:
- Simple tool (Instagram caption, 1 API call): 1 credit
- Standard tool (ad copy set, ~1,000 tokens): 2 credits
- Premium tool (full campaign, 3 sequential API calls): 5 credits
Store costs in a config object — not hardcoded — so pricing changes don't require redeploying generation routes:
// lib/billing/tool-credit-costs.ts
export const TOOL_CREDIT_COSTS = {
instagram_caption: 1,
facebook_ad_copy: 2,
full_campaign: 5,
} as const;
Pass TOOL_CREDIT_COSTS[toolId] to your deduction function before each generation. When you add a new tool, add one line to the config — the billing system doesn't change.
The Credit Balance UI: Showing the Balance and Triggering Refills
Display the credit balance in the navigation bar, show an inline balance before each generation action, and trigger a Buy Credits modal when balance hits zero — the low-balance state is the highest-intent moment for upsell conversion.
Three UI states
- Balance ≥ 5: green indicator, balance displayed in navbar
- Balance 1–4: amber indicator, "Running low — buy more" nudge visible
- Balance = 0: Generate button disabled, Buy Credits modal opens automatically
The post-purchase UX
Stripe success_url: /dashboard?credits=added. On load, detect the query param and fetch balance server-side — never rely on client cache after purchase. Show toast: "10 credits added to your account." Strip the query param from the URL after displaying the toast so refreshing doesn't re-trigger the success message. The webhook may arrive milliseconds after redirect — if balance hasn't updated yet, poll once after 2 seconds before showing the final count.
Server Component balance display
Read creditBalance directly from PostgreSQL in a Server Component:
// components/CreditBalance.tsx — Server Component
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
export async function CreditBalance() {
const session = await auth();
if (!session?.user?.id) return null;
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { creditBalance: true },
});
const balance = user?.creditBalance ?? 0;
const variant = balance >= 5 ? 'green' : balance >= 1 ? 'amber' : 'red';
return <span className={`credit-badge credit-badge--${variant}`}>
{balance} credits
</span>;
}
Next.js revalidates on navigation after the Stripe redirect — no manual cache invalidation. Never store balance in client state after purchase; always re-fetch from the database. This is blog #50 in the hassanr.com engineering series — the capstone on billing architecture for AI products.
I document production billing patterns on hassanr.com because credit billing is where most AI SaaS products lose money — either through race conditions, webhook double-grants, or pricing credits below AI cost. Hassan Raza built this system for discrete-output AI products where every generation has a known $14 cost and a fair $20–25 price.
Frequently Asked Questions
Credit-based billing lets users buy credit bundles upfront via one-time Stripe payments, then deducts credits when AI features run. A creditBalance on the User record enables fast reads; an append-only CreditTransaction ledger records every PURCHASE, CONSUMPTION, REFUND, and BONUS as the source of truth. If balance corrupts, summing the ledger restores it. Store stripePaymentIntentId as @unique to prevent duplicate grants on webhook retries. OpenAI, Midjourney, and Runway use this model — buy upfront, spend per generation, refill when low.
Two concurrent requests can both read balance = 1, both proceed, and deduct twice — balance hits -1. Fix: wrap check and decrement in prisma.$transaction with SELECT ... FOR UPDATE, which locks the row until commit. The second request waits, then reads balance = 0 and returns false. Second layer: @unique stripePaymentIntentId on CreditTransaction — webhook retries fail the duplicate insert safely, preventing double credit grants.
Four components: a Stripe Checkout route with mode: 'payment' and userId/creditsToAdd as string metadata; a checkout.session.completed webhook that checks paymentIntentId exists before incrementing balance inside $transaction; deductCredit() using SELECT FOR UPDATE to atomically check and decrement; refundCredit() that increments balance and logs REFUND — call in catch when AI fails after deduction.