Per-seat pricing is a counter. Usage pricing is a distributed accounting system. Record events idempotently, aggregate without melting the database, reconcile with Stripe, and make an invoice a customer can verify.
Flat subscriptions are a boolean: are they paying or not. Usage-based pricing turns billing into a data pipeline, and every mistake in it is either money you did not collect or a charge you cannot defend. The engineering is not hard, but it is unforgiving about details most features let you get away with.
Everything downstream — invoices, dashboards, disputes — derives from one append-only table. Get its shape right and the rest is arithmetic.
class MeterEvent(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4)
account = models.ForeignKey(Account, on_delete=models.PROTECT)
meter = models.CharField(max_length=50) # "api_request", "gb_stored"
quantity = models.DecimalField(max_digits=18, decimal_places=6)
occurred_at = models.DateTimeField() # when it happened
recorded_at = models.DateTimeField(auto_now_add=True)
idempotency_key = models.CharField(max_length=100)
metadata = models.JSONField(default=dict)
class Meta:
constraints = [
models.UniqueConstraint(fields=["account", "meter", "idempotency_key"],
name="meter_event_idempotent"),
]
indexes = [models.Index(fields=["account", "meter", "occurred_at"])]
Four decisions worth defending:
Decimal, never float. Floats accumulate error, and at a million rows that error is on an invoice. This is not theoretical.occurred_at separate from recorded_at. A retried webhook or a queued batch arrives late; it must still bill to the period in which it happened.if exists check, which races. Retries are normal, not exceptional.Metering sits in the hot path of the very endpoints you meter, so it must be cheap and it must never fail the request.
def record_usage(account_id, meter, quantity=1, *, occurred_at=None, key=None):
payload = {
"account_id": str(account_id),
"meter": meter,
"quantity": str(quantity),
"occurred_at": (occurred_at or timezone.now()).isoformat(),
"idempotency_key": key or str(uuid.uuid4()),
}
try:
redis.rpush("usage:buffer", json.dumps(payload))
except RedisError:
logger.exception("usage buffer unavailable")
persist_meter_events([payload]) # fall back to a direct write
A worker drains the buffer every few seconds with bulk_create(..., ignore_conflicts=True), which turns the unique constraint into free deduplication. One insert per API call will become your slowest query long before it becomes your biggest table.
Never let metering raise into the request. A billing outage that takes down the product costs more than the usage you failed to record — and the fallback path means you rarely lose it anyway.
Summing raw events per invoice works until the table has a hundred million rows. Roll up hourly, then read the rollups.
class UsageRollup(models.Model):
account = models.ForeignKey(Account, on_delete=models.CASCADE)
meter = models.CharField(max_length=50)
hour = models.DateTimeField()
quantity = models.DecimalField(max_digits=20, decimal_places=6)
event_max = models.DateTimeField() # newest recorded_at folded in
class Meta:
constraints = [models.UniqueConstraint(
fields=["account", "meter", "hour"], name="rollup_unique")]
The job recomputes recent hours rather than assuming they are final — late events are guaranteed, not hypothetical:
def rebuild_rollups(since_hours=48):
start = timezone.now().replace(minute=0, second=0, microsecond=0) \
- timedelta(hours=since_hours)
rows = (MeterEvent.objects
.filter(occurred_at__gte=start)
.annotate(hour=TruncHour("occurred_at"))
.values("account_id", "meter", "hour")
.annotate(quantity=Sum("quantity"), event_max=Max("recorded_at")))
UsageRollup.objects.bulk_create(
[UsageRollup(**r) for r in rows],
update_conflicts=True,
update_fields=["quantity", "event_max"],
unique_fields=["account", "meter", "hour"],
)
Two properties make this safe: it is idempotent (run it twice, same answer) and it recomputes rather than increments, so a missed run heals itself on the next pass. Increment-based rollups drift, and drift in billing is a support ticket.
Three shapes cover nearly all pricing, and the third causes almost all the confusion:
TIERS = [(1_000, Decimal("0")), (10_000, Decimal("0.002")), (None, Decimal("0.0015"))]
def graduated_cost(quantity: Decimal) -> Decimal:
total, remaining, previous = Decimal("0"), quantity, 0
for upto, rate in TIERS:
band = remaining if upto is None else min(remaining, upto - previous)
if band <= 0:
break
total += band * rate
remaining -= band
previous = upto or previous
return total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
Round once, at the end. Rounding each tier compounds a cent per tier per invoice, and a customer with a spreadsheet will find it. Decide ROUND_HALF_UP explicitly rather than inheriting whatever the default is.
Stripe's meters accept usage events and do the tier arithmetic on the subscription. Report the rollups, not raw events — fewer calls, and your own numbers stay the source of truth.
def report_hour(rollup):
stripe.billing.MeterEvent.create(
event_name=rollup.meter,
payload={"stripe_customer_id": rollup.account.stripe_customer_id,
"value": str(int(rollup.quantity))},
timestamp=int(rollup.hour.timestamp()),
idempotency_key=f"{rollup.account_id}:{rollup.meter}:{rollup.hour:%Y%m%d%H}",
)
The idempotency key is deterministic on purpose: replaying a day of reports charges nothing twice. Note also that Stripe rejects events timestamped too far in the past — so late-arriving usage beyond that window becomes a manual invoice item, and you want an alert when it happens rather than silence.
Run a daily job comparing your rollups with what Stripe recorded. Discrepancies are normal in small numbers and catastrophic when discovered at year end.
def reconcile(account, period_start, period_end):
ours = rollup_total(account, period_start, period_end)
theirs = stripe_meter_total(account, period_start, period_end)
drift = abs(ours - theirs)
if drift > ours * Decimal("0.001"):
alert(f"usage drift {drift} for {account} ({ours} vs {theirs})")
The fastest way to lose a customer on usage pricing is a bill they did not see coming. Two features prevent nearly all of it:
def check_thresholds(account):
spend = current_period_spend(account) # cached, refreshed hourly
for pct in (50, 80, 100):
if spend >= account.budget * pct / 100:
if not Notified.objects.filter(account=account, period=account.period,
pct=pct).exists():
notify(account, pct, spend)
Notified.objects.create(account=account, period=account.period, pct=pct)
Give every line a drill-down. "API requests — 143,208 — €221.31" should link to a page that shows the daily breakdown adding up to exactly that number. This one page removes most billing support tickets, because the customer answers their own question.
Two rules keep it defensible. Freeze the period when you invoice — write a BillingPeriod row with the totals and mark it closed, so a late event lands in the next period as an adjustment rather than silently altering an invoice you already sent. And store the price version on the invoice: when you change your rates next year, last year's invoice must still recompute to the amount you charged.
Decimal end to end; rounding applied once, explicitly.occurred_at and recorded_at stored separately.Usage billing is one of the few features where a rounding bug is a legal problem rather than a cosmetic one. Build the boring parts — constraints, idempotency, reconciliation — first, and the pricing experiments on top become safe to run.