If you're building an AI app (image generation, transcription, an agent, anything that calls a model) you've probably realized a flat "$10/month" doesn't work. Every action costs you real money in GPU/API spend, so a single power user can torch your margins. The answer is usage credits: users buy a balance, each action spends some.
Credits sound trivial. They are not. I've shipped about 10 small AI/SaaS apps, and the credit layer is where I got burned every single time. It took three patterns to fix it for good. Here they are, with copy-pasteable code for Next.js + Supabase + Stripe. Get these right and your billing won't oversell, double-charge, or strand a user's money.
Let's kill all three.
The mistake is doing the check in your app code:
// DON'T: read-then-write has a race condition
const { balance } = await getBalance(userId);
if (balance < cost) throw new Error("insufficient");
await setBalance(userId, balance - cost); // two concurrent requests both pass the check
Do it in the database, in one statement, with the guard in the WHERE
clause:
-- balances: one row per user
create table credit_balances (
user_id uuid primary key references auth.users(id) on delete cascade,
balance integer not null default 0 check (balance >= 0),
updated_at timestamptz not null default now()
);
-- append-only ledger = audit log + idempotency guard (see Part 2)
create table credit_ledger (
id bigint generated always as identity primary key,
user_id uuid not null references auth.users(id),
delta integer not null,
reason text not null,
idempotency_key text unique, -- the secret weapon
created_at timestamptz not null default now()
);
create or replace function spend_credits(p_user uuid, p_amount int, p_key text)
returns integer language plpgsql security definer set search_path = public as $$
declare new_balance int;
begin
update credit_balances
set balance = balance - p_amount, updated_at = now()
where user_id = p_user and balance >= p_amount -- the guard
returning balance into new_balance;
if new_balance is null then raise exception 'insufficient_credits'; end if;
insert into credit_ledger (user_id, delta, reason, idempotency_key)
values (p_user, -p_amount, 'spend', p_key);
return new_balance;
end $$;
where ... and balance >= p_amount
is the whole trick. Under concurrent requests Postgres takes a row lock, so the second update
waits for the first to commit and re-evaluates the condition against the new balance. The balance can never go negative. No application-level locking, no Redis, no race.
Stripe delivers webhooks at-least-once. Your handler must be safe to run twice with the same event. Key every grant on the Stripe event id and let the ledger's unique
constraint enforce it:
// app/api/stripe/webhook/route.ts
export async function POST(req: Request) {
const body = await req.text(); // raw body, required for signature check
const event = stripe.webhooks.constructEvent(
body, req.headers.get("stripe-signature")!, process.env.STRIPE_WEBHOOK_SECRET!
);
if (event.type === "checkout.session.completed") {
const s = event.data.object as Stripe.Checkout.Session;
const userId = s.metadata?.userId;
const credits = creditsForSession(s); // map your Price to credits
// idempotencyKey = event.id, so a retry is a no-op
await grantCredits({ userId, amount: credits, idempotencyKey: event.id });
}
return Response.json({ received: true });
}
create or replace function grant_credits(p_user uuid, p_amount int, p_key text)
returns integer language plpgsql security definer set search_path = public as $$
declare new_balance int;
begin
-- already applied this event? return current balance unchanged.
if exists (select 1 from credit_ledger where idempotency_key = p_key) then
select balance into new_balance from credit_balances where user_id = p_user;
return coalesce(new_balance, 0);
end if;
insert into credit_balances (user_id, balance) values (p_user, p_amount)
on conflict (user_id) do update set balance = credit_balances.balance + p_amount
returning balance into new_balance;
insert into credit_ledger (user_id, delta, reason, idempotency_key)
values (p_user, p_amount, 'purchase', p_key);
return new_balance;
end $$;
The exists
check is a fast path. The real guarantee is idempotency_key unique
. If two retries race past the check, the second insert
into the ledger violates the constraint and the whole function (it's atomic) rolls back, including the balance bump. Applied exactly once.
A bonus: now you can safely return 500
on any transient error and let Stripe retry, because retries are free.
Don't run a slow model call inside the request. It'll time out. Spend the credits, queue a job, process it in a worker, and refund if it fails:
// app/api/jobs/route.ts: spend first, then enqueue
const jobId = crypto.randomUUID();
try {
await spendCredits({ userId, amount: 5, idempotencyKey: jobId }); // keyed on jobId
} catch (e) {
return Response.json({ error: "insufficient_credits" }, { status: 402 });
}
await db.from("jobs").insert({ id: jobId, user_id: userId, type, status: "queued", credits_cost: 5 });
// worker.ts: claim, process, refund on failure
const { data: job } = await db.rpc("claim_next_job"); // FOR UPDATE SKIP LOCKED
try {
const output = await runYourModel(job);
await db.from("jobs").update({ status: "done", output }).eq("id", job.id);
} catch (err) {
await db.from("jobs").update({ status: "failed", error: String(err) }).eq("id", job.id);
await refundCredits({ userId: job.user_id, amount: job.credits_cost,
idempotencyKey: `refund:${job.id}` }); // distinct key, never double-refunds
}
Two details that matter. Claim jobs with FOR UPDATE SKIP LOCKED
so you can run N workers without two of them grabbing the same job. And key the refund refund:<jobId>
, distinct from the spend's <jobId>
, so a retried failure handler can't refund twice.
WHERE
clause and you oversell.Three functions (spend
, grant
, refund
), one ledger table doing double duty as audit log and idempotency guard, and a worker. It won't oversell, double-charge, or strand money. It took me a few shipped apps to get here.
If you'd rather not rebuild it, I packaged all of this into ** CreditKit**, a Next.js + Supabase + Stripe starter for AI apps. That's these patterns plus passwordless auth, Stripe checkout and portal, the worker, and a dashboard. Either way, now you know the traps.
Algy ( youngalgy.com). I build and ship small AI/SaaS products. CreditKit is the billing layer extracted from about 10 of them.