Billing systems · Field notes

How to bill AI usage

Metering AI is easy — every call can emit an event. Billing it correctly is three harder problems: choosing a unit, reconciling two clocks, and making the arithmetic visible. Here is a design that holds up, and the failure modes that shaped it.

[ 01 — The decision that determines everything ]

Pick the unit before you build the ledger

Every AI billing system has three units in it, and they are rarely the same unit.

There is what you sold — usually a count, because that is what a buyer can hold in their head. There is what you store, which wants to be money, because money is what a ledger is good at. And there is what you show, which has to match the contract or the customer will not believe you.

Contract
operations
10,000 / month
×
rate
Ledger
currency
balance in USD / INR
÷
rate
Display
operations
balance ÷ rate

Money in the middle, counts on both ends. The two conversions are where every problem in this article lives. Both use a rate — and a rate is a number that changes.

Store money. A currency balance stays correct when rates change, when a model gets cheaper, when you add a modality you had not planned for. A balance denominated in operations silently re-prices itself every time you touch the rate card.

But be honest about the right-hand box. The moment you sell a count and store an amount, you have committed to maintaining a conversion forever, and it is only as stable as the rate underneath it.

[ 02 — The system before ]

What a free-text rate field costs you

The system I inherited stored two numbers per customer: a pool, which was just the contracted operation count, and a per-operation rate typed in by whoever set the account up. Available operations was one divided by the other.

The intended rate was always 1. It was a free-text field on an internal form, labelled like a price, and it behaved like a divisor.

rate = 1.0
10,000
rate = 0.5
20,000
rate = 0.1
100,000

Operations actually available on a 10,000-operation contract. Nobody typed a number meaning "give this account ten times its allocation". They typed a price.

The same field failed the other way for customers billed in dollars. A rate of 1 reads as "one unit per operation" — sensible-sounding — and in a pool denominated in dollars it means one dollar per operation. Invisible while free credits lasted, then orders of magnitude above cost the moment they ran out.

The lesson is not really about a rate field:

Every screen showed the contracted number. The figure that governed consumption was never rendered anywhere. It existed only inside a nightly job that printed nothing.

Nobody ignored a warning — there was no warning. A derived value that decides what someone is charged, and appears in no interface, is not a calculation. It is an unexploded assumption. That single observation is why the rest of this design looks the way it does.

[ 03 — The meter ]

Make the usage event carry everything

One event per AI call, emitted by the feature that made it. The billing job never asks a model what it did; it reads events. So the event has to be complete when it is written, because nothing downstream can reconstruct what was never recorded.

Usage event · one per AI call
tier
Which price band the model sits in. Missing → no rate resolves, and the event drops out of costing entirely.
usageType
text · image · video · audio. Decides both the rate and the unit.
billable
Explicitly true or false. Absent is not false — absent means the job cannot classify it, so it is skipped.
usageUnits
Seconds, for audio. The one modality not counted in operations.
weight
Lets one call count as several operations. Defaults to 1.
feature · channel
Not used in the arithmetic. Used in every question anyone asks afterwards.

The three highlighted fields are load-bearing. Anything missing one cannot be priced — and usage that cannot be priced is revenue you never see again.

Voice is why usageType has to exist from day one. A voice vendor bills per second; no amount of modelling makes a second an operation. Retrofitting a second unit into a system that assumed one is far more expensive than carrying the field before you need it.

Validate at emit

Nothing downstream can stop a feature shipping events without the required fields. If you take one thing from this section: enforce the schema where the event is written, not where it is read. By the time the nightly job sees it, the only options are to guess or to drop — and dropping is silent.

[ 04 — Prices ]

One rate card, versioned, resolved by fallback

Rates live in exactly one place, keyed by tier × country × usageType, one version active at a time and every previous version retained. Nobody outside that document sets a price — there is no per-customer rate field, because that is the bug in section two.

