Guides
Build guide

How to add a credits wallet in Next.js, and why the balance is not one number

Add a credits wallet in Next.js: sell packages, show the balance, spend FIFO from dated buckets, and warn before expiry. The balance is an aggregate over buckets, not a column you decrement.

Dharmendra Jagodana10 min read

In short

A credits wallet is not a number on the workspace row. Credits arrive in dated buckets from three sources and spend earliest-expiring first. Show the balance with useCreditBalance, sell top-ups with CreditStorePage, and warn with useExpiringCredits, which fires only on buckets that carry an expiry at all.

Search for a credits system and every result on page one starts the same way: a credits_remaining column on the user row, decremented after the work. Then the post spends its second half fighting the bug it just introduced, because two requests read 100, both subtract 100, and the customer gets 200 credits of work for 100 credits of money.

The patches are familiar. A transaction. A row lock. A SELECT ... FOR UPDATE someone will later remove for being slow.

All of that is downstream of the first decision, which nobody flags as a decision: a wallet was stored as an integer. It is not one. It is a stack of dated allocations, and the questions that actually break credit systems - what gets spent first, what expires when, what a retry does - only have answers once you stop pretending otherwise.

What you are building

A wallet a customer can read, top up and spend down, on a workspace. When it works, the billing screen shows a balance and a history, a "buy credits" button returns from Stripe with the balance already higher, and a job that runs twice charges once.

Underneath, no line of your code subtracts anything.

Before you start

A BuildBase project with the billing module, and a workspace to hang the wallet on. Selling credit packs needs Stripe connected. Granting them with a plan does not.

Step one: decide where credits come from

Before any code, pick which of the three sources you are turning on. A bucket records where it came from, and the source decides the rules that follow it for the rest of its life:

SourceCreated byTypical use
plan_grantA plan version with a credit grantCredits with a plan
credit_pack_purchaseStripe checkout completingSelf-serve top-ups
admin_grantYour console, or a workflow actionGoodwill, migrations

Most products want two of these: a grant attached to the plan so a new customer starts with something, and packs they can buy when that runs out. A pack is defined once, in the dashboard, and carries a creditAmount, pricing per currency, and an optional validityDays.

That last field is the one to think about now rather than later. Set it and a bucket bought from that pack gets an expiresAt of the purchase date plus that many days. Leave it unset and those credits sit there until spent. You are choosing between an open liability on your balance sheet and a support conversation with someone whose credits vanished, and both are real. Pick on purpose. (validityDays only applies where a pack is actually bought or granted by hand. A plan grant pointing at the same pack resolves its expiry a different way, which step five gets to.)

The admin grant has a split worth knowing before you build a support flow on it. From the console it hands over the package's creditAmount, so granting 250 credits means having a 250-credit pack to grant from. The grant_credits workflow action takes an arbitrary number instead and needs no pack at all.

There is a fourth value in the enum, refund, and nothing in the server ever writes it. A refund does not mint a bucket. It reaches back into the original purchase, which the failure modes below come back to.

Step two: show the balance, which is five numbers

The balance endpoint returns five fields, and the reason to know all of them is that available alone cannot answer a customer's question:

'use client';

import { useCreditBalance } from '@buildbase/sdk/react';

export function Balance({ workspaceId }: { workspaceId: string }) {
  const { balance } = useCreditBalance(workspaceId);
  if (!balance) return null;

  return (
    <p>
      {balance.available} credits left, {balance.totalConsumed} used of{' '}
      {balance.totalGranted} granted
    </p>
  );
}

GET /api/v1/public/workspaces/:workspaceId/credits also carries totalExpired and totalRefunded. When somebody writes in asking where their credits went, totalExpired is the answer, and a balance screen that never shows it turns a one-line reply into an investigation.

The read needs the billing-view permission, which is worth noting before you drop the component into a page every member can see.

Step three: sell a top-up

The purchase flow is a Stripe Checkout session, and the pieces are already built. openCreditStore() from useSaaSAuth opens the purchase UI from anywhere, which is what you want behind a "buy credits" button in an out-of-credits error state. For a page of your own, CreditStorePage is headless and hands you the packages through a render prop:

