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.
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.
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.
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.
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.
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.
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.
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.
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.
rate[tier][country][type]
rate[tier]["OTHER"][type]
not found
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.
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.
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.
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.
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.
Batch it. Small groups with a pause between them — a nightly job that stampedes your datastore is a nightly outage on a schedule.
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.
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.
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.
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.
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.
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.
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.
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.
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.
| 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.