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.

KC Ramo · DjangoZen Team Sep 05, 2026 18 min read 201 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.

Draining the buffer without losing a batch

The drain worker is where "rarely lose it" is won or lost. The naive loop — pop an item, insert it, repeat — is both slow and fragile. Read a batch and trim it in one Redis transaction, write it in one statement, and push it back if the database refuses:

BATCH = 1000

def persist_meter_events(payloads):
    MeterEvent.objects.bulk_create(
        [
            MeterEvent(
                account_id=p["account_id"],
                meter=p["meter"],
                quantity=Decimal(p["quantity"]),
                occurred_at=datetime.fromisoformat(p["occurred_at"]),
                idempotency_key=p["idempotency_key"],
                metadata=p.get("metadata", {}),
            )
            for p in payloads
        ],
        batch_size=500,
        ignore_conflicts=True,
    )

def drain_buffer():
    pipe = redis.pipeline()                  # MULTI/EXEC by default
    pipe.lrange("usage:buffer", 0, BATCH - 1)
    pipe.ltrim("usage:buffer", BATCH, -1)
    items, _ = pipe.execute()
    if not items:
        return 0
    try:
        persist_meter_events([json.loads(i) for i in items])
    except DatabaseError:
        redis.rpush("usage:buffer", *items)  # retry later; keys make it safe
        raise
    return len(items)

Because every payload carries its idempotency key, pushing a batch back after a partial failure cannot double-count: the rows that did land are silently skipped next time by ignore_conflicts. The remaining gap is a worker killed between execute() and the insert. If that window matters to you, move items into a per-worker processing list with LMOVE and delete them only after the commit; for most products the simpler version plus a monitored buffer length is enough. Alert when LLEN usage:buffer keeps growing — it is the earliest signal that the database or the worker is unhealthy, long before an invoice is wrong.

Validate at the edge, not in the worker. A payload with an unknown meter name or a negative quantity should be rejected by record_usage with a logged error; once it is in the buffer, the worker has no one to report the problem to, and a single poisoned item that raises on every attempt will block the whole queue.

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.

Events that arrive after the window

A fixed 48-hour window heals everything that arrives within 48 hours and silently ignores everything after. An offline mobile client that syncs after a long weekend, or a partner who replays a week of webhooks, produces events whose occurred_at is older than any hour the job will ever look at again. Those rows sit in the event table and never reach a rollup, an invoice or Stripe.

Drive the rebuild from recorded_at instead: find which hours were touched by rows recorded since the last run, however old they are, and recompute exactly those.

def rebuild_touched(since):
    touched = (MeterEvent.objects
               .filter(recorded_at__gte=since)
               .annotate(hour=TruncHour("occurred_at", tzinfo=UTC))
               .values_list("account_id", "meter", "hour")
               .distinct())

    for account_id, meter, hour in touched:
        agg = (MeterEvent.objects
               .filter(account_id=account_id, meter=meter,
                       occurred_at__gte=hour,
                       occurred_at__lt=hour + timedelta(hours=1))
               .aggregate(quantity=Sum("quantity"), event_max=Max("recorded_at")))
        UsageRollup.objects.update_or_create(
            account_id=account_id, meter=meter, hour=hour, defaults=agg)

Store the since watermark in a small table and advance it only after the loop completes, with a few minutes of overlap to cover transactions that committed late. The fixed-window job can stay as a belt-and-braces nightly pass. Whichever you use, emit a metric for the lag recorded_at - occurred_at: when its tail grows, a client or integration is misbehaving, and when an event lands in an already-invoiced period, the invoicing section below decides where the money goes.

Not every meter is a sum

The rollup above sums, which is right for counters: requests, messages, compute seconds. It is wrong for gauges — storage, active seats, provisioned instances — where each event is a reading of a level, not an increment. Summing hourly storage snapshots of 40 GB across a month bills 29,760 GB instead of 40.

Gauges need two changes. Roll up with Max (the peak reading in the hour) instead of Sum, and price on a time-weighted average across the period:

