Credits and usage limits for an AI app you built with a prompt
An AI feature needs a balance someone can spend down and a hard stop when it is gone. What the agent builds instead is a number in a column. Here is the shape that holds, and the prompts that get it.
In short
Credits for an AI app you built with a prompt are a balance the server spends after the work succeeds, never a column the browser decrements: check, work, then consume with an idempotency key, answer 402 when the balance is empty, show the platform's balance and store, and let a workflow send the low-balance email.
You are giving the AI feature in your app a balance: a number of credits each workspace can spend, a way to buy more, a warning before it runs out, a hard stop when it has, and a limit on the free plan that resets every month. When it works, a person uses their free allowance, sees exactly how much is left, buys a pack on a test card and watches the balance rise without a reload, gets refused with a clear message at zero, and gets an email before that happens. None of it lives in a column the browser can write.
That last sentence is the whole post. Ask a coding agent for credits and
it builds a credits integer on the users table, decrements it from the
page that calls the model, and adds a route "for testing" that sets it. In
February 2026 someone audited three products posted on Reddit and found
one with "development endpoints still in production that let anyone mark
themselves as a paid user and give themselves unlimited credits". That is
not an unusual app. It is the default output of the prompt "add credits".
What we ran on 27 October 2026: nothing new. The consume path in this guide is the one in charge per credit in Next.js and the credits wallet, which were written against our platform and whose figures (the 30-a-minute limit, the seven-day expiry window) come from the platform's own routes. The builder-specific parts are the prompts, and the honest note is that none of them has been run inside a builder today.
Before you start
An app with BuildBase sign-in and a workspace per customer, on any of the builders (the Lovable, v0, Bolt and Replit guides each end at that point). A Stripe account connected in the console, for the packs. A feature that costs something to run: a model call, an export, a render.
1. Decide what you are selling: a balance, a limit, or both
Two mechanisms look alike from the pricing page and behave differently underneath, and the agent will build whichever one you name, so name the right one.
A limit (a quota) is an allowance that comes with the plan and resets every period. Twenty generations a month on the free plan, five hundred on Pro. It is counted, not bought; when it is used up the answer is "upgrade" or "wait until the first"; and nothing carries over.
A balance (credits) is something people buy and spend down. A pack of 500 for $10, a monthly grant of 100 with the plan, a top-up when it runs low. It carries over, it can expire, it can be given away in a promotion, and when it is gone the answer is "buy more". The balance is also a liability on your side until it is spent, which is why it is a ledger and not a number.
Most AI apps want both: a quota on the free plan so strangers can try the feature, credits for everyone who pays so heavy users pay for what they use. The longer argument, with the cases where each is the wrong choice, is quotas are not credits. For this guide the decision is a sentence you say out loud: "free users get N a month; paid users spend credits".
What you should see: that sentence, with the numbers filled in, before you prompt anything.
2. Put the price list in the console, not in code
Packs, prices, grants and expiry are configuration, and they live in the BuildBase console so a price change is a form, not a deploy.
- Packs. Each one has a name, a number of credits, a price, and an
optional
validityDays. A pack with no validity never lapses. - Plan grants. A plan can grant credits each period. In one
configuration only (
renewOnPeriodon, in reset mode) the unspent part expires at the next monthly reset; leave it off and granted credits carry over. - Quotas. On the free plan, the monthly limit for the feature, as a plan limit the server checks.
Nothing about prices goes in the app. The agent will try to put them there, because the pricing page has to show them, and the answer is that the page reads the packs from the platform.
What you should see: the packs listed in the console with their prices, the free plan carrying the monthly limit, and a test purchase in Stripe's test mode adding credits to a workspace before any code is written.
3. The server route: check, do the work, then consume
This is the step that decides whether the balance means anything. The order inside the route is check, work, debit, and the debit is the gate.
// app/api/generate/route.ts
import BuildBase from '@buildbase/sdk';
const bb = BuildBase({ serverUrl, orgId, getSessionId });
const COST = 10;
export async function POST(request: Request) {
const { workspaceId, prompt, requestId } = await request.json();
// Advisory. Cheap, and it stops you doing work nobody can pay for.
const balance = await bb.credits.getBalance(workspaceId);
if ((balance?.available ?? 0) < COST) {
return Response.json(
{ error: 'Insufficient credits', available: balance?.available ?? 0 },
{ status: 402 }
);
}
const output = await generate(prompt);
// Authoritative. The balance moves here, after the work succeeded.
try {
const debit = await bb.credits.consume(workspaceId, {
amount: COST,
description: 'AI generation',
idempotencyKey: requestId,
});
return Response.json({ output, remaining: debit.balanceAfter });
} catch (err) {
if (err.code === 'INSUFFICIENT_CREDITS') {
return Response.json({ error: 'Insufficient credits' }, { status: 402 });
}
throw err;
}
}Three things in it that the agent's version never has. The check is advisory and the consume is the decision: on the server the debit lands as one conditional update that requires the balance to still cover the amount, so two requests racing for the last credit cannot both win, and there is no window to close. The consume comes after the work, because no call reverses a consumed credit, so a debit before work that fails is a refund ticket. And the idempotency key makes a retry safe: the same key returns without touching the balance, so a user who clicks twice, or a network that resends, is charged once.
Paste this prompt, with your builder's words for "server route":
The AI feature is paid for in credits held by BuildBase, not by this app. In the server route that runs it: read the balance with the server SDK's credits.getBalance and answer 402 if it cannot cover the cost; run the work; then call credits.consume with the amount, a description and an idempotency key taken from the request. Never consume before the work. Never store a credit balance, a credits column or a plan in our database. Do not add any route that sets a balance, grants credits or marks a user as paid, including for testing. The browser never calls the credits API directly.What you should see: call the route once and the balance drops by ten. Make the model call throw and the balance does not move, because the line that moves it never runs. Send the same request id twice and it is charged once.
Correcting potential issues. The ways the agent drifts, and the line to paste back:
- It added a
creditscolumn and decrements it. Paste: "Remove the credits column and every read or write of it. The balance is read from credits.getBalance and spent with credits.consume, on the server only." - It consumes before the work "to prevent abuse". Paste: "Move the consume call after the work succeeds. The getBalance check before the work is the abuse prevention; the debit is after."
- It left out the idempotency key, or generated it on the server. Paste: "The idempotency key comes from the client with the request, one per user action, so a retry of the same action carries the same key."
- It calls the credits API from the browser with a key. Paste: "No credits call runs in the browser. The browser calls our route; the route calls the platform."
4. The browser: the balance, the warning and the buy button
The screens are the platform's, and the agent's job is to place them. Three pieces:
useCreditBalance(workspaceId)returns the balance with its fields (available,totalGranted,totalConsumed,totalExpired, and the expiring part), so the number on the screen is the ledger's number.WhenCreditsLowwith a requiredthresholdrenders its children once the balance is at or below the number you give it, andWhenCreditsExhaustedrenders at zero. There is no default threshold because there is no sensible default for a number you price yourself.openCreditStore()fromuseSaaSAuth()opens the purchase screen from anywhere, which is what the out-of-credits message should do, andCreditStorePageis the same store as a page of your own.
Show the workspace's credit balance in the app header with useCreditBalance from @buildbase/sdk/react. Inside the AI feature, wrap the warning in WhenCreditsLow with threshold set to the cost of one run, and wrap the out-of-credits message in WhenCreditsExhausted; both offer a button that calls openCreditStore(). Add a /credits page that renders CreditStorePage. When our route answers 402, open the store; when it answers 429, show "try again in a minute" and nothing else.What you should see: the balance in the header, a yellow warning when it
is at or below one run's cost, the store opening on a click, a purchase on
a test card, and the header updating without a reload. Then the expiry
case: useExpiringCredits(workspaceId, 7) lists what lapses in the next
seven days (seven is the default window, 90 the ceiling), and a line on
the billing screen that says "40 credits expire on Friday" is the
difference between a top-up and a support ticket.
Correcting potential issues. If the agent built its own balance screen reading its own column, step 3's first fix-up. If the "buy" button opens a Stripe link the agent pasted, paste: "The buy button calls openCreditStore(); the platform handles checkout and credits the balance. Remove the Stripe link."
5. The hard stop, and the email before it
Two answers come out of the route in step 3, and they need two different screens.
402 means buy. The balance cannot cover the run. The response carries how many are available and how many were requested, so the message can say the exact shortfall, and the button opens the store.
429 means wait. The consume endpoint is rate limited to 30 requests a minute per IP, and that limiter answers with a 429 of its own. A 429 from this route is never an empty balance. Generated apps show the buy screen on any error, and a person who just bought a pack and then sees "buy more" because they clicked fast is a refund request.
The email is a workflow, not code. The platform raises a
credit.low_balance event on its own, and a workflow triggered on it
sends the "running low" email from your sender, with a link to the store;
a second workflow on the quota's quota.limit_exceeded event tells a free
user they have hit the month's limit and what Pro would give them. Both
are built in the console's workflow editor with a template, and each run
is logged, so "did Acme get the warning" is a search. Emails that
arrive is the guide to
the sender and the workflows.
What you should see: spend a workspace down past the point where the platform fires the low-balance event and the email arrives from your domain; spend it to zero and the feature refuses with the store; call the route thirty-one times in a minute and the thirty-first answers 429 with no store in sight.
6. The free plan's limit, which is a different thing
The free tier's monthly allowance is a quota, and it is enforced on the server by the plan, not by a counter in your database. The route records one unit of usage against the workspace before the work, the platform answers whether the workspace is within its plan's limit, and an exceeded limit comes back as a refusal your route turns into a 429 with a message about upgrading. The reset is the plan's, monthly, with no cron job of yours.
In the browser, WhenQuotaExhausted and its siblings render on the
quota's state (within, near the threshold, exhausted) rather than on
numbers the page computes, which is the point of enforcing quotas in
React: the page never decides whether
someone is over, it only shows what the server decided. The server side,
with the batching that matters once the feature is popular, is
quota enforcement in Next.js.
Free-plan workspaces get a monthly limit on the AI feature, enforced by the BuildBase plan limit, not by a counter in our database. In the server route, record usage against the workspace before running the work and answer 429 with an upgrade message when the platform says the limit is exceeded. In the browser, wrap the feature in WhenQuotaExhausted with an upgrade prompt. Do not add a usage table, a monthly reset job or a counter column.What you should see: a free workspace runs the feature N times, the next call answers 429 with the upgrade message, the page shows the upgrade prompt without a reload, and on the first of the month it works again with nothing of yours having run.
7. What the agent builds instead, and why it fails quietly
It is worth knowing the shape, because you will be reading diffs for it.
The balance is an integer on the users table. The page that calls the
model reads it, calls the model, subtracts, and writes it back, so a slow
network means two calls and one debit, and a fast one means the opposite.
A /api/dev/add-credits route exists because testing needed it, and
nothing removed it. There is no idempotency key, so every retry is a
double charge, and no ledger, so when a customer says "I had 50 and now I
have 12" there is nothing to show them. The free plan's limit is a
generations_this_month column that a cron job the agent also wrote
resets, or does not, on the first.
Each of those is a support ticket with a date on it, and the audit above is what it looks like when a stranger finds the dev route before you do. Enrichlead, the vibe-coded product that shut down within about a week of launch in March 2025, failed in the same family: its founder described "maxed out usage on API keys" and "people bypassing the subscription", which is what no limit and no gate cost when the feature behind them is metered by someone else's bill.
The reason to care about the shape, rather than any vendor, is the API bill underneath. Every credit you fail to charge is a model call you paid for. A balance the browser can set is a balance a stranger can set to a million, and the model provider bills you for every one of them.
One more thing about the builder's own credits, which are a different balance from the one you sell. A Base44 reviewer wrote on 15 October 2026 that "a runaway automated workflow in one of my apps consumed almost all 10,000 monthly integration credits". That is your building budget, not your customers' balance, and it argues for the same habit in both places: a limit the thing cannot cross, set by someone other than the thing spending it.
Where a plain limit, or the builder's own metering, wins
If your feature is cheap to run and you sell seats rather than usage, you do not want credits. A monthly quota on each plan is simpler to explain. It carries no liability on your books, it never expires on a customer who then writes in, and the plan enforces it. Credits earn their complexity when people buy them, when usage varies by ten times between customers, or when you want a balance that can be granted in a promotion.
And if you bill usage in arrears, as a line on the invoice rather than as a prepaid balance, Stripe's metered prices are the right tool and credits are the wrong one: Stripe Billing adds 0.7% to the card fees, read on 27 October 2026, and does the invoicing. Lovable's and Replit's built-in payments, read the same day, take a subscription and say nothing about usage, credits or limits, so on those builders the metering is yours either way, and the shape above is the one that holds.
Key takeaway
Credits are a ledger the server spends after the work, never a column the browser writes. Check, work, then consume with an idempotency key; 402 means buy and 429 means wait; the prices, the store and the low-balance email live in the platform, and no route in your app sets a balance.
What breaks, and how you can tell
The balance goes negative, or two clicks charge once. The page is
subtracting a column. Step 3: the balance is only ever moved by
credits.consume on the server.
A retry charged twice. No idempotency key, or a key generated per attempt instead of per action. The key comes from the client, one per user action.
The buy screen appears when the user clicked fast. The app treats 429 as 402, and step 5 says why that is the wrong screen: 429 is the rate limiter, so the message is "try again in a minute" and nothing opens.
Credits were deducted for a run that failed. The consume ran before the work. Move it after.
A customer says they lost credits and you cannot say where. They
expired, and the balance screen never showed totalExpired or the
expiring list. Step 4.
Someone has a million credits. A route sets balances, usually under
/dev/ or /admin/ with no check. Delete it, and read the five
checks before
launch.
The free limit never resets, or resets at the wrong time. A counter column and a cron job. Step 6: the limit is the plan's, and the platform resets it.
Install
The packs, the store and the workflows live in the console; the app needs the SDK and its sign-in:
npm i @buildbase/sdk
npx buildbase init --org-id <your 24-character org id>