Back to blog
Article

Metering usage accurately enough to bill on

Metering usage accurately enough to bill on
S

StriveBit

4 min readSaaS Development

A metering ledger you can trust

A client runs a platform where customers pay per document processed through their LLM pipeline. Every request hits the API, the pipeline runs, and we record a usage event. At the end of the month, we sum events per customer and bill through Stripe.

The hard part is not the sum. The hard part is that usage events arrive late, arrive duplicated, and sometimes arrive after you have already invoiced.

Write events to a ledger first

We use a `usage_events` table as the source of truth. The billing process reads from it, but nothing in the application reads from it directly for billing decisions — it is append-only.

CREATE TABLE usage_events (
  id BIGSERIAL PRIMARY KEY,
  tenant_id UUID NOT NULL,
  event_type TEXT NOT NULL,
  event_id TEXT NOT NULL,
  units INT NOT NULL,
  occurred_at TIMESTAMPTZ NOT NULL,
  recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  invoiced_at TIMESTAMPTZ,
  UNIQUE (tenant_id, event_id)
);

The `event_id` is generated by the calling service — we use a UUID per pipeline run. The unique constraint handles deduplication: if the pipeline retries and re-emits the same event, the insert fails and we catch the constraint violation.

`occurred_at` is when the work actually happened. `recorded_at` is when we wrote the row. The gap between them matters.

Late events and the billing window

The pipeline pushes events to a queue. Most arrive within seconds. Some arrive minutes later when the queue backs up. A few arrive hours later when a downstream service was down and the retry buffer flushed.

If we bill on the first of the month for the previous month's usage, we have a choice: close the billing period and miss late events, or wait. We close the period but do not ignore stragglers.

The billing job marks every event with `occurred_at` in the billing period by setting `invoiced_at`. Late events with `occurred_at` in the previous period but `recorded_at` after invoicing get picked up in the next cycle as an adjustment.

func billPeriod(ctx context.Context, tenantID string, start, end time.Time) error {
    tx, _ := pool.BeginTx(ctx, pgx.TxOptions{})
    defer tx.Rollback(ctx)

    var total int
    _ = tx.QueryRow(ctx, `
        SELECT COALESCE(SUM(units), 0)
        FROM usage_events
        WHERE tenant_id = $1
          AND occurred_at >= $2 AND occurred_at < $3
          AND invoiced_at IS NULL
    `, tenantID, start, end).Scan(&total)

    _, _ = tx.Exec(ctx, `
        UPDATE usage_events
        SET invoiced_at = now()
        WHERE tenant_id = $1
          AND occurred_at >= $2 AND occurred_at < $3
          AND invoiced_at IS NULL
    `, tenantID, start, end)

    _ = tx.Commit(ctx)
    return stripeReportUsage(tenantID, total)
}

The adjustment shows up on the next invoice. We considered issuing credit notes or running a separate correction invoice, but for amounts under a few hundred rupees it is not worth the accounting overhead. We flag it in the usage report we send to the customer and move on.

What we do not do

We do not use Stripe's real-time metering for this. Stripe's Usage Record API has a 10-minute window for modifying events and does not handle the multi-day-late case well. We push a single aggregated total to Stripe at billing time instead of individual events.

We also do not bill on `recorded_at`. Billing on when we learned about the event rather than when it happened produces invoices that do not match what the customer actually used in a given period. That is a support ticket waiting to happen.

Reconciliation

Once a week we run a reconciliation job that compares the sum of uninvoiced events against the unbilled usage we reported to the customer dashboard. If they drift, the dashboard is showing stale data, which is usually a cache invalidation problem rather than a billing problem. The ledger is the authority; the dashboard is a read model.

For this client, late events average 0.3% of monthly volume. The adjustment on the next invoice is small enough that customers do not notice, and the ledger stays consistent. That is the tradeoff: we accept a one-cycle delay on edge-case events rather than holding the billing run open or building correction invoices. For a platform processing tens of thousands of documents a month, that is the right call.

Back to all articles

Ready to build something great?

We help ambitious teams build software that lasts. If you're interested in working with us or want to discuss your project, let's connect.

Get in touch