How to Add Credit-Based Billing to an AI App (FastAPI + React)

· min read

Every call your app makes to an AI model costs you money. With a flat monthly price, your heaviest users can cost more than they pay you, and you won’t notice until the bill from OpenAI arrives.

Credit-based billing fixes that: users buy credits, each AI call spends some, and when the credits run out, the calls stop. This post builds a small, working version with FastAPI, PostgreSQL, and Stripe, then wires it into a React frontend. You can have it running in an afternoon.

1. Count in tokens

AI providers bill you per token, so make tokens your credit unit too. A user who buys “500,000 tokens” spends exactly what the provider charges you for, and your accounting matches your invoice with no conversion.

2. One table for the balance

CREATE TABLE credit_balance (
    user_id  integer PRIMARY KEY REFERENCES users(id),
    tokens   bigint NOT NULL DEFAULT 0 CHECK (tokens >= 0)
);

That’s the whole data model for now. The CHECK constraint means the database refuses a negative balance, even if your code has a bug.

3. Check before the call

Before calling the model, make sure the user has credits left:

async def get_balance(db, user_id: int) -> int:
    row = await db.fetchrow("SELECT tokens FROM credit_balance WHERE user_id = $1", user_id)
    return row["tokens"] if row else 0


@app.post("/summarize")
async def summarize(req: SummarizeRequest, user=Depends(current_user)):
    if await get_balance(db, user.id) <= 0:
        raise HTTPException(status_code=402, detail="Out of credits")
    ...

You don’t know how many tokens a call will use until it finishes, so “more than zero” is a fair rule for a first version. A user with a few tokens left might go slightly negative on their last call. The next section deals with that.

4. Bill what the call actually used

The provider tells you the exact token count in every response. Spend that, not a guess:

from openai import AsyncOpenAI

client = AsyncOpenAI()


async def spend(db, user_id: int, tokens: int) -> None:
    await db.execute(
        """
        UPDATE credit_balance
        SET tokens = GREATEST(tokens - $2, 0)
        WHERE user_id = $1
        """,
        user_id,
        tokens,
    )


@app.post("/summarize")
async def summarize(req: SummarizeRequest, user=Depends(current_user)):
    if await get_balance(db, user.id) <= 0:
        raise HTTPException(status_code=402, detail="Out of credits")

    response = await client.responses.create(
        model="gpt-5-mini",
        input=f"Summarize this:\n\n{req.text}",
    )
    await spend(db, user.id, response.usage.total_tokens)
    return {"summary": response.output_text}

Two details in spend matter more than they look.

  • It’s one statement. Reading the balance in Python, subtracting, and writing it back would let two requests that finish at the same moment both spend the same credits. A single UPDATE can’t be interrupted halfway like that.
  • GREATEST(..., 0) absorbs the last call. If a user had 200 tokens left and the call used 300, they land on zero instead of negative. You eat 100 tokens once; the next call is blocked by the check.

5. Sell credit packs with Stripe

A credit pack is a one-time Stripe Checkout payment. Put what the user is buying in the session’s metadata, so your webhook knows what to grant later:

import stripe

PACKS = {"small": (500_000, 900), "large": (2_000_000, 2900)}  # tokens, price in cents


@app.post("/credits/checkout")
async def buy_credits(pack: str, user=Depends(current_user)):
    tokens, cents = PACKS[pack]
    session = stripe.checkout.Session.create(
        mode="payment",
        line_items=[{
            "price_data": {
                "currency": "usd",
                "unit_amount": cents,
                "product_data": {"name": f"{tokens:,} AI tokens"},
            },
            "quantity": 1,
        }],
        metadata={"user_id": user.id, "tokens": tokens},
        success_url="https://yourapp.com/billing?paid=1",
        cancel_url="https://yourapp.com/billing",
    )
    return {"url": session.url}

When the payment completes, Stripe calls your webhook. Verify the signature, then add the tokens:

@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
    payload = await request.body()
    event = stripe.Webhook.construct_event(
        payload, request.headers["stripe-signature"], STRIPE_WEBHOOK_SECRET
    )

    if event["type"] == "checkout.session.completed":
        session = event["data"]["object"]
        await grant_once(
            db,
            payment_id=session["payment_intent"],
            user_id=int(session["metadata"]["user_id"]),
            tokens=int(session["metadata"]["tokens"]),
        )
    return {"ok": True}

6. Grant each payment only once

Stripe sometimes delivers the same event more than once, for example when your server answered slowly the first time. If the webhook adds credits every time, some users get their pack twice. Record every payment you’ve granted, and let a primary key reject repeats:

