← Blog

ai · 2026-10-28 · 7 min

Designing a usage-credit system for an AI app

How SparkWrite AI meters AI usage with a Postgres credit ledger: pre-checking before spending, deducting only on success, and keeping the client optimistic but honest.

Every AI call costs real money. That's the constraint most AI side projects ignore until the invoice arrives the day after they tweet about the launch.

SparkWrite AI is a workspace of AI writing tools — content generation, history, authenticated accounts. The question I had to answer before shipping wasn't "which AI model to use" (that was easy: the Gemini API). It was:

How does an individual app let users generate content with a paid API without giving them an unlimited tap on the money hose?

My answer was a credit system. Not a subscription, not pay-per-call — a ledger. Here's how it was designed and what I learned building it.

Why credits

Credits are the middle ground between flat subscriptions and pay-per-call:

  • A subscription needs billing infra, refunds, plan changes — way too much product for a v1
  • Pay-per-call makes the user hesitate at every click; the product starts to feel like a taxi meter
  • Credits give the product a single number to enforce: can this user afford this generation right now?

And credits make the bigger design decisions fall out naturally:

  • Every feature can price itself. A short summary costs less than a full article
  • Abuse is bounded. A leaked demo account can't drain the Gemini budget past its starting credits
  • Users understand credits instantly. It's how every game and every API platform ever built has trained them to think

So the model: each user has a balance, every generation has a cost, and the two get reconciled on every request. Postgres holds the balance; Drizzle keeps the access to it type-safe; Clerk tells us whose balance it is.

The ledger

The credits are a table in Postgres, shaped like this:

export const credits = pgTable("credits", {
  id: uuid("id").defaultRandom().primaryKey(),
  userId: text("user_id")
    .notNull()
    .unique(),                  // Clerk user id
  balance: integer("balance").notNull().default(0),
  updatedAt: timestamp("updated_at", { withTimezone: true })
    .notNull()
    .defaultNow(),
});

One row per user, one authoritative number per user. No derived state, no "sum of all the movements" back-of-the-envelope queries on the hot path.

The alternative shape is an append-only ledger — every spend and every top-up is its own row, and the user's balance is a SUM() of them. That design is better accounting: every credit movement is provable, auditable, and reconcilable. It's also more work at every step, because you now need to manage aggregations instead of reading a number.

For SparkWrite, I kept the ledger discipline without paying the ledger price, by making a different table carry the audit trail: the generation history. Every successful generation already writes a row with the cost that was charged. That record is the ledger of spends. The credits table just holds the authoritative balance.

Which leads to the rule the whole design hangs on.

Deduct on success, never on request

The tempting implementation is: check the balance, deduct, then call Gemini. It's also wrong.

// ✗ The wrong order
await db.update(credits)
  .set({ balance: sql`${credits.balance} - ${cost}` })
  .where(eq(credits.userId, userId));
 
const result = await generateWithGemini(prompt); // …and if this throws?

If the generation fails — model error, timeout, content filter — you've charged the user for nothing. And a credit system that eats credits on failures eventually produces its own bad publicity: a user screenshots the failed generation and the "you were charged 1 credit" toast in the same frame. Trust doesn't come from the balance being correct most of the time; it comes from the failure modes being fair.

So the atomicity flows the other way:

// The real order: validate → generate → charge → record
const [account] = await db
  .select()
  .from(credits)
  .where(eq(credits.userId, userId));
 
if (account.balance < cost) {
  throw new NoCreditsError();
}
 
const { text, grounding } = await generateWithGemini(prompt);
 
await db.transaction(async (tx) => {
  await tx.insert(generations).values({ userId, tool, prompt, cost });
  await tx.update(credits)
    .set({ balance: sql`${credits.balance} - ${cost}` })
    .where(eq(credits.userId, userId));
});

Validate first, spend the user's credits only after the output exists. And the deduction plus the history write happen in one transaction — those two facts about the request (balance went down, one generation happened) can never disagree. That single guarantee is what makes the credits table trustworthy enough to be one row.