1
rate[tier][country][type]
Exact match. The normal path.
2
rate[tier]["OTHER"][type]
Country fallback, so a new market bills correctly on day one instead of failing.
3
not found
Skip the item and alert loudly. Never invent a price and never default to zero — one bills the wrong number, the other bills nothing and looks like success.

Resolution order. Two fallbacks and a refusal.

Keep every version and the timestamp it became active. You will be asked what something cost on a specific day last month — for a report, a dispute, a recalculation — and the only honest answer comes from the card that was live that day, not the one live now.

Margin is a separate number

Markup is stored per customer, apart from the base rate, so the rate card always reflects what the thing actually costs you:

finalRate = baseRate[tier][country][type] × (1 + markup / 100)

// non-USD contract
finalRateLocal = finalRate × conversionRate

Fold margin into the base rate and you lose the ability to answer "are we making money on this account" without archaeology.

[ 05 — Spending ]

Free credits are a balance, not a counter

Allocate free credits as an amount — contractedOps × finalRate — and let the job spend it like money. Each day's cost draws against free credits first; only the remainder reaches the wallet.

drawn from free credits
charged to wallet
0free balance exhausted →day's total cost

One day, one customer, one category. The split is computed, not chosen: fromFree = min(cost, balance). Voice and non-voice run separately, against separate balances.

Run the waterfall independently per category. Voice and messages have different balances and different units, and merging them means a chatty month silently eats a customer's voice allowance.

Write both halves to the ledger even when one is zero. A free-credit draw is a real economic event — it consumes something the customer was given — and a ledger that only records money leaving the wallet cannot tell you how much of the allowance is left.

[ 06 — The run ]

What the nightly job actually does

Bill in arrears, once a day, per customer. Not per call — per-call billing multiplies write volume by traffic and buys you nothing a daily aggregate does not.

Guard
Has this day already been billed?
Query the ledger for entries matching this customer and date range. If any exist, skip and alert. This is the only thing standing between a retried cron and a double charge.
Window
Resolve the day to a UTC range
A billing day is a local calendar day, not a UTC one. Convert once, at the boundary, and end just short of midnight so clock skew cannot drag in tomorrow.
Read
Aggregate usage by tier × type × billable
Audio buckets sum seconds; the rest sum weighted operation counts. One query, one composite aggregation.
Price
Resolve rate, apply markup, convert currency
Per bucket. Price the non-billable usage too — you want to know what you gave away, even though nothing is deducted for it.
Deduct
Free credits, then wallet
Atomic decrement on the balance, a ledger entry for each half. Snapshot the wallet before and after, so a run is auditable without replaying it.
Write back
Emit an event per deduction
Amount, currency, source of funds, and a per-tier and per-feature breakdown. This is what every dashboard downstream reads.
Settle
Push to the payment platform, record the job
Only if there is something to charge. Record per-customer status so a partial failure shows up as a row, not as a gap.

Batch it. Small groups with a pause between them — a nightly job that stampedes your datastore is a nightly outage on a schedule.

Design for the retry

Assume the job will run twice. The duplicate guard, the per-customer job rows and the before/after balances all exist for the morning when it does — so "did we charge them twice" takes a query, not an afternoon.

[ 07 — Two clocks ]

The day it happened, and the day it was counted

Usage is stamped when the call happens. Billing records are stamped when the job runs, about twelve hours later. Nearly every confusing question anyone asks about a system like this comes from that gap.

Usage
events
calls land all day · stamped when they happen
Billing
job
writes billing records
day D · 00:00 12:00 23:56 D+1 · 03:00

Never join on the write timestamp. Stamp every billing record with the day it is about and join on that. One field, and it removes an entire category of off-by-one reporting bug.

The corollary: today can never be shown next to yesterday. Today's usage is real, but nothing has priced it. Show it — separately, labelled as not yet billed — rather than letting it quietly widen a total that is meant to reconcile.

[ 08 — The seam ]

Cycle rollover lands inside the offset