CREATE TABLE credit_grant (
    payment_id  text PRIMARY KEY,
    user_id     integer NOT NULL REFERENCES users(id),
    tokens      bigint NOT NULL,
    created_at  timestamptz NOT NULL DEFAULT now()
);
async def grant_once(db, payment_id: str, user_id: int, tokens: int) -> None:
    async with db.transaction():
        inserted = await db.fetchval(
            """
            INSERT INTO credit_grant (payment_id, user_id, tokens)
            VALUES ($1, $2, $3)
            ON CONFLICT (payment_id) DO NOTHING
            RETURNING payment_id
            """,
            payment_id, user_id, tokens,
        )
        if inserted is None:
            return  # already granted this payment

        await db.execute(
            """
            INSERT INTO credit_balance (user_id, tokens) VALUES ($1, $2)
            ON CONFLICT (user_id) DO UPDATE SET tokens = credit_balance.tokens + $2
            """,
            user_id, tokens,
        )

The insert and the balance update run in one transaction, so a crash between them can’t leave a payment recorded with no credits added. Stripe can retry as often as it likes; each payment adds tokens exactly once.

With that, the backend has a balance, a check, billing from real usage, and packs people can buy.

7. Show it in React

The backend is done. The frontend needs three things: show the balance, react when credits run out, and send the user to Stripe to buy more.

Add one endpoint for the balance:

@app.get("/credits/balance")
async def credits_balance(user=Depends(current_user)):
    return {"tokens": await get_balance(db, user.id)}

Then a small component that shows it and offers the packs:

const API = import.meta.env.VITE_API_BASE_URL;

export function Credits() {
	const [tokens, setTokens] = useState<number | null>(null);

	useEffect(() => {
		fetch(`${API}/credits/balance`, { credentials: 'include' })
			.then((res) => res.json())
			.then((data) => setTokens(data.tokens));
	}, []);

	async function buy(pack: 'small' | 'large') {
		const res = await fetch(`${API}/credits/checkout?pack=${pack}`, {
			method: 'POST',
			credentials: 'include'
		});
		const { url } = await res.json();
		window.location.href = url; // Stripe Checkout
	}

	return (
		<div>
			<p>{tokens === null ? 'Loading...' : `${tokens.toLocaleString()} tokens left`}</p>
			<button onClick={() => buy('small')}>Buy 500K tokens ($9)</button>
			<button onClick={() => buy('large')}>Buy 2M tokens ($29)</button>
		</div>
	);
}

When credits run out, the backend answers 402. Treat that as its own case instead of a generic error, so the user sees what to do next:

async function summarize(text: string) {
	const res = await fetch(`${API}/summarize`, {
		method: 'POST',
		credentials: 'include',
		headers: { 'Content-Type': 'application/json' },
		body: JSON.stringify({ text })
	});

	if (res.status === 402) {
		setOutOfCredits(true); // show the Credits component instead of an error
		return;
	}
	const data = await res.json();
	setSummary(data.summary);
}

One thing to expect after a purchase: Stripe redirects the user back before your webhook has always run, so the balance can still show the old number for a few seconds. Refetching the balance when the page loads with ?paid=1 covers most cases.

That’s a complete credit system, front to back.

What this version leaves out

The version above works, and for a side project it may be all you need. Once real customers depend on it, these are the gaps you’ll hit next:

  • Plans with included tokens. Most SaaS products sell a subscription that includes a monthly allowance, with credits on top. That means an allowance that resets each billing period, and spending it before touching purchased credits.
  • Teams. Credits usually belong to an organization, not to one user, with every member spending from the same balance.
  • Streaming. With a streamed response, the token count arrives on the very last chunk, after the user has already seen the text. Billing has to happen when the stream ends, not when it starts.
  • Lost webhooks. Refetching the balance helps with a slow webhook, not a lost one. A production setup also verifies the checkout with Stripe when the user comes back, so a paying customer never waits on a webhook that may not arrive.
  • Knowing your margin. Input and output tokens cost different amounts, and prices differ per model. Logging the dollar cost of every call is how you find out whether your credit prices actually cover your AI bill.
  • An admin view of who is spending what, and who is about to run out.

FastReact ships all of this already built and tested: plan allowances, organization credit balances, credit packs through Stripe, streaming-safe billing, per-call cost logging, and an admin AI-usage dashboard, wired to a working AI copilot so you can watch the whole flow before building your own feature on it. The AI usage and credit billing docs walk through how it fits together.

logo-light

Ship production-ready SaaS applications with FastAPI + React. Complete authentication, payments, multi-tenancy, and admin dashboards - deploy anywhere with zero vendor lock-in.

© 2026 FastReact. All rights reserved.

🌼 Made with daisyUI

FASTREACT