Building an AI Credits System for Your SaaS (Stripe + Next.js)
AI SaaS
Stripe
Credits System
Billing
Next.js

Building an AI Credits System for Your SaaS (Stripe + Next.js)

Credits are a ledger, not a counter. The reserve→execute→settle pattern, idempotent Stripe grants, and pricing math for AI products — from production credit systems we've shipped.

July 9, 20264 min read

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.

Related: Usage-Based Billing with Stripe · Multi-Tenant SaaS with Supabase RLS

Last updated July 13, 2026

Get thoughtful updates

Join our monthly digest for founders and builders.

Work with PolarSoftBD

Need help shipping your next product? Let's talk.

Start a project →