import { CreditStorePage } from '@buildbase/sdk/react';

export function BuyCredits() {
  return (
    <CreditStorePage>
      {({ loading, packages, selectPackage }) =>
        loading ? (
          <p>Loading packages...</p>
        ) : (
          packages.map((pkg) => (
            <button key={pkg._id} onClick={() => selectPackage(pkg._id)}>
              Buy {pkg.creditAmount} credits
            </button>
          ))
        )
      }
    </CreditStorePage>
  );
}

Server-side, POST /credits/purchase takes a creditPackageId, a successUrl and a cancelUrl, and it needs the billing-manage permission rather than billing-view. Buying is not reading.

The part you would otherwise have to build is the webhook. A Stripe checkout webhook that fires twice is not an edge case, it is Tuesday, and the guard here is a unique index on stripeCheckoutSessionId across buckets. The second delivery tries to mint a bucket with a session id that already exists, the index refuses it, and nothing is granted twice. The database is the referee, not a check-then-write in the handler, which would be its own race.

Step four: spend, and let the guard arbitrate

Spending is one call:

import BuildBase from '@buildbase/sdk';

const bb = BuildBase({ serverUrl, orgId, getSessionId });

try {
  await bb.credits.consume(workspaceId, {
    amount: 50,
    description: 'Batch export',
    idempotencyKey: jobId,
  });
} catch (err) {
  if (err.code === 'INSUFFICIENT_CREDITS') {
    return Response.json({ error: 'Not enough credits' }, { status: 402 });
  }
  throw err;
}

Two things happen inside it, and the order matters.

First, one conditional update against the aggregate balance, filtered on available being at least the amount requested. Mongo applies the filter and the decrement atomically, so at most one caller succeeds per available unit. That single line is the whole concurrency story. Nothing reads the balance and then decides: the filter and the decrement are the same operation, so losing the race means your update matched nothing. Only then is the balance read, so the 402 carries a fresh number rather than a stale one. This is the same shape as the conditional write the roll-your-own tutorials arrive at eventually, minus the three attempts.

Then the buckets are drawn down, earliest-expiring first and oldest after that. One consume of 50 can take 20 from a pack that lapses on Friday and 30 from a grant with no expiry, writing a ledger row for each. Credits about to expire leave first, which is the behaviour a customer would pick if you asked them.

If you pass an idempotencyKey, a repeat returns the original result instead of charging again. Pass a stable id for the logical job rather than a fresh one per attempt. Where the debit sits relative to the work is a separate decision, and it has real consequences, because no call reverses a consumed credit.

Step five: warn before an expiry that might not exist

Here is where a wallet quietly diverges from what its own pricing page promises. Plan-granted credits get an expiresAt in exactly one configuration:

renewOnPeriodModeExpiry
off (default)anynone, a one-time balance
ontopupnone, they accumulate
onresetat the next monthly reset

Three of the four give you credits that never lapse. renewOnPeriod is off unless someone turns it on, so the default for a plan grant is a lifetime balance, not a monthly allowance. The grant is configured on the plan version rather than the plan, which means changing it is a versioning decision like any other commercial term. If your pricing page says "500 credits a month" and nobody set that flag, each customer is given 500 credits once, and the gap shows up as a margin problem months later rather than as an error.

Even the one expiring row has a way out. The expiry is set to the subscription's period end, and if the subscription carries neither a usage period end nor a Stripe one, the grant comes out with no expiry at all rather than an error.

Purchased credits are the other path and they follow validityDays on the pack, independent of any of this.

Wherever expiry is live, warn on it:

import { useExpiringCredits } from '@buildbase/sdk/react';

const { expiringCredits } = useExpiringCredits(workspaceId, 7);

Seven days is the default window and 90 is the ceiling. WhenCreditsLow covers the other failure, a balance running out rather than timing out, and renders once the balance is at or below the threshold you give it. Warning at a threshold sells a top-up. Hitting zero with no warning ends the session.

The failure modes

A shortfall throws instead of undercharging. The aggregate balance and the buckets are two records of the same fact, and they can disagree. If the guard allows a spend that the buckets cannot cover, the deduction does not quietly take what it can find: the balance decrement is rolled back and the call throws a desync error naming the workspace. Loud is correct here. A wallet that silently serves work it could not account for is worse than one that returns a 500, and the log line tells you reconciliation is owed.

