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 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
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 ·