# 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.

- Source: https://www.buildbase.app/guides/credits-and-usage-limits-for-an-ai-app
- Section: Guides
- Author: Dharmendra Jagodana
- Published: 2026-10-29

## 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](/guides/charge-per-credit-nextjs)
and [the credits wallet](/guides/credits-wallet-nextjs), 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](/blog/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 (`renewOnPeriod` on, 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.

```ts
// 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":

```text
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 `credits` column 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.
- `WhenCreditsLow` with a required `threshold` renders its children once
  the balance is at or below the number you give it, and
  `WhenCreditsExhausted` renders at zero. There is no default threshold
  because there is no sensible default for a number you price yourself.
- `openCreditStore()` from `useSaaSAuth()` opens the purchase screen from
  anywhere, which is what the out-of-credits message should do, and
  `CreditStorePage` is the same store as a page of your own.

```text
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](/guides/emails-that-arrive-for-an-ai-built-app) 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](/guides/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](/guides/quota-enforcement-server-side-nextjs).

```text
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](/blog/vibe-coded-app-hacked-incidents-and-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:

```bash
npm i @buildbase/sdk
npx buildbase init --org-id <your 24-character org id>
```
