TL;DR: A usage metering API has one job that a normal API does not: every event it accepts must still be countable, exactly once, months later, in front of a customer disputing the number. That constraint drives every design decision below — idempotency keys and their window, event identity, late and out-of-order arrival, aggregation that survives a mid-period configuration change, and the ability to explain how a total was reached. This is the reference page for building or evaluating one.
Most billing projects fail at ingestion, not at the invoice. The invoice endpoint is the part everyone designs carefully because it is visible. Ingestion is the part that quietly loses three per cent of a customer's traffic and is discovered a quarter later during an argument.
This page is the architecture, in the order the problems actually bite.
1. The request shape
A metering endpoint should accept a batch, require almost nothing, and never make the caller look something up first.
POST /api/v1/usage
x-api-key: <key>
Idempotency-Key: 8f14e45f-ceea-467a-9b1e-3f0a1d3c9b21
[{ "meter": "meter.api-requests", "value": 42 }]
The single most damaging design mistake in a metering API is requiring the caller to supply identifiers they do not have at the call site. If sending one event needs a subscription id, a product id and a meter id, the integration stalls — because the developer instrumenting the code path is not the person who configured the catalog, and none of those ids are in scope where the event happens.
Require the quantity. Infer the rest where it is unambiguous, and say clearly in the response when you did.
2. Idempotency, and the window nobody documents
Clients retry. Networks fail after the write and before the response. A metering API that double-counts on retry is worse than one that is occasionally down, because the failure is silent and lands on an invoice.
So: accept an Idempotency-Key, and publish three things about it.
- The window. How long the marker lives. After it expires, the same key will be treated as new and will double-count. Every vendor has a window; most do not say what it is. Ours is 14 days for the request marker and 45 for per-record markers.
- The scope. Keys must be namespaced per account. If they are global, the first customer to use
1takes it from everyone else, and the second customer gets a conflict for a key they have every right to use. This is a real bug, not a hypothetical — it is easy to fix the request marker and forget the per-record one. - The conflict semantics. Same key, same payload is a safe replay. Same key, different payload is a client bug and should be a hard error, not a silent overwrite.
One subtlety worth stating: the fingerprint that decides "same payload" must exclude a receive-time default timestamp. Otherwise every retry looks like a different payload, because each HTTP attempt arrives at a different instant, and the safety mechanism rejects the very retries it exists to protect.
3. Event identity is not request identity
These are two different problems and conflating them is the most common design error after the window.
The request marker answers "have I processed this HTTP call before". The event marker answers "have I already counted this specific event". If event identity is derived from the request — request_id:index, say — then the same logical event resent inside a different batch counts twice, because it is a different request.
If your callers may re-batch, accept a per-event transaction_id and dedupe on that. Stripe dedupes on an identifier, Lago on a transaction id, OpenMeter on source plus id. If you only have request-scoped identity, document that limitation loudly, because it determines how the client must batch.
4. Late and out-of-order events
An event timestamped last Tuesday can arrive today. A queue backed up, a mobile client was offline, a batch job ran late. There are exactly three defensible policies:
- Fold it in. The event lands in the period its timestamp belongs to, even if that period has been invoiced. Simple and honest, but it means invoices can change after issue, so you need corrections.
- Reject beyond a horizon. Anything older than N days is refused with a clear error. Predictable, and it makes closed periods truly closed.
- Accept and attribute to now. Simplest, and wrong for anyone who cares which month the usage happened in.
Pick one and write it down. The failure mode is not choosing badly — it is not choosing, and then discovering the behaviour during a dispute.
There is a trap here worth flagging, because it is easy to build by accident: an unlimited backdating policy is incompatible with expiring dedupe state. If unique-member markers expire after 45 days but a legitimate 90-day-old event is still accepted, that member gets counted twice in a month that was already closed. Either bound the backdating horizon or retain the dedupe state for the life of the period it protects. You cannot have both.
5. Aggregation that survives configuration changes
Meters have an aggregation: sum, count, max, unique count. The subtlety is what happens when someone changes it mid-period.
If the rollup document is keyed without the aggregation, flipping a meter from sum to count means the new aggregation starts incrementing on top of the old one's accumulated total — a meter sitting at 4.2 million units suddenly reports 4,200,001 "events". Include the aggregation in the rollup identity, or refuse the change mid-period. Silently continuing is the one option that produces a number nobody can explain.
unique_count deserves its own note: it needs membership state, not a counter. "How many distinct users this month" cannot be answered by incrementing, so you are storing a set — and that set's retention is now part of your billing correctness, as above.
6. Rating: quantity is not amount
Metering produces quantities. Rating turns a quantity into money by walking a chain:
usage event → subscription which customer is being billed → plan what they agreed to → charge which meter and item this covers → meter how values combine (sum/count/max/unique) → rated line quantity × price, at the price in force AT EVENT TIME
That last clause is the one that gets skipped, and it is the expensive one. If prices are edited in place, no past invoice can ever be reconstructed. The moment someone corrects a typo in a unit price, every invoice raised under the old value becomes unexplainable, and every dispute about them becomes unwinnable.
Prices must be append-only: close the old version with an end timestamp, write a new one, and rate against whichever was in force when the event happened. This is cheap to build before you have rating and nearly impossible to retrofit afterwards, because the data you would need to rebuild history is exactly what the overwrite destroyed.
7. Explainability
The final requirement is the one that separates a metering system from a counter: given a number on an invoice, can you show the events behind it?
Practically that means raw events are retained and exportable, rollups record which charge and price produced them, and there is a query that answers "show me the events that make up this line". If the answer is "we have the total", you will lose the first serious dispute, and losing it will cost more than the feature would have.
Evaluating someone else's metering API
Five questions, in order. The bad answers are more informative than the good ones.
- What is the dedupe window, in seconds? Bad answer: "it's idempotent".
- Is the idempotency key scoped per account? Bad answer: "it's a unique key".
- What happens to an event timestamped inside a closed period? Bad answer: "that shouldn't happen".
- Are prices versioned? Bad answer: "you can see the current price".
- Can I export raw events continuously in a documented format? Bad answer: "you can export invoices".
The rest of the cluster
Everything we have written on this topic, grouped by where it fits, is on the usage metering hub. The pages below are the ones that follow directly from this one.
- Idempotent usage metering: dedupe and late events
- Designing meters: value types and aggregations
- Metering monthly active users
- Rate limits, quotas and billing meters are three different things
- Why a SQL table breaks invoices
- usagedb internals: idempotent dedupe
- usagedb internals: rollups and watermarks
- What a billing API must actually do