DEV Community

Algernon
Algernon

Posted on

How to build a credit system for a Next.js AI app (Stripe + Supabase)

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.

The three things everyone gets wrong

  1. Overdrawing. Two requests arrive at once, both read "balance = 1," both spend. Now the balance is negative and you gave away work for free.
  2. Double-granting. Stripe retries webhooks (it will), and if you grant credits on every delivery, a $9 purchase becomes $18 of credits.
  3. Forgetting the refund. The AI job fails after you've already charged the credits. The user paid for nothing and emails you angry.

Let's kill all three.

Part 1. The atomic spend (overdraw becomes impossible)

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
Enter fullscreen mode Exit fullscreen mode

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 $$;
Enter fullscreen mode Exit fullscreen mode

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.

Part 2. Idempotent grants (Stripe retries can't double-credit)

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 });
}
Enter fullscreen mode Exit fullscreen mode
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 $$;
Enter fullscreen mode Exit fullscreen mode

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.

Part 3. The worker and refund-on-fail

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 });
Enter fullscreen mode Exit fullscreen mode
// 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
}
Enter fullscreen mode Exit fullscreen mode

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.

The pitfalls, in one list

  • Check the balance in app code instead of the WHERE clause and you oversell.
  • Grant on webhook delivery instead of a unique event id and you double-credit.
  • No refund path means angry users and chargebacks.
  • Run the model in the request and you get timeouts at scale.
  • Reuse one idempotency key for spend and refund and they collide. Use distinct prefixes.

That's the whole pattern

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.

Top comments (0)