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.
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:
| Source | Created by | Typical use |
|---|---|---|
plan_grant | A plan version with a credit grant | Credits with a plan |
credit_pack_purchase | Stripe checkout completing | Self-serve top-ups |
admin_grant | Your console, or a workflow action | Goodwill, 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:
renewOnPeriod | Mode | Expiry |
|---|---|---|
| off (default) | any | none, a one-time balance |
| on | topup | none, they accumulate |
| on | reset | at 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