Skip to content
← All posts
Billing 6 min read

Usage Metering That Still Holds Up When a Customer Disputes It

When a customer challenges an invoice, an aggregate number isn't a defense — the raw events behind it are.

A customer emails: "Your invoice says 40,000 API calls, we only made 12,000." If the only thing your metering system stores is a monthly total, there is no good answer to that email. You either credit the charge back and take the trust hit, or you argue an aggregate number against a customer who has no visibility into how it was derived. Neither position holds up.

This isn't a billing bug. It's a decision made much earlier, the moment someone chose to store running counts instead of events. Every metering system we've built — API call counters, AI token wallets, per-seat SMS credits — starts from the same constraint: every line on an invoice has to trace back to a specific, timestamped, idempotent record that survives a customer asking you to prove it.

The fix costs storage, not much else. What it changes is how you think about the metering pipeline from the first line of code, not after the first dispute lands.

What breaks when metering is aggregate-only

The common shortcut looks like this: a request comes in, you run UPDATE usage SET count = count + 1 WHERE tenant_id = ?, and at the end of the month you multiply the count by a rate. It works fine until something ordinary happens: a webhook gets redelivered, a worker retries after a timeout, a client library retries a 502. Each retry increments the counter again, silently, with nothing recording that it happened twice.

Now the invoice is wrong and you don't know it yet. Worse, when the customer disputes it, there's no record to consult. The count is the count. You cannot subtract the retries because you never recorded which increments were retries and which were real usage.

Store the event, not just the count

The alternative is to treat metering as an append-only event log and compute aggregates from it, never the other way around. A minimal schema:

CREATE TABLE usage_events (
  event_id      TEXT PRIMARY KEY,      -- idempotency key
  tenant_id     UUID NOT NULL,
  metric        TEXT NOT NULL,        -- 'api_call', 'ai_token', 'sms_sent'
  quantity      NUMERIC NOT NULL,
  resource_ref  TEXT,                 -- endpoint, model, campaign id
  occurred_at   TIMESTAMPTZ NOT NULL,
  ingested_at   TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX ON usage_events (tenant_id, metric, occurred_at);

Aggregation for the invoice becomes a SUM(quantity) query with a date range filter, not a mutable counter. That query is re-runnable at any time, against any window, which is exactly the capability you need when someone challenges a number six weeks after the fact.

Idempotency keys stop the dispute before it starts

The event_id column is the load-bearing part of that schema. Generate it deterministically — a hash of tenant, resource, action, and a request-scoped identifier — and insert with ON CONFLICT (event_id) DO NOTHING. A retried webhook, a redelivered queue message, or a client that resends after a timeout all produce the same event_id and land as a no-op the second time.

This is the same discipline we apply to entitlement gating: the check that decides whether a tenant is allowed to make the call should never be the same code path that records the billing event, because a bug in one shouldn't corrupt the other. We've built this separation into subscription billing and AI spend wallets with auto-recharge, where a single over-count on a token metric can silently drain a tenant's balance and trigger a support ticket that looks like a billing error but is really a retry bug.

Reconcile before the invoice goes out, not after the complaint comes in

Generate a per-tenant reconciliation report at invoice time, before the email goes out: raw event count for the period, the aggregate used for billing, the dollar amount, and the delta from the prior period. If a tenant's usage jumped 300% with no corresponding change in their account activity, that's worth a look before they see the number, not after. This is the same principle we use for reconciliation reports on data migrations — the report has to exist before someone asks for it, or it doesn't count as evidence.

A simple decision rule: if the reconciliation report and the invoice total don't match to the cent, the invoice does not go out automatically. It gets a human review. That one gate has caught more billing errors, in our experience, than any amount of code review on the metering pipeline itself.

The dispute workflow: replay, don't argue

When a customer disputes a charge, the correct response is not a re-explanation of the pricing model. It's a query: pull every raw event for the disputed tenant and window, group by resource or endpoint, and hand over the breakdown. If their number and yours disagree, the disagreement will show up as a specific set of events, not as a vague gap between two totals.

  • Filter usage_events by tenant_id and the disputed date range.
  • Group by metric and resource_ref to show where the volume came from.
  • Cross-check occurred_at against the customer's own logs if they can supply them.
  • If duplicates are found, check whether their event_id hashes collide — that's a client-side retry, not a metering bug.
  • Issue a credit against the specific events found to be wrong, not a flat percentage off the invoice.

That last step matters more than it looks. A flat percentage credit settles the conversation but teaches you nothing about whether your metering has a systemic problem. A credit tied to specific event IDs tells you exactly what broke, which is the same instinct behind keeping audit logs that answer the question someone will actually ask instead of a generic activity feed nobody can query under pressure.

The credit you issue should be traceable to specific events, or you've learned nothing from the dispute.

The honest tradeoff: raw events are not free

Keeping every raw event forever gets expensive fast, especially for high-cardinality metrics like AI token usage, where a single tenant can generate hundreds of thousands of events a day. Storing that at full fidelity indefinitely across hundreds of tenants is a real storage and index cost, and it's fair to ask whether it's worth it.

Our practical answer: keep raw events at full fidelity for the current billing cycle plus one prior cycle, since that covers the overwhelming majority of disputes, which surface within 30 to 60 days of the invoice. After that window, roll events into compressed daily aggregates per tenant per metric, and archive the raw rows to cold storage rather than deleting them outright. You keep the ability to reconstruct a dispute from months ago without paying full index cost on data nobody queries anymore. If your billing volume is small enough that storage cost is trivial, skip the rollup entirely and keep everything hot — the added complexity of a rollup job isn't worth it below a few million events a month.

Where this fits in the broader platform

Metering rarely lives alone. It sits next to entitlement checks that gate features, a billing system that turns aggregates into invoices, and often an admin console someone in support uses to issue a credit without touching the database directly. Getting the events-first design right early is far cheaper than retrofitting it once a support team is fielding disputes with nothing but a monthly total to point to. If you're scoping this as part of a larger platform build, it's worth treating metering as its own subsystem in the services we build rather than a side effect of the billing integration — the two have different failure modes and different audiences for the data they produce. We've run this pattern across 77 shipped platforms and it's one of the few pieces of SaaS infrastructure where the up-front cost is small and the payoff shows up exactly once, on the day a customer pushes back on an invoice and you can answer in one query instead of a week of Slack threads. If you want a second opinion on how your current metering pipeline would hold up under that test, get in touch.

Frequently asked

What's the difference between usage metering and billing?

Metering is the record of what happened — raw, timestamped events describing usage. Billing is the interpretation of that record into a price. Keeping them as separate systems, with billing derived from metering rather than storing its own counts, is what lets you re-run an invoice calculation and get the same answer twice.

How long should we keep raw usage events?

Keep full-fidelity events for the current billing cycle plus one prior cycle at minimum, since most disputes surface within 30 to 60 days. Beyond that, roll up to daily aggregates and archive the raw rows to cold storage instead of deleting them, so a dispute from months back can still be reconstructed if needed.

Can idempotency keys alone prevent billing disputes?

They prevent one major class — double-counting from retries and redelivered webhooks — but not all of them. Disputes also arise from unclear pricing, mid-cycle plan changes, or the customer's own usage tracking disagreeing with yours. Idempotency keys make your side of the ledger trustworthy; they don't resolve pricing disagreements.

Should support staff be able to issue billing credits directly?

Yes, but only against specific events or line items, never as a flat percentage off the total invoice through a raw database edit. Build a narrow admin action that references the disputed event IDs, so the credit is auditable and the underlying metering issue, if there is one, gets flagged rather than quietly written off.