A plan change expires the old grant whatever the mode. This is the one I expected to work the other way. Moving a customer to another plan expires their existing plan-granted buckets before any new ones land, and it does that without consulting the grant's mode, so a topup customer who has been accumulating credits all year loses them on an upgrade. Canceling expires them too. Mode only decides what happens on the monthly re-grant, not on a plan change. Purchased and admin-granted credits survive all of it, which is the right split and the sentence to have ready when somebody upgrades mid-month and asks where the balance went. Their invoice moves on a different set of rules again.

A refund claws credits back, and your code cannot start one. When Stripe reports a refund, the matching pack is found by its payment intent and the same fraction of it is taken back, rounded up. A dispute takes all of it the moment it is opened. What the customer already spent stays spent, because it cannot be un-spent, so a full refund on a half-used pack pulls back what is left and stops there. This used to be missing: both webhooks reached the router, fell through as unhandled, and the credits stayed spendable after the money had gone. There is still no route or SDK call that reverses a purchase from your own code, which matters if you were planning a self-serve refund button.

Spending is rate limited, and 429 is not 402. The consume route allows 30 requests per minute per IP. A 402 means the wallet is empty. A 429 from the same route means you are calling it too fast, and a batch job that fans out is the usual way to find that out.

Read the transactions, reconcile against the ledger. These are two collections doing two jobs. Transactions are the user-facing feed, paginated newest first at up to 100 a page, filterable by type, which is what your billing screen renders. The ledger is the immutable trail, one row per bucket touched, carrying balanceBefore and balanceAfter, with the idempotency key stamped on the first of them. Render the first. Reconcile with the second. A UI built on the ledger shows a customer one purchase as three rows and invites a support ticket.

Check who is allowed to spend. The consume route requires a usage-record permission on top of workspace membership. It did not always: for a while it carried the rate limiter and the membership check and nothing else, which meant the read-only viewer role could drain a workspace's balance. That is fixed, and it is the kind of thing worth checking in your own routes, because spending a customer's credits is spending their money.

Where this sits next to the rest

Credits are the prepaid half of consumption billing. The allowance that comes with a plan and resets on renewal is a quota, and the two behave differently enough that choosing the wrong one shows up as an email. Plenty of products run both.

The full hook and component inventory, including the pieces this guide skipped, is at docs.buildbase.app/credits/overview.

I have not stood this exact sequence up as a single app end to end. The component and hook names are the documented SDK surface, and the server behaviour described here was read from the routes, the models and the credit service as they are today, not from how they ought to work.

npm i @buildbase/sdk
billing
credits
nextjs
wallet

Frequently Asked Questions

How do you stop two requests spending the same credit?

Let the database arbitrate. The consume path runs a single conditional update filtered on available being greater than or equal to the amount, so at most one caller wins per available unit. Reading the balance and then subtracting in application code is the race, and no amount of checking before the write fixes it.

Do credits expire?

Purchased credits expire if the package sets validityDays. Plan-granted credits expire in one configuration only: renewOnPeriod true together with reset mode, which expires them at the next monthly usage reset. Leave renewOnPeriod off, or use topup mode, and the grant is a one-time balance with no expiry. A plan change or cancellation still expires plan-granted credits, whatever the mode. Purchased and admin-granted credits survive both.

Which credits get spent first?

The ones expiring soonest, then the oldest. Deduction takes dated buckets first, earliest expiry before later, and only then the buckets with no expiry at all, oldest first. It takes what it can from each until the amount is covered, so a single consume can span several buckets and the credits about to lapse leave first.

What is the difference between the ledger and the transaction history?

The ledger is the immutable audit trail, one row per bucket touched, carrying balanceBefore and balanceAfter. The idempotency key that makes a retry safe is stamped on the first of those rows. Transactions are the user-facing feed you render in the UI. The route your billing screen reads is transactions; the ledger is what you reconcile against.

Ship it

Create a project and run this guide against your own workspace.

7-day free trialNo credit card requiredCancel anytime