Django Advanced

Usage-Based Billing for Django SaaS: Metering, Aggregation, Stripe Meters, and Invoicing

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.

DjangoZen Team Sep 05, 2026 20 min read 14 views

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.

The meter event is the whole foundation

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.
  • Idempotency key, enforced by the database. Not by an if exists check, which races. Retries are normal, not exceptional.
  • Append-only. Corrections are new negative rows, never edits. Someone will ask why last month's invoice changed, and "we updated a row" is not an answer.

Recording without slowing the request

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.

Aggregating without melting the database

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.

Pricing models, and the one that hurts

Three shapes cover nearly all pricing, and the third causes almost all the confusion:

  • Per unit. €0.002 per request. Trivial.
  • Volume. Total usage picks one price, applied to everything. 12,000 requests in the 10k+ band at €0.0015 → all 12,000 at €0.0015.
  • Graduated (tiered). Each tier is charged at its own rate. First 1,000 free, next 9,000 at €0.002, remainder at €0.0015.
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.

Handing it to Stripe

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.

Reconcile, always

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})")

Limits, alerts, and not surprising anyone

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:

  • A spend cap per account, checked cheaply against a cached running total. Above it: throttle, queue, or refuse — a choice the customer makes, not you.
  • Threshold notifications at 50%, 80% and 100% of their expected spend, each sent once per period.
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)

The invoice a customer can verify

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.

Checklist before charging real money

  • Idempotency enforced by a database constraint, not application logic.
  • Decimal end to end; rounding applied once, explicitly.
  • occurred_at and recorded_at stored separately.
  • Rollups recompute a trailing window and are safe to re-run.
  • Metering can never fail a user request.
  • Daily reconciliation with the payment provider, with an alert on drift.
  • Spend caps and threshold notifications live before your first large customer.
  • Every invoice line drills down to a breakdown that sums exactly.
  • Periods are frozen on invoicing; corrections are new rows.

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.