GAUGE_METERS = {"gb_stored", "active_seats"}

def gauge_average(account, meter, start, end):
    hours = int((end - start).total_seconds()) // 3600
    total = (UsageRollup.objects
             .filter(account=account, meter=meter, hour__gte=start, hour__lt=end)
             .aggregate(s=Sum("quantity"))["s"]) or Decimal("0")
    return total / hours          # hours with no reading count as zero

Decide explicitly what a missing reading means. Treating it as zero under-bills when your sampler is down; carrying the previous reading forward is fairer but needs a gap-fill step. Whichever you choose, write it into the pricing page, because "GB-month" means different things at different vendors and customers will compare.

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.

Volume versus graduated, worked through

The same tier table produces very different bills depending on which model you apply. A volume price function over the same TIERS picks the single rate of the band the total falls into:

def volume_cost(quantity: Decimal) -> Decimal:
    for upto, rate in TIERS:
        if upto is None or quantity <= upto:
            return (quantity * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
RequestsGraduatedVolume
1,000€0.00€0.00
1,500€1.00€3.00
10,000€18.00€20.00
10,001€18.00€15.00
12,000€21.00€18.00

Look at the 10,000 and 10,001 rows. Under volume pricing, one extra request lowers the bill by five euros. Customers notice these cliffs, and the sharp ones will pad their usage to cross them. Volume pricing is still a legitimate choice — it is simple to explain and rewards commitment — but model the boundaries before you publish them. Graduated pricing is continuous by construction, which is why it rarely generates disputes.

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.

Totals versus deltas

Stripe meters aggregate the events you send — for a sum meter, every event's value is added to the customer's usage. That interacts badly with rollups that recompute. If an hour was reported as 480 requests and a late event later moves the rollup to 481, sending the new total adds 481 on top of 480; relying on the deterministic idempotency key instead means Stripe returns the earlier response and the extra request is never billed. Stripe also only remembers idempotency keys for a limited time (on the order of a day), so a replay older than that is not protected by the key at all.

The robust shape is to report deltas and keep a ledger of what you have already sent:

class StripeReport(models.Model):
    rollup     = models.ForeignKey(UsageRollup, on_delete=models.PROTECT)
    sequence   = models.PositiveIntegerField()
    value      = models.BigIntegerField()
    identifier = models.CharField(max_length=120, unique=True)
    sent_at    = models.DateTimeField(null=True)

def report_delta(rollup_id):
    with transaction.atomic():
        rollup = UsageRollup.objects.select_for_update().get(pk=rollup_id)
        sent = rollup.stripereport_set.aggregate(v=Sum("value"))["v"] or 0
        delta = int(rollup.quantity) - sent
        if delta <= 0:
            return None            # decreases go through the adjustment path
        seq = rollup.stripereport_set.count() + 1
        report = StripeReport.objects.create(
            rollup=rollup, sequence=seq, value=delta,
            identifier=f"{rollup.account_id}:{rollup.meter}:{rollup.hour:%Y%m%d%H}:{seq}")

    stripe.billing.MeterEvent.create(
        event_name=rollup.meter,
        identifier=report.identifier,
        payload={"stripe_customer_id": rollup.account.stripe_customer_id,
                 "value": str(delta)},
        timestamp=int(rollup.hour.timestamp()),
    )
    StripeReport.objects.filter(pk=report.pk).update(sent_at=timezone.now())
    return report

The ledger row is committed before the API call, so a crash after sending leaves a row with sent_at empty; a sweeper resends those with the same identifier, which Stripe uses to deduplicate meter events. Your database, not Stripe's retention window, now decides what has been billed, and the reconciliation job has a local record to compare against.

Decreases are the awkward case. Meters are designed to accumulate, so a rollup that shrinks after a correction should not be "reported back" as a negative value. Handle it on the invoice instead — a negative invoice item or a credit note against the period — and record that adjustment in your own tables so reconciliation expects it.

What int() does to fractional usage

Meter event values are whole numbers, which is why report_hour converts with int(). For request counts that is harmless. For fractional meters it is a silent revenue leak: int(Decimal("0.73")) is 0, and a customer storing 0.73 GB every hour is never billed for anything. The fix is to choose the reporting unit so that real quantities are integers — megabytes instead of gigabytes, milliseconds instead of seconds, thousandths of a unit — and configure the Stripe price per that unit. If you cannot, carry the fractional remainder forward in your ledger and report it once it accumulates to a whole unit, so the truncation is deferred rather than lost.

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)

