cd /news/ai-startups/how-to-build-a-credit-system-for-a-n… · home topics ai-startups article
[ARTICLE · art-23023] src=dev.to ↗ pub= topic=ai-startups verified=true sentiment=· neutral

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.

read5 min views16 publishedJun 6, 2026

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.

── more in #ai-startups 4 stories · sorted by recency
── more on @stripe 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/how-to-build-a-credi…] indexed:0 read:5min 2026-06-06 ·