The client is only a mirror

Writes happen on the server, but the balance is displayed right where generations happen — and the update rule is simpler than a state library:

const [balance, setBalance] = useState(0); // seeded when the page loads
 
const handleGenerate = async () => {
  const res = await generate(payload);
  setBalance(res.balance); // server truth wins, always
};

No optimistic arithmetic on the client, no derived countdown drifting out of sync — the server just told us exactly how much remains after charging, so the display shows that.

The one rule that matters more than the code: the client is allowed to show a slightly stale balance for a second, but it's never the accountant. Pre-checks, disabled buttons, and thresholds all read from the last server value; everything that concerns money is decided by the Postgres row and relayed verbatim to the client.

The race I chose to accept

Here's the hole in this design, stated honestly: select the balance, then deduct it is not atomic over the network. Two tabs generating at the same moment can both pass the pre-check at balance 1 and both write a deduction — landing the account at -1.

The textbook fix is row-level locking (SELECT … FOR UPDATE inside the transaction) so the second request waits and re-checks. The practical fix at SparkWrite's scale was to accept it:

  • An overdraft costs one generation of API spend, once, on a double-click
  • Locking the credits table on every generation serializes the exact request path where the slow third-party call (Gemini) happens — that's a queue, not a feature
  • The balance floor is enforced anyway (Math.max(0, …) on display, delete/gate on refill), so the patient attacker finds the door already closed

That's the engineering call I'd defend in a design review: under-locking on a rare, self-limiting race beats over-locking on a hot path. If SparkWrite ever gets concurrent heavy usage, the history table is the roadmap — it knows where the overdraft happened, so pins, per-tool wallets, or idempotency keys can be added where the data says they're needed.

Trust, but reconcile

Deciding to accept a race is fine. Being unable to detect the day it stops being rare is not. So the system carries one cheap tripwire instead of locks:

-- periodic check: does the stored balance still agree with the history?
SELECT c.user_id
FROM credits c
WHERE c.balance <> start_balance
  - (SELECT COALESCE(SUM(cost), 0) FROM generations g WHERE g.user_id = c.user_id);

(Conceptually — the real query adds top-ups and whatever the seed balance is. The shape is what matters: the balance row and the ledger of deductions are expected to agree, and a mismatch is a bug, not a business event.)

Same for the overdraft: balance < 0 is an alert, because that's the race expressing itself — visible, countable, and having a data-driven answer to "has this tradeoff ever actually fired?"

A senior dependency on your own tradeoffs isn't arrogance; it's instrumenting them so you can tell the difference between "rare" and "I stopped looking."

What I'd add next

The v1 deliberately ignores three things; when scale makes them matter, this is the order I'd buy them back:

  1. Idempotency keys — a client retry after a network blip can pay twice for one successful Gemini call. That's a user-visible double charge, so it comes first. One key sent with the request lets the charge side deduplicate retries
  2. Refunds for partial failures — a generation that streams 80% of an article before erroring currently charges full price. A partial-credit refund through the same transaction makes that honest too
  3. Real rate limits as a second layer — credits constrain spend; they don't constrain request rates. A leaked session with a large balance can still burst. Rate limits protect the API; credits protect the invoice. They're not the same layer

Notice the first two share a property: they both exist to keep the charge side provably correct rather than to add features.

The takeaway

A credit system is mostly a promise discipline: the balance is one authoritative Postgres number; nothing is charged before output exists; every charge has an audit row; the tradeoffs that were accepted are instrumented so they can't silently rot; and the client is allowed to be optimistic but never to be the accountant.

None of that required a billing framework. Just Postgres, Drizzle, Clerk for identity, and the restraint to not charge anyone for a failure that wasn't their fault.

The result is live in SparkWrite AI — and if you're building something with a paid API metering problem of your own, the best place to steal ideas from is the generation-history table. Spend the complexity getting that right.

0%