---
title: "How to build a credit system for an AI SaaS"
description: "The pieces a homegrown credit ledger actually needs — races, failed jobs, expiry, idempotency — and when to stop building it. Keep Stripe for payments."
---

# How to build a credit system for an AI SaaS

Updated August 21, 2026

People type this into ChatGPT and then write `users.credits -= 1`. That line is the first bug. This page is the list of bugs after it — races on the last credit, failed generations, packs with expiry, Stripe webhooks that double-grant — and the point at which you should stop building a billing product.

Keep Stripe for the charge. A credit system is a ledger, not a Checkout session.

## A credit balance is not `users.credits -= 1`

Two Generate clicks. The customer has one credit. Both requests read `credits = 1`, both pass the check, both decrement. You either go negative or you served two jobs you cannot collect for.

The first fix is `SELECT … FOR UPDATE` on the row, or a Redis lock around the read-and-write. That serializes two *synchronous* decrements. It is not enough once the job is async.

- If you decrement under the lock, enqueue the job, and drop the lock, a second request can spend the same credit while the first job is still running.
- If you hold the lock until the worker finishes, you serialize every generation for that user. A stuck worker holds the lock until you invent a timeout — at which point you have started building a reservation, badly.
- A Redis lock with a TTL is a homemade hold without commit-of-actual-cost or release-on-failure.

The last credit is a concurrency problem. Locks on a request are not a concurrency primitive for a job queue.

## Failed and long AI jobs

Cost is not known up front. A 200-token call and a 20,000-token call are not the same debit. The job can fail at second 40. Three patterns show up:

- **Deduct, then refund on failure.** A crash between deduct and refund charges the user for nothing. A retry of the refund can double-refund.
- **Run the job, then deduct.** Two jobs start against the last credit. Both succeed. You overdrew, or you eat the second call.
- **Hold → run → commit actual cost, or release.** Hold an estimate before the expensive call. On success, commit what it actually cost. On failure, release the hold. Give the hold a TTL so a dead worker unlocks the wallet.

That third one is a [reservation](/docs/concepts/reservations.md): `active` → `committed` / `released` / `expired`. See also the [AI generation use case](/docs/use-cases/ai-generation-app.md) for the same lifecycle on a long job.

## Credit blocks, not one integer

A production balance is not one number. It is a stack of blocks:

- a signup grant
- a weekly pack with an expiry
- a paid wallet top-up
- a promo that should burn first

Burn order has to be deterministic: lowest priority, then soonest expiry, then free before paid, then FIFO. If you merge packs into one integer you cannot expire the weekly pack without inventing a second column and a cron. If two blocks have the same priority, soonest-expiry first is what stops a user losing a pack they paid for.

QuotaStack stores each grant as its own block on an append-only ledger. The rules: [credits](/docs/concepts/credits.md), [pack stacking](/docs/cookbook/pack-stacking.md).

## Stripe is the charge, not the ledger

Stripe Checkout takes the card. Stripe Billing Meters aggregate usage onto an invoice. That is the charge. It is not a credit system.

Meters do not reserve credits for a job that might fail. They do not expire a weekly pack. They do not answer `can()` on the request path before you call the model.

Stripe also limits a subscription to [20 products](https://docs.stripe.com/billing/subscriptions/quantities). Each Billing Meter is attached through its own price, and each price consumes one of those slots. For multi-model AI — a few models × input / output / cached tokens — you hit the cap. [A 2026 write-up on HN](https://news.ycombinator.com/item?id=47180267) is the usual citation. Do not treat Stripe as the place you keep 30 meters.

Webhook retries will fire `checkout.session.completed` more than once. If the grant is not keyed on the payment or session id, you double-grant. [Idempotency](/docs/concepts/idempotency.md) is the rule: one payment id, one grant.

## Idempotency and the request path

Every grant, reserve, commit, release, and usage event needs a deterministic key. `user:job:attempt` or the PSP payment id — something you can send again after a timeout without creating a second row.

`can()` (an entitlement check) belongs *before* the expensive call. If the check errors, fail closed: do not run the model. A failed check that you treat as "yes" is how you give away inference.

Retries of the same key must be no-ops. That is the only way a worker crash plus a restart does not double-charge.

## You can build all of this

It is 3–6 months of a billing product, not a weekend. A production ledger is at least:

- append-only ledger entries (every grant, debit, hold, release)
- credit blocks with priority and expiry
- reservations with hold / commit / release and a TTL
- metering rules in config, not in `if model == "opus"`
- entitlements (`can()`) on the request path
- webhooks for `low_balance`, `expired`, `exhausted`

QuotaStack is that API. Keep Stripe (or any PSP) for payments — we do not move money and we are not a merchant of record. Point Cursor at [https://quotastack.io/llms.txt](https://quotastack.io/llms.txt). Seven calls from zero to a working flow: [quickstart](/docs/quickstart.md). Sign up: https://platform.quotastack.io/signup. Free until you're billing real money: $2 usage credit/mo (~100k events), then $2 / 100k. No plan to pick.

## What to use instead

- Payments: Stripe (or Paddle / Lemon Squeezy / Dodo). [QuotaStack vs Stripe Billing](/compare/quotastack-vs-stripe-billing.md).
- Closest credit-native alternative: [QuotaStack vs Credyt](/compare/quotastack-vs-credyt.md).

## FAQ

- **How do I stop two requests spending the last credit?** A row lock or a Redis lock only covers the request. For async jobs, hold the last credit (a reservation) so the second request sees it as spent. See [reservations](/docs/concepts/reservations.md).
- **Should I deduct credits before or after the AI call?** Neither. Hold an estimate, run the call, commit the actual cost or release the hold. Deduct-then-refund and deduct-after both race.
- **How do credit packs with expiry work next to a wallet?** Keep them as separate blocks. Burn lowest priority, then soonest expiry, then free before paid. Do not merge into one integer. [Credits](/docs/concepts/credits.md), [pack stacking](/docs/cookbook/pack-stacking.md).
- **Can I use Stripe Billing Meters as my credit system?** No. Meters invoice usage. They do not reserve, expire a pack, or `can()` before the model call. Keep Stripe for the charge. A subscription is also capped at [20 products](https://docs.stripe.com/billing/subscriptions/quantities).
- **How do I refund a failed generation without a race?** Do not deduct-and-refund. Release the reservation. The hold never became a debit, so there is nothing to unwind. [Reservations](/docs/concepts/reservations.md).
- **How long does it take to build this vs use QuotaStack?** The ledger above is months. QuotaStack is the API; the [quickstart](/docs/quickstart.md) is seven calls. $2 usage credit/mo (~100k events), then $2 / 100k.

Start free — $2 usage credit/mo (~100k events), then $2 / 100k. No card: https://platform.quotastack.io/signup
