How to properly build a credit ledger

ayushrodrigues1 pts0 comments

How to build a credit ledger that scales | Autumn

DocsBlogPricingDiscord

Start for free

Back to blogView as .md<br>Introduction

When we think about charging for AI applications and agents, we default to "usage-based billing".

And while it's clear that tokens should be monetized this way, the infrastructure people are actually building to support this looks completely different from the existing systems that phrase was coined for.

Existing billing services assumed that billing was asynchronous. You send events that are tallied up and added to the end of a monthly invoice. This worked well for compute and infrastructure.

The most common form of AI billing, however, is "quota-based": a form of usage-billing where synchronous access decision needs to be made in every request path before tokens are spent. This is the business model behind growing companies like Cursor, Lovable and OpenAI (you can even read about Codex's billing architecture in this blog by Jonah Cohen).

Customers buy some amount of usage upfront (usually in the form of credits), use them up, then hit a hard limit . These limits are a piece of state in constant flux: plan changes, billing cycles, user controls, promotional grants and other business logic.

What do you need?

The infrastructure behind our billing has to satisfy three conditions at once: low-latency access decisions on whether a customer can do something (ie, use a credit), a provably correct and auditable record of their usage, and with all of it working under high throughput (millions to billions of monthly events) and concurrency.

This article is aimed at AI companies on their second or third rewrite of billing. We will cover:

Understanding the counter and ledger, and why you need both

Using a cache to keep access decisions fast

Tracking usage under load, and why synchronous deduction is needed

Handling resets, expiry, and usage analytics

Since credits are the most common way of billing for AI tokens, we'll use this as the example going forward. However, the principles apply to any model where quotas are enforced.

Real-time access decisions

A reasonable first approach would be to ask: can't we just aggregate over all a user's usage events to see if someone has remaining credits when checking a request?

This doesn't work for real-time enforcement (we've seen it tried!). Different plans have different limits, rollover logic, expirations. The query becomes complex, slow, and very hard to debug or iterate on.

The solution is the counter: typically a static value for the remaining balance of credits or usage, and when that's positive (with some nuance for cases like overages) the action is allowed.

The counter

The first thing to consider is your data model for credits and balances. Imagine you have a plan which offers 100 credits every month. You would probably start with a schema that looks like this:

user_id<br>plan<br>balance

cus_001<br>pro<br>847

cus_002<br>free<br>12

cus_003<br>scale<br>4,203

cus_004<br>pro<br>560

Now what happens if you want to add single-purchase top ups? You might think to add another column topup_balance to your table.

This works for exactly one new credit type. The moment a bucket needs rules of its own; top-ups that expire after 6 months, promotional credits, 3-month rollovers, a daily refresh, a single number stops being enough. Each bucket has its own balance, deduction rules, reset cycles and expiration dates.

Therefore, a better approach is to treat each bucket of credits as a separate row within the table: each with its own source, balance, period and expiration date. When determining a user's counter balance, you aggregate over these rows.

user_id<br>source<br>balance<br>resets_at<br>expires_at

usr_1a2b<br>subscription<br>100<br>2026-07-01<br>null

usr_3c4d<br>subscription<br>100<br>2026-07-01<br>null

usr_3c4d<br>top_up<br>250<br>null<br>2027-06-05

usr_5e6f<br>subscription<br>100<br>2026-07-01<br>null

The obvious place to keep this is your primary database. Balances and plan config already live in Postgres, so you query it directly: sum the customer's credit grant rows, compare against the cost, apply any plan rules, return allow or deny. This is correct and simple, and for many products it's all you ever need.

The cost is latency. Every request runs a full aggregation, which is fine at low volume but costly when the check gates every AI call.

Introducing a cache

The fix is to split the read path from the source of truth. Keep authoritative balances in Postgres, but serve the access check from an in-memory store like Redis. You can maintain a single derived value per customer, their spendable balance, so the check is one key lookup rather than a query with joins.

That value is seeded from Postgres. On a cache miss (a new customer, an evicted key, a cold start) you run the full aggregation over their grant rows once, write the result to Redis, and serve every subsequent check from memory. Plan rules that gate the decision, like whether overage is allowed, can be cached alongside the balance so the whole...

billing usage credits balance plan credit

Related Articles