Every AI SaaS eventually builds the same thing: a credits system. Users buy or earn credits, features consume them, and somewhere in between lives a surprising amount of distributed-systems engineering. Get it wrong and you either leak money (credits spent twice, refunded but not revoked) or enrage users (credits deducted for failed generations). Here's the architecture we use in production AI products at PolarSoftBD.
Why credits beat per-request billing for AI products
- Cash flow — customers prepay; you're never financing their OpenAI bill
- Psychology — "80 credits left" feels like a game; "$3.42 accrued" feels like a taxi meter
- Cost abstraction — one credit price survives model/provider changes; your margins are a config value, not a repricing exercise
- Abuse control — free-tier credits cap your exposure to signup farming
The tradeoff: you now own a ledger, and ledgers demand correctness.
Rule #1: credits are a ledger, not a counter
The naive implementation — UPDATE users SET credits = credits - 5 — fails in four ways: no audit trail, no idempotency, race conditions under concurrency, and no way to answer "why is my balance 12?" support tickets.
Model credits as an append-only transaction ledger:
CREATE TABLE credit_transactions (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL,
amount INTEGER NOT NULL, -- positive = grant, negative = spend
kind TEXT NOT NULL, -- 'purchase' | 'subscription_grant' | 'spend' | 'refund' | 'expiry'
idempotency_key TEXT UNIQUE, -- request id / stripe event id
reference TEXT, -- generation id, invoice id...
created_at TIMESTAMPTZ DEFAULT now()
);
Balance is SUM(amount) — cache it on the user row for reads, but the ledger is the truth. Every support question, refund dispute, and abuse investigation becomes a query instead of an archaeology dig.
Rule #2: reserve → execute → settle
The subtle killer in AI products: the expensive operation can fail after you've charged for it. Deduct before the LLM call and users pay for timeouts; deduct after and users with 1 credit run 50 parallel jobs.
The fix is a hold pattern, same as hotels authorizing your card:
// 1. RESERVE — atomic check-and-hold
const hold = await db.$transaction(async (tx) => {
const balance = await getBalance(tx, userId); // includes existing holds
if (balance < cost) throw new InsufficientCredits();
return tx.creditHold.create({
data: { userId, amount: cost, status: "pending", expiresAt: addMinutes(new Date(), 10) },
});
});
// 2. EXECUTE the AI work
try {
const result = await generateImage(prompt);
// 3a. SETTLE — convert hold to a real spend
await settleHold(hold.id, { reference: result.id });
return result;
} catch (err) {
// 3b. RELEASE — user pays nothing for failures
await releaseHold(hold.id);
throw err;
}
Expired holds get swept by a cron job (crashes happen between execute and settle). Users never pay for your errors, and can't overdraft with parallel requests.
Rule #3: sell credits through Stripe idempotently
Credit purchases arrive via checkout.session.completed webhooks — which Stripe retries. The Stripe event ID is your natural idempotency key:
case "checkout.session.completed": {
await db.creditTransaction.upsert({
where: { idempotencyKey: event.id }, // retry-safe: second delivery no-ops
update: {},
create: {
userId: session.metadata.user_id,
amount: Number(session.metadata.credits),
kind: "purchase",
idempotencyKey: event.id,
reference: session.id,
},
});
break;
}
For subscription plans that grant monthly credits, hang the grant on invoice.paid (not subscription_updated) keyed on the invoice ID — one grant per successful payment, exactly.
Pricing credits: work backwards from cost
Compute your worst-case unit cost per operation (model tokens in + out at list price), then price a credit so your cheapest bundle keeps ~70%+ gross margin on the most expensive operation. Two practical rules:
- Make operations cost different credit amounts (1 credit per chat message, 5 per image, 20 per video) rather than repricing credits per model
- Expire subscription-granted credits (30–90 days) but never purchased ones — expiring paid credits is a chargeback magnet and illegal in some jurisdictions
Don't forget the boring surface area
Refund handling that claws back granted credits (floor at zero, log the negative), an admin panel to grant goodwill credits, per-user rate limits independent of balance, and a usage page that itemizes every deduction. That last one cuts "where did my credits go" tickets by an order of magnitude.
We've built this before — twice is a pattern, five times is a product
Credits systems, Stripe billing, holds, ledgers, and the AI integration behind them are exactly the systems PolarSoftBD ships for clients — fixed price, full code ownership.
- SaaS development service → — full AI SaaS builds including billing and credits
- Stripe integration service → — billing systems from $250 fixed price
- Book a free 30-minute call →
Related: Usage-Based Billing with Stripe · Multi-Tenant SaaS with Supabase RLS