How to build a credit system for a Next.js AI app (Stripe + Supabase) A developer has created a credit-based billing system for Next.js AI applications using Stripe and Supabase, solving the problem of flat-rate pricing that can lead to margin erosion from heavy users. The system uses PostgreSQL database-level atomic operations with row locking to prevent race conditions in credit spending, and implements idempotency keys in the credit ledger to handle Stripe's at-least-once webhook delivery without double-charging users. The solution includes a `spend_credits` function that checks balance in the `WHERE` clause of an update statement, ensuring concurrent requests cannot overspend credits. 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 : js // 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: