Metering is the easy half — every model call can emit an event. Turning those events into an invoice nobody has to apologise for is where it gets interesting. This is the design I'd build again, in the order I'd build it.
This is the whole article. Everything downstream is a consequence of it, and it is almost always decided by accident — someone needs a number in a database on a Tuesday and picks one.
Your contract says something like 10,000 AI operations per month. That is what sales sold and what the customer believes. The naive move is to store exactly that: a counter at 10,000, decrement on each call. It works for about a quarter.
It breaks the moment two calls stop costing the same. A cheap model and an expensive model are both "one operation." A four-second voice clip and a nine-minute call are both "one operation." Now your counter is measuring something that has no stable relationship to your bill from the provider.
Store money. Show operations. The ledger holds a currency amount; the contract's operation count is a presentation of it, computed at read time. Money is the only unit that stays true when models, rates, modalities and exchange rates all move underneath you.
So the free allocation becomes an amount, derived once when the contract is signed:
allocated_credit = contracted_operations × rate_at_signing
10,000 operations at $0.004 becomes a $40 pool. You deduct real cost from that $40. If you later add a model that costs triple, it draws down the pool three times faster — which is correct, and which a counter would have hidden from you until the provider invoice arrived.
Two conversions, one source of truth. The middle box is the only number in your database. Both outer boxes are derived, and both can be recomputed if you got a rate wrong — which you will.
Somewhere in your admin panel there is a temptation to put a "rate" field on the customer record so the account manager can set it per deal. Do not do this. A per-customer rate typed by a human is a divisor with no schema, no validation and no audit trail, and it will quietly multiply someone's entitlement by ten.
Instead: one document, versioned, resolved by a key. Mine looks like this:
rate_card[tier][country][usage_type] → cost_per_unit
tier_2 / IN / text → 0.0041
tier_2 / US / text → 0.0045
tier_2 / ANY / text → 0.0045 ← fallback
Three properties matter, and they are the difference between a rate card and a config file you're afraid of:
rate_card_active_at(date) because in six months someone will
ask why an invoice from March looks like that, and "we changed the rates in
April" is only an answer if you can prove what March's were.
ANY, then alert.
final = base × (1 + markup/100). Keep them apart and you can
answer "what is our margin on this account" without archaeology.
If a rate lookup misses after the fallback, do not default to zero and do not default to one. Skip the item, leave it unbilled, and alert. Zero silently gives away product; one silently charges a dollar for something that cost a third of a cent. Both are worse than a page in your Slack.
The instinct is to force one unit across everything so the ledger is tidy. It isn't worth it. A text completion is naturally an event; a voice call is naturally a duration. Providers already price them that way, and flattening voice into "operations" means inventing a fictional average call length and then being wrong about it forever.
So make usage_type a first-class dimension and let each one carry
its own unit:
Different units, same ledger. Both resolve to money before anything is deducted, so the wallet never has to know the difference.
One extra field earns its keep: a weight on the event. Some features fire one
API call but genuinely represent several units of work — a summariser that
fans out to four completions, say. Let the emitting code declare
weight: 4 rather than emitting four fake events. Default it to
1 and sum the weight instead of counting documents.
The catch, and it is a real one: nothing validates that weight. A feature team can set it to 40 and you will bill 40× with no error anywhere. Treat it as a number that needs review in code review, not a number you trust.
Every AI call emits one event. That event is the only evidence the billing job will ever have, and it is written by teams who are not thinking about billing. So the contract has to be small enough to remember and strict enough to be useless if violated.
Six fields. Miss any of the first five and you can usually recover by reprocessing. Miss the sixth and you never find out.
That last field deserves the attention. You will have usage you deliberately don't charge for — internal testing, a trial, a feature you're giving away to land the account. The obvious implementation is to omit the field when it isn't billable.
Don't. If billable is absent, your aggregation cannot tell
"we decided not to charge for this" from "someone forgot." Both look
identical, and the job skips both. That is revenue walking out of the
building with no log line.
Require the field explicitly. Treat a missing value as a data-quality incident, not a free operation — count those events, price them anyway, and put the number on a dashboard labelled something you cannot ignore. "Unbilled — missing flags: $312 this week" gets fixed. A silently skipped bucket does not.
Free credits, trial allowances and goodwill top-ups are all the same thing: a pot of money you drain before you touch the customer's wallet. Once credits are denominated in currency this gets pleasantly boring.
Deduction is a waterfall, per usage type, in one direction:
One rule, applied per usage type.
free_used = min(cost, available), remainder to the wallet. Keep
voice and non-voice pools separate or a chatty week eats the voice
allowance.
Two things worth doing here that are easy to skip:
Bill in a batch, once a day, for yesterday. Not in the request path. The request path should do exactly one billing-related thing — emit the event — and then get on with serving the user. Pricing, currency conversion, wallet writes and provider reconciliation have no business adding latency to a chat response.
Batch the tenants too. Fifteen at a time with a small delay between batches keeps you inside provider rate limits and makes a failure affect fifteen tenants instead of all of them.
Track the run itself as data: a parent record for the run, a child record per tenant, each with a status and the wallet balance before and after. When someone asks why a tenant wasn't billed on the 14th, you want to read a row, not grep logs.
There are two timestamps in this system and confusing them produces bugs that survive for months because every individual number looks right.
Both views — "what did they use" and "what did we charge" — need to answer questions about yesterday. If the billing event only carries its own creation time, every join is off by one day and the off-by-one moves with the time zone.
Stamp every billing event with the date it covers, not the
time it was written. A run at 03:00 on the 12th writes events tagged
2026-08-11. Now the two streams join on a single key and stay
joined regardless of when the job actually ran, or how many times you
re-ran it.
Then pick one day boundary and use it everywhere. If you bill on local days, normalise every range to a local-midnight-to-local-midnight window converted to UTC, in one helper, used by both the job and every dashboard query. Two places computing "yesterday" independently will eventually disagree.
One small trick worth stealing: end the window a few minutes short of midnight. Clock skew between whatever emits your events and whatever stores them will otherwise pull a handful of the next day's events into today's invoice, and the resulting discrepancy is maddening to chase.
Last thing: today's usage exists in your event store but has not been billed yet. Don't render it next to billed figures — the totals won't match and you'll get a support ticket. Show it separately and label it as pending.
You store money. The customer bought operations. So the UI divides:
operations_remaining = floor(balance / rate_per_operation)
Floor, not round — a customer has only consumed an operation once they've
fully paid for one. That part is easy. The interesting part is that this
number is only stable if rate_per_operation is stable, and there
are two ways it isn't.
Currency. If your rate card is in USD and the wallet is in another currency, the divisor moves with the exchange rate. The balance is untouched; the displayed operation count drifts anyway:
Same wallet balance, four different answers. The money is correct at every step. The number on the contract is the thing that stopped being true.
Tiers. The same trap, from a different direction. If you price a cheap model and an expensive model differently — which is the entire reason you built tiers — then "operations remaining" depends on which features they use next. Two customers who bought the same 10,000 operations get different counts, correctly, and neither can be reconciled against the contract.
There are only three honest ways out, and you should choose one on purpose rather than discover it later:
1. Freeze the divisor. Store the rate that applied at
signing on the contract and display against that forever. The count matches
the contract exactly. You absorb the FX and model-mix risk.
2. Sell money. Put credits in the contract instead of
operations, and show a balance. Honest, trivially correct, and a harder
sell.
3. Sell operations properly. Charge a flat rate per
operation regardless of model and eat the variance across your customer
base, like an insurer.
What you cannot do is sell a fixed operation count, price per model, bill in a second currency, and expect the displayed number to match the contract. That is three variables and one equation.
Everything above is the design I'd build now. It reads clean because it was expensive. Four things went wrong on the way, and each one maps to a rule in this guide.
Entitlement was stored as a contracted operation count plus a per-account
rate, typed into a free-text field on an internal form. Available operations
were count / rate. The field was labelled like a price. Enter
0.1 where 1 was intended and the account silently
gets ten times its allocation.
Nobody ever typed "give this customer 10×" — they typed a price. The UI displayed the contracted number, never the derived one, so the two never visibly disagreed. Some accounts drew 2–10× what they'd paid for. No screen anywhere rendered the number that decided it.
On accounts billed in USD the same field was sometimes set to
1 — meaning one dollar per operation, orders of magnitude above
actual cost. It stayed invisible while the free pool lasted, because nothing
was drawn from the wallet. The moment a pool ran dry, the account started
paying a dollar for a fraction-of-a-cent call.
One badly-scoped field produced both failure modes at once. That is what "never let a human type a rate" is really about.
Translating the old model into the money-denominated one meant computing
ops × rate per account — using the same wrong rates. Every
distortion carried straight across, now baked into a currency balance where
it was harder to spot.
The fix was a full audit: recompute every migrated pool from the central rate card and normalise. Manual, slow, and entirely the cost of not having had a rate card in the first place. Migrate from the corrected source, not from the field you're replacing.
A credit job had the start of the billing day hardcoded to a fixed UTC offset — exactly midnight in the market it was written for, and wrong everywhere else. Non-local markets lost most of a billable day. Separately, a daily cutoff a few hours short of the boundary was dropping around 12.5% of one channel's billing revenue outright.
Both are the same mistake: a day boundary computed in more than one place, by more than one person, from a constant instead of a helper. Longer version here.
The pattern across all four: usage billing fails without erroring. Nothing 500s. Credits get granted, invoices generate, and every number is internally consistent with every other number. The system is confidently wrong, which is the only kind of wrong that survives in a billing system for long enough to matter.
Which is why the most valuable thing we built wasn't the rate card or the waterfall — it was the dashboard that put usage and charges side by side per account. It didn't fix anything. It just made the disagreements visible, and everything else followed from being able to see them.
If you're building this now, in order:
(tier, country, usage_type), with a country fallback, old
versions retained, and a rate_at(date) lookup.
Metering is an events problem. Billing is a units problem wearing an events problem's clothes.