Billing systems · Guide ·

How to bill AI usage without making a loss

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.

[ 01 — The unit ]

Decide what one credit actually is

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.

The decision

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.

what you sold
operations
10,000 / mo
convert once
what you store
money
$40.00
convert on read
what you show
operations
7,240 left

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.

[ 02 — Pricing ]

One rate card, versioned, never hand-typed

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:

Fail loud

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.

[ 03 — Units ]

Voice is seconds. Text is operations.

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:

text
operations
image
operations
audio
seconds

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.

[ 04 — The event ]

The usage event contract

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.

1
Who — tenant id
Partition key for everything downstream.
2
When — UTC timestamp
Store UTC. Bill on local-day boundaries. Never the reverse.
3
What tier — the pricing class, not the model name
Log the model string too, but price off the tier. Model names change weekly; your rate card shouldn't.
4
What type — text / image / audio / video
Selects the unit and the rate-card column.
5
How much — seconds for audio, weight for the rest
Defaults to 1 so a forgetful caller still bills something sane.
6
Billable? — an explicit boolean
The one that will hurt you. See below.

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.

Make absence loud

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.

[ 05 — Credits ]

Free credits are a balance, not a counter

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:

free credit pool — $23.20
wallet — $16.80
day's cost: $40.00 free drained first, remainder billed

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:

[ 06 — The job ]

The nightly run

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.

all day
Features emit usage events
Six fields, fire and forget, into whatever you already use for analytics. No wallet reads on the hot path.
~3am local
Guard against a repeat run
Query the ledger for entries already covering this tenant and this date. If any exist, skip and alert. Crons fire twice; this is the cheapest idempotency you will ever write.
then
Aggregate the day
Group by (tier, usage_type, billable). Sum seconds for audio, weight for everything else. One query per tenant, not one per event.
then
Price, then deduct
Rate card + markup + FX → cost per bucket. Free pool first, wallet for the remainder, both as ledger entries.
finally
Emit what you charged
A second stream of events describing the deduction — amount, currency, which pool it came from, the per-tier breakdown. This is what your dashboards read. Never recompute charges for display.

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.

[ 07 — Time ]

Two clocks, one alignment key

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.

The fix is one field

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.

[ 08 — Display ]

Turning money back into what you sold

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:

at signing
50,000 ops
+3% FX
48,295 ops
+6% FX
47,222 ops
+12% FX
44,736 ops

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.

Pick your poison deliberately

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.

[ 09 — From production ]

What actually broke

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.

1 — A rate field that behaved like a divisor

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.

2 — The same field, in the other direction

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.

3 — A migration that inherited the bug

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.

4 — A time constant that was right in one country

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.

[ 10 — Reference ]

The checklist

If you're building this now, in order:

Metering is an events problem. Billing is a units problem wearing an events problem's clothes.