Now put a billing cycle boundary in the middle of that gap. The cycle rolls at midnight; the job runs at three. For those three hours the contract has moved on while the charge being applied belongs to the month that just closed.

Contract
dates
cycle: 1–31 May
cycle: 1–30 Jun
Usage
31 May usage
Billing
job
charges 31 May
Free credit
balance
last month's leftover · never reset
31 May 00:00 1 Jun 00:00 03:00 1 Jun 12:00

The shaded band is the seam. Three lanes roll at midnight. The fourth does not, and that is the actual bug.

The charge itself is fine — the job bills the day it was told to bill, at that day's prices, whatever the contract now says. Drawing it is still worth doing, because the fourth lane is invisible in prose and unmistakable on an axis.

Rolling the dates does not refill the balance. If your cycle update changes two dates and stops, the new month opens with whatever the old one left behind — often nothing — and every operation bills straight to the wallet with no buffer in front of it. Provision credits in the same transaction that rolls the cycle, or you have built a monthly chore that somebody will eventually forget.

[ 09 — The rate you don't control ]

When the display unit floats

If you price in one currency and bill in another, the deduction is straightforward: charge local currency at the day's rate. The conversion to watch is the one going back the other way, when a balance has to be rendered as the count on the contract.

at 85
50,000
at 88
48,295
at 90
47,222
at 95
44,736

The same untouched balance, four exchange rates. Nothing was spent between these rows. A customer sold 50,000 operations watches the number fall through the month.

The billing stays correct throughout. The display cannot be, because the contract is denominated in a unit the ledger does not store and the bridge between them is a number neither party controls.

There are three honest ways out, and all of them are somebody else's decision: fix the conversion rate for the life of the contract, sell in the currency you bill in, or sell an amount instead of a count. Engineering cannot repair this downstream — it can only display it accurately and raise it early.

[ 10 — Show everything ]

More metrics means more questions, and that is the point

The change that mattered most was not the rate card. It was the dashboard.

Two views, deliberately fed from different sources. One reads billing records and answers what did we charge. The other reads raw usage and answers what did they use. Keeping them separate is what makes them useful: when they disagree, the disagreement is the finding.

billed
charged
given away
priced, free
dropped
unbillable

Give the third bar a name and a column. Usage that arrived without the fields needed to price it is revenue leaving quietly. It should be as visible as the revenue that arrived.

Every metric we exposed generated questions. Why is that account's voice spend triple last month. Why does this feature cost more than the one beside it. Why is there a gap between used and charged. Each question was answerable, and answering it hardened something — a missing field, a wrong weight, an account on the wrong tier.

"We can't show that easily" is never an acceptable answer about a number that decides what someone pays. It means the number exists and nobody can check it — which is exactly the condition the original bug needed in order to survive.

Instrumentation is not overhead you add once the system works. On a billing system it is the system working. It is the only mechanism by which anyone finds out that the arithmetic has quietly stopped being true.

[ 11 — Checklist ]

What I'd insist on next time

Decision Why
Store money, not counts A currency balance survives rate changes. A count re-prices itself silently.
Settle the contract unit first Selling a count and storing an amount commits you to a conversion forever. Fine — but decide it deliberately, not by accident.
One rate card, versioned, no per-customer prices A price that can be typed per account will be typed wrong, and there will be nowhere single to look.
Margin separate from cost Otherwise "are we profitable on this account" needs archaeology.
Validate usage events at emit Downstream can only guess or drop, and dropping is silent.
Tag billing records with the day they bill Never join reporting on the write timestamp. The clocks are twelve hours apart.
Provision credits in the step that rolls the cycle Anything left as a manual monthly chore is a future outage with a date on it.
Render every number that touches money Including the derived ones. Especially the derived ones.

None of this is exotic. It is a rate table, a nightly aggregate, a waterfall and two dashboards. What makes it reliable is not sophistication — it is that every number in it can be seen by somebody who is allowed to ask why.