How I Built Prepaid Credits for a Multi-Model AI App
How I designed Zenote's credit system around reservations, settlement, model weights, concurrency, and payment webhooks instead of treating billing as a counter.
- ai
- saas
- architecture
- billing
- engineering
I recently added prepaid credits to Zenote, a multi-model AI app I have been building.
At first, the feature sounded simple: give every model a credit price, subtract credits when someone sends a message, and let users buy more.
That version is easy to describe and surprisingly easy to get wrong.
An AI request can fail before generation, fail halfway through a stream, fall back to another model, get aborted by the user, or run twice because two requests arrived at nearly the same time. A payment webhook can also be delivered more than once.
Once money and usage are attached to those states, a credit balance stops being a UI number. It becomes a small accounting system.
This is how I approached it.
I wanted one product unit above model pricing
Zenote can route requests across multiple AI models. Those models do not have identical economics, so charging every request as if it costs the same would hide an important difference.
I did not want the rest of the product to reason directly in provider prices either. Provider pricing is an implementation detail that can change independently of the interface I want users to understand.
So the application has its own unit: credits.
Each supported model maps to a credit weight. The chat UI can talk in credits while the model catalog decides how expensive a particular model is inside that system.
That separation gives me a useful boundary:
provider/model details
↓
model credit weight
↓
product credits
↓
user balanceThe important part is not the word "credits." It is that the product has a stable abstraction between external AI infrastructure and the user's balance.
This also fits a broader pattern in usage-based software. Stripe describes credit-based and prepaid models as one way to package variable usage, especially when the underlying service has consumption that changes from request to request. Their current guidance for AI billing also calls out credit reservations as a guardrail before expensive work starts.
Stripe's guide to usage-based billing for AI companies is a useful reference for that model.
A balance was not enough
My first architectural rule was that the balance should not be the history.
Zenote keeps separate credit accounts, credit transactions, reservations, purchases, and usage events. The account gives me the current state. The other records explain how it got there.
That matters the moment something goes wrong.
If a user says a request consumed credits unexpectedly, a number like 420 is not enough information. I need to know which request changed it, which model was requested, which model actually ran, whether fallback happened, how many credits were charged, and whether the request succeeded, failed, or was aborted.
So a chat settlement also writes a usage event with request-level metadata such as the requested and actual model, provider, token counts when available, latency, status, and credits charged.
I am not trying to build a general-purpose billing platform. I am keeping enough evidence to make Zenote's own billing behavior explainable.
Reserve first, settle later
The central decision was to split charging into two operations:
request starts
→ reserve credits
→ run model
→ settle actual charge
→ refund unused reservationBefore the AI request starts, Zenote calculates the amount it may need and reserves that amount from the account. If the balance cannot cover the reservation, the model call does not start.
This prevents a bad sequence where I pay for provider inference and only discover afterward that the user's balance was already empty.
The reservation also accounts for model fallback. If the requested model has a configured fallback, the system reserves the larger credit weight of the two. After the request finishes, it settles using the model that actually ran and returns any unused portion.
That makes fallback a billing state instead of an edge case hidden inside the AI layer.
Reservations expire after a short window. An expired reservation can be released back to the account, and a released reservation cannot later become a valid charge.
This is the part that changed how I thought about the feature. Charging is not balance -= price. It is a state transition.
Streaming makes failure states matter
Zenote streams assistant responses. That creates a less obvious question: what should happen if the request fails after useful output has already started reaching the user?
The chat route tracks whether generation materially started. A successful stream settles normally. If a request dies before producing meaningful output, the reservation can be released. If output already started, the settlement path can still charge the request rather than blindly treating every exception as free usage.
I also store whether the run ended as success, failed, or aborted in the usage record.
There is no universally correct billing policy for every AI product here. The important engineering decision is to make the policy explicit.
If billing behavior is scattered between a catch block, a UI error toast, and a provider callback, it becomes difficult to reason about. I wanted one settlement function to decide what happens to the reservation.
Free and purchased credits are different buckets
Zenote currently separates monthly free credits from purchased credits.
When a request is reserved, free credits are consumed first and the reservation remembers how much came from each bucket. Settlement can then refund the correct bucket.
This becomes important at month boundaries.
Free credits belong to a grant period. If an old reservation expires after the account has already moved into a new monthly period, I do not want stale free credits from the previous period silently inflating the new allocation.
Purchased credits are different because they are not tied to that monthly grant period.
It is a small distinction in the data model, but trying to reconstruct it later from one combined balance would be much harder.
Concurrency was the part I did not want to hand-wave
Two chat requests can arrive at almost the same time.
If both read a balance of 10 credits before either writes, both can conclude that spending 8 credits is allowed. A normal read-check-write flow can therefore overspend even if every individual function looks correct.
I use Appwrite transactions around credit mutations. The account is staged for a write before the transactional read so concurrent settlements conflict instead of independently passing the same balance check. Transaction conflicts are retried a bounded number of times.
I also wrote concurrency tests specifically for the credit system. One of the cases verifies that expired or already released reservations cannot later charge the account.
This is the kind of test I care about more than checking whether a balance label renders correctly. The dangerous bugs are the ones that only appear when two valid operations overlap.
Payment success does not directly mean "increment the balance"
The purchase side uses PayMongo for QR-based checkout.
A checkout creates a pending purchase. Credits are granted only after the server processes a verified payment event. The webhook route reads the raw request body, verifies the PayMongo signature with HMAC and timing-safe comparison, and rejects malformed or invalid requests before fulfillment.
Fulfillment is also designed to be idempotent.
If the purchase is already marked paid, processing the event again does not grant the credits again. The ledger transaction uses a deterministic idempotency key derived from the purchase, so the same purchase cannot legitimately become two credit grants.
That distinction matters because webhook delivery is not the same thing as a one-time function call. Billing code has to assume that an event may be retried.
The UI is downstream of the ledger
The visible part of the feature is intentionally boring.
The chat composer can show the current credit total. Settings exposes the balance, purchase options, model rates, and recent activity. After a request finishes, the client refreshes the balance from the server.
But none of those components own billing truth.
They are views over server-side credit state.
That is a pattern I have been trying to keep across the products I build: the interface can be optimistic about interaction, but it should not invent authoritative state for things involving money, permissions, or usage limits.
I used a similar mindset when hardening the AI chat on my portfolio: the interesting work is not the text box itself, but the limits and boundaries around an expensive backend action.
What I would keep if I rebuilt it
The feature ended up larger than a credits column, but I would keep the same core shape:
account = current balance
ledger = why the balance changed
reservation = permission to spend
usage event = what actually happened
purchase = why paid credits entered the systemThat model makes failure states visible instead of forcing every path into "charged" or "not charged."
It also gives me room to change model weights, add models, inspect usage, or change packaging without making the payment provider the center of the product architecture.
The main lesson was simple: if an AI feature can create variable cost, billing has to participate in the request lifecycle before the expensive work starts, not after it ends.
Once I treated credits as state transitions instead of arithmetic, the rest of the system became much easier to reason about.