Credit-based pricing moved from a niche pattern to the default billing architecture for AI SaaS between 2024 and 2026, and the reason is structural. Traditional SaaS enjoys huge economies of scale — the marginal cost of adding a seat is near zero. AI features do not. Every inference call costs real money, and seat pricing collapses under heavy users. Credits reprice the product by unit of work rather than unit of user, which aligns revenue with cost. But credits are also a small stored-value system with tax, refund, expiry, and compliance surface area that straight subscriptions do not have. This post covers when credits beat straight pricing, how to design the ledger so it survives audits and partial refunds, and the prepaid-money regulations that can make a clean design illegal in a specific jurisdiction.
When credits are the right answer
Credits are not universally better. They add operational overhead, make revenue recognition more complex, and create a new support surface (balance disputes, expiry complaints, refund edge cases). They earn their keep in four product shapes:
- AI SaaS with inference as the primary cost driver — image generation, transcription, LLM-powered features where heavy users cost 50x light users.
- Marketplaces where the platform takes a fee per action and usage is spiky — ad credits, boost credits, featured-listing credits.
- API products with heterogeneous endpoints — a credit that maps to compute cost smooths the pricing across endpoints that cost 100x different amounts to serve.
- Products with strong prepaid-commit economics — enterprise customers prefer to commit $X and draw down rather than receive variable monthly invoices.
If a product does not fit one of those patterns, a per-seat or flat-rate plan is usually cheaper to operate. The cost of credit infrastructure is real — ledger, reconciliation, finance tooling, customer education — and should not be paid without the revenue case behind it.
The ledger — double-entry or regret it later
The single biggest mistake in credit-system design is treating the balance as a mutable integer on the user record. It seems simple, it works in demos, and it falls apart the first time a refund, a chargeback, or a race condition arrives. The right model is a double-entry ledger: every balance is a materialized sum over an append-only list of entries, each with a source, amount, and reason code. Never decrement a balance — write a negative entry. Never increment — write a positive entry. The current balance is always a SUM query.
| Column | Type | Purpose |
|---|---|---|
| id | uuid | Primary key for the ledger entry |
| user_id / org_id | uuid | Account the entry applies to |
| amount | bigint (credits in smallest unit) | Positive for grants, negative for spend |
| entry_type | enum (grant, spend, refund, expiry, adjustment) | What category of movement this represents |
| source_type / source_id | string / uuid | What caused it — invoice, api_call, promo, support_adjustment |
| idempotency_key | string (unique per account) | Prevents duplicate writes on retries |
| expires_at | timestamp nullable | When this grant expires, if ever |
| metadata | jsonb | Reason, support ticket, model version, anything needed for audit |
| created_at | timestamp | Immutable creation time — never update |
The balance is a VIEW or cached SUM, not a column. Resist the temptation to denormalize it onto the user row without an idempotent recompute path — drift between a cached balance and the ledger will bite within weeks of launch.
Granting credits — idempotency is not optional
Every credit grant should be idempotent on a meaningful key. A Stripe webhook retries. A support tool double-clicks. A background job restarts. Without idempotency, a $100 refund grants $100 in credits the first time and another $100 on the retry. The idempotency key should be deterministic from the source event — for a Stripe invoice, the invoice id plus line id; for a promo, the promo code plus the redeeming user id; for a support adjustment, the ticket id.
// A credit-grant helper that survives retries, double-clicks, and restarts
import { db } from "./db";
interface GrantInput {
userId: string;
amount: number; // positive integer, credits in smallest unit
sourceType: "invoice" | "promo" | "support" | "refund_reversal";
sourceId: string; // invoice id, promo redemption id, ticket id
expiresAt?: Date | null;
metadata?: Record<string, unknown>;
}
export async function grantCredits(input: GrantInput) {
if (input.amount <= 0) throw new Error("Grant amount must be positive");
const idempotencyKey = `${input.sourceType}:${input.sourceId}`;
// Insert with a unique constraint on (user_id, idempotency_key).
// If it exists, fetch and return the existing entry — same result, no double-grant.
try {
const entry = await db.ledger.insert({
userId: input.userId,
amount: input.amount,
entryType: "grant",
sourceType: input.sourceType,
sourceId: input.sourceId,
idempotencyKey,
expiresAt: input.expiresAt ?? null,
metadata: input.metadata ?? {},
});
return { entry, created: true };
} catch (err) {
if (isUniqueViolation(err)) {
const existing = await db.ledger.findByKey(input.userId, idempotencyKey);
return { entry: existing!, created: false };
}
throw err;
}
}
// Spend follows the same pattern with a negative amount and a guard
// that reads current balance inside a transaction.
export async function spendCredits(
userId: string,
amount: number,
sourceId: string,
) {
return db.transaction(async (tx) => {
const balance = await tx.ledger.balanceForUpdate(userId); // SELECT ... FOR UPDATE
if (balance < amount) throw new Error("Insufficient credits");
return tx.ledger.insert({
userId,
amount: -amount,
entryType: "spend",
sourceType: "api_call",
sourceId,
idempotencyKey: `api_call:${sourceId}`,
});
});
}Two details are load-bearing in the spend path. The balance read uses SELECT ... FOR UPDATE inside the transaction, which prevents two parallel spend calls from both passing the balance check and overdrawing the account. And the spend insert uses an idempotency key tied to the underlying action (e.g., the API call id), so a retry of the same call never double-charges.
Expiry — a product decision disguised as a tax rule
Credit expiry policy is the area where product intent and legal reality collide. From a product perspective, expiry drives engagement and caps the company's liability. From a legal perspective, unspent credits can be treated as stored value subject to unclaimed-property rules, prepaid-instrument regulations, or consumer-protection laws. In the US, state-level escheatment laws vary wildly. In the EU, consumer-protection directives limit arbitrary forfeiture. In India, RBI's prepaid payment instrument rules impose hard constraints on non-bank issuers. The safe default for most SaaS: 12-month expiry on promotional grants; no expiry on purchased credits, or 24-month minimum; explicit, disclosed policy at purchase time.
Revenue recognition pitfall: credits sold upfront are deferred revenue until consumed. Recognizing them on purchase is aggressive and will not survive an audit. Build the ledger so 'credits purchased' and 'credits consumed' are separable facts — revenue recognition follows consumption, not collection.
Refunds and chargebacks on credit purchases
A customer buys 1,000 credits for $100, spends 400, then disputes the charge. What happens to the 600 unused credits — and the 400 already consumed? The ledger has to model both outcomes cleanly. The right pattern is a refund_reversal entry that writes a negative grant equal to the refunded amount, ordered to consume unspent credits first. If the refund exceeds the unspent balance, the remaining deficit becomes a negative balance on the account, which is a support flag rather than a hard block — the customer already got value for the consumed portion, and pursuing it as a debt is a legal question, not a software one.
Jurisdictional compliance — the short version
Credit systems can trip prepaid-instrument, money-transmitter, and stored-value regulations depending on how they are designed. The safe design keeps credits narrow — usable only inside the product, non-transferable, non-refundable to cash, denominated in product units rather than currency. That design is generally outside the scope of money-transmitter rules in the US, outside the scope of RBI prepaid instrument rules in India, and outside the scope of EU e-money directives. It is not legal advice, and a product operating at scale should have a lawyer confirm for its specific jurisdictions, but it is the architectural pattern that stays furthest from the regulated lines.
- Non-transferable — credits belong to an account, cannot be gifted, traded, or cashed out.
- Non-refundable to cash — refunds go back to the original payment method, not to a cash balance.
- Narrow utility — credits buy product features, not third-party goods or services.
- Denominated in product units where possible — '1,000 image generations' is cleaner than '$100 of credits' in some jurisdictions.
Operational tooling — the part that decides whether this ships
Ledger design is a fraction of the work. The tools that make a credit system operable in production are usually underestimated until they are absent. A credit-system launch needs at minimum: an admin console that lets support view a user's ledger entries and grant adjustments with a required reason code; a reconciliation report that matches purchased credits to collected payments to recognized revenue; a customer-facing balance and history view that shows every entry with a plain-language reason; and an alert when an account's balance drifts from its computed value, which should be never — if it fires, something is wrong with the ledger itself.
Key takeaways
- Credits earn their keep when cost-per-use varies wildly across users. Otherwise per-seat pricing is cheaper to operate.
- Design the ledger as double-entry and append-only from day one. Balance is a query, not a column.
- Idempotency on grants and spends is not optional — deterministic keys tied to the source event prevent the single most expensive class of bug.
- Expiry is a product decision constrained by prepaid-instrument and unclaimed-property rules. Default to 12 months on promos, longer on purchases.
- Keep credits narrow — non-transferable, non-cashable, product-scoped — to stay outside money-transmitter and e-money regulatory surface.
- Recognize revenue on consumption, not collection. Build the ledger so those are separable facts.