Metering Is a Ledger, Not a Counter

I now run four products that charge for usage rather than for a seat: emails sent, documents scanned, audits run, characters judged. Each one arrived at the same set of problems in roughly the same order, and each one initially modeled usage as a counter you decrement.
A counter is the wrong model. What you want is a ledger with reservations, and the difference shows up the first time a job fails halfway through.
Start with the unit, and write it down
Before any code, the meter needs a definition precise enough to print on an invoice. “One credit is one email.” Not one send attempt, not one recipient, not one broadcast.
That sounds obvious until you hit the cases. Does a bounce consume a credit? (Yes, in my case: the provider charged me and the mail left the building.) Does a double opt-in confirmation email? (Also yes, and I went back and forth on this. It costs the same to send as any other mail, and pretending otherwise means a free channel for abuse.) Does a message to a suppressed address that never gets attempted? (No, nothing happened.)
Write the answers down before you build, because every one of them is a support conversation later, and the version you improvise under pressure will contradict the one in your docs.
The related decision is how many meters the customer sees. One of my products has three genuinely different cost drivers: outbound email, LLM relevance judging, and rendered page fetches. The customer sees one balance. Internally each activity has its own conversion rate onto that balance, so an email is one credit, judging is one credit per 4,000 characters, and a rendered fetch is two. One meter, published conversion rates. Three separate balances would be technically honest and commercially useless.
Charge for volume, not for events
Metering by event count is the intuitive choice and it misprices badly whenever your events vary in size.
The LLM filter made this vivid. The items it judges range from an 80-character headline to a dependency release note; the longest real one I measured runs about 13,600 characters. Charging per item makes the headline watcher subsidize the changelog watcher, which is backwards, since the changelog is the expensive one. Charging per character means a credit buys a fixed volume of judged text and the cost lands where the work is.
The rule: meter the thing your own cost is proportional to. If your provider bills you by tokens, bytes, or seconds, a per-request price is a bet that your customers’ requests are all the same size, and they never are.
The debit has to be atomic with the work
The core mechanic is that spending and doing must not be separable. The naive shape has a race:
account = get_account(account_id)
if account.balance >= 1:
account.balance -= 1
save(account)
do_the_work()
Two workers, a balance of 1: both read 1, both pass the check, both work. The read-check-write gap is the whole problem, so remove it and let the database evaluate the condition and apply the change in one statement:
UPDATE accounts
SET credit_balance = credit_balance - :n
WHERE id = :account_id
AND credit_balance >= :n
RETURNING credit_balance;
A returned row means the debit happened and you may proceed. No row means the balance was insufficient at the moment of truth. Row-level locking makes concurrent updates queue and re-evaluate against the latest value, so two workers racing a balance of 1 get exactly one success between them, every time.
Reservations, for work that takes a while
Atomic debit solves the instantaneous case. It does not solve the case where the work takes thirty seconds and can fail.
If you debit up front and the job dies, you’ve charged for nothing. If you debit at the end, you’ve done unpaid work and a customer at zero balance can queue a hundred jobs. Neither is acceptable, so the pattern is two-phase:
- Reserve at the start: an atomic debit that also writes a reservation record tied to the job.
- On success, settle: mark the reservation consumed. The credits are spent, the ledger says why.
- On terminal failure, compensate: credit the balance back and mark the reservation released.
The subtle rule sits in step 3: refund only genuinely claimed failures. A job that was claimed by a worker and then failed gets its reservation released. A job that was never claimed, or whose outcome is still unknown, does not, because a refund on an unknown outcome is how you build a free-usage loop. Somebody submits, the result is ambiguous, the credits come back, repeat.
I got this wrong in an early version by refunding on any non-success status, including “still running.” Reservations are a state machine, and “unknown” has to be its own state that resolves in one direction only.
The ledger underneath
The balance column is a cache. The source of truth is an append-only table where every top-up, debit, reservation, release, and adjustment is a row that is never updated or deleted.
CREATE TABLE credit_transactions (
id bigserial PRIMARY KEY,
account_id uuid NOT NULL,
delta integer NOT NULL,
reason text NOT NULL,
meta jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
);
The conditional update and the ledger insert go in the same transaction, so the running balance and the history cannot drift. This is double-entry bookkeeping’s central insight applied to a credits table: record movements, derive balances.
An append-only ledger feels like ceremony for a small product, and it pays for itself the first time somebody asks where their credits went. The answer is a SELECT with a reason column and a meta blob naming the job, rather than an afternoon of log archaeology. Put enough in meta to answer “which job was this,” because you will be asked.
Let one path break the floor, deliberately
There is exactly one place I allow the balance to go negative: a payment dispute. If someone charges back a top-up after spending the credits, the clawback goes through a force-debit path that skips the balance check.
That’s not an oversight, it’s the invariant being stated correctly. The invariant was never “balances are non-negative.” It was “credits are never spent twice.” A negative balance is an accurate record of someone having spent money they took back, and accuracy beats tidiness. Once you can articulate which invariant you actually hold, it becomes clear which paths are allowed to violate the other ones.
Tell people before they hit zero
The last piece is a small state machine that I underestimated. A low-balance warning needs to arm once, fire once, and re-arm only after a top-up.
Without the re-arm rule you pick one of two bad behaviors: warn on every check and nag someone hourly while their balance hovers near the threshold, or warn once ever and stay silent the next time it matters, months later. Same for the “you are out of credits and things have stopped” notice, which is a different message and deserves to be sent exactly once per exhaustion.
Eight rules for a usage meter
- Define the unit precisely enough to invoice, including the awkward cases, before you write code.
- One customer-facing meter, with published conversion rates for each internal activity.
- Meter the dimension your own cost scales with, which is usually volume rather than event count.
- Make the debit atomic with a conditional update, never read-check-write.
- Use reservations for anything that can fail mid-flight, and compensate only claimed failures.
- Keep an append-only ledger with a reason and enough metadata to answer “which job.”
- Know which invariant you actually hold, and let the exceptional path violate the other one openly.
- Arm-once, fire-once, re-arm-on-topup for balance warnings.
Linked from
- The Billing Bug You Can't Have How emit's prepaid credit system makes overdrawing impossible under concurrency, …
Stay in the loop
Get notified when I publish new posts. No spam, unsubscribe anytime.