Making notifications exactly-once

The check above is correct with one worker and wrong with two: both read "not notified", both send the email, both create a row. Move the "once" into the database, just like the meter events:

class Notified(models.Model):
    account = models.ForeignKey(Account, on_delete=models.CASCADE)
    period  = models.CharField(max_length=20)       # e.g. "2026-09"
    pct     = models.PositiveSmallIntegerField()

    class Meta:
        constraints = [models.UniqueConstraint(
            fields=["account", "period", "pct"], name="notified_once")]

def check_thresholds(account):
    spend = current_period_spend(account)
    for pct in (50, 80, 100):
        if spend < account.budget * pct / 100:
            break
        with transaction.atomic():
            _, created = Notified.objects.get_or_create(
                account=account, period=account.period, pct=pct)
            if created:
                transaction.on_commit(lambda pct=pct: notify(account, pct, spend))

get_or_create relies on the unique constraint to resolve the race: the loser gets an IntegrityError internally and falls back to fetching the winner's row. Sending on commit means a rolled-back transaction never produces an email about a threshold that was not recorded. If notification delivery itself fails, retry it from a queue; do not delete the row, or the customer gets the same warning every hour.

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.

A breakdown that sums exactly

The drill-down page is only convincing if its rows add up to the invoice line to the cent. The trick is to show quantities per day and compute money once, on the total. Pricing each day separately and summing reintroduces per-row rounding, and under graduated pricing a "cost per day" is not even well defined — the day that crosses a tier boundary is priced differently depending on what came before it.

def daily_breakdown(period, meter, tz):
    return (UsageRollup.objects
            .filter(account=period.account, meter=meter,
                    hour__gte=period.start, hour__lt=period.end)
            .annotate(day=TruncDate("hour", tzinfo=tz))
            .values("day")
            .annotate(quantity=Sum("quantity"))
            .order_by("day"))

Group by day in the customer's time zone so their "Tuesday" matches their own logs, and show the frozen period total at the bottom, read from BillingPeriod.totals rather than recomputed. If a late correction exists for the period, list it as a separate row labelled as an adjustment carried to the next invoice. Customers accept adjustments they can see; they escalate numbers that changed without explanation.

Troubleshooting billing discrepancies

When a customer disputes a number, the question is always which layer disagrees with which. Work down the pipeline — events, rollups, ledger, Stripe, invoice — and the first layer that does not match the one above it is where the bug is.

SymptomLikely causeWhere to look
Rollups lower than the event sum for old hoursEvents recorded after the rebuild window closedRows where recorded_at - occurred_at exceeds the window; switch to the recorded_at watermark rebuild
Stripe consistently a little below your rollupsLate events after an hour was reported, blocked by the deterministic idempotency keyCompare rollup vs ledger per hour; move to delta reporting
Stripe roughly double for one dayReplay of old reports after Stripe's idempotency window expiredLedger rows with duplicate hours; use meter event identifier and a local ledger
Fractional meter bills zeroint() truncation of sub-unit valuesReporting unit; carry-forward remainder
Hour buckets at :30TruncHour using the active non-UTC time zonePass tzinfo=UTC; rebuild affected hours
Invoice line differs from breakdown by a centMoney computed per day and summedShow quantities per day, price the total once
Usage buffer length climbingDrain worker failing on a poisoned payload or database unavailableWorker logs; validate payloads in record_usage
Customer received the 80% email twiceCheck-then-create race across workersUnique constraint on the notification table

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.