// how to build a credit system

How to build a credit system for an AI SaaS.

Updated August 21, 2026

The pieces a homegrown ledger actually needs — races, failed jobs, expiry, idempotency — and when to stop. Keep Stripe for payments.

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.

// 1

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.

// 2

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: activecommitted / released / expired. See also the AI generation use case for the same lifecycle on a long job.

// 3

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, pack stacking.

// 4

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. 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 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 is the rule: one payment id, one grant.

// 5

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.

// when to stop

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 quotastack.io/llms.txt. Seven calls from zero to a working flow: quickstart. Free until you're billing real money: $2 usage credit/mo (~100k events), then $2 / 100k. No plan to pick.

// instead

What to use instead

// faq

Credit-system 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.

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, pack stacking.

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.

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.

How long does it take to build this vs use QuotaStack?

The ledger on this page is months. QuotaStack is the API; the quickstart is seven calls. $2 usage credit/mo (~100k events), then $2 / 100k.

The ledger is an API. Keep Stripe for the charge.

$2 usage credit/mo (~100k events), then $2 / 100k. No plan to pick. No credit card.