From the blog

Usage-Based Billing in Elixir and Phoenix: A Complete Guide

By Liam Killingback ·

Usage-Based Billing in Elixir and Phoenix: A Complete Guide

Every SaaS ends up doing the same three jobs. Count what each customer uses. Stop them when they hit their plan’s limit. Bill them for whatever they used beyond it. Rails has Pay and Laravel has Cashier, and hosted metering products like OpenMeter ship SDKs for Node, Python and Go. On Phoenix you have historically hand-rolled all three, usually starting with an UPDATE usage SET count = count + 1 that works fine until it sits on the hot path of every request.

This guide walks through usage-based billing in Elixir end to end: the data model, where the counter should live, how to gate without race conditions, how billing periods and Stripe fit in, and where the free MIT core of Aurora Meter stops and the Pro tier starts. The code is real; you can paste it into a Phoenix 1.7 or 1.8 app today.

What “usage-based” actually means

Three pricing shapes cover almost every product:

  • Hard caps. The Free plan gets 50 AI generations a month. Number 51 is refused until the period rolls over or the customer upgrades.
  • Metered overage. The Scale plan includes 1,000 generations, then charges $0.02 each. Nothing is refused; the invoice grows.
  • Feature flags. API access is on for Pro and off for Free. There is no counter at all, only a yes or no.

A billing system that only handles one of these forces you to bolt the others on later. Design for all three from the start, and keep them in one place so the pricing page, the gate in your code and the Stripe products never drift apart.

The data model

You need four things per customer, which in the examples below is an organisation, not a user:

  1. A plan assignment. Which plan the org is on, and whether the subscription behind it is in a status that grants access (active, trialing, past_due) or not (canceled, unpaid).
  2. A live counter per feature. How many generations this month, right now, including the request that is in flight.
  3. A durable record of usage you intend to invoice. Dashboards can tolerate losing a second of increments in a crash; invoices cannot.
  4. A billing period. In the simplest system that is the UTC calendar month. Once Stripe is involved, it is the subscription’s current period, which starts on the day the customer subscribed.

Most hand-rolled systems put the live counter in Postgres and pay for it on every request. The next section is about why that is the wrong place.

Where the counter lives

An increment on the request path has to be cheap, atomic and concurrent. Postgres row updates are atomic but not cheap: every request takes a connection from the pool, a round trip, a row lock and a WAL write. A GenServer per tenant is cheap but serialises every increment through one mailbox, so a busy tenant becomes a bottleneck and a crashed process loses its state.

ETS with :ets.update_counter/4 is the right primitive on the BEAM. It is atomic, lock-free for distinct keys, and does not involve a process at all:

:ets.update_counter(:usage, {"org_42", :ai_generations, period}, {2, 1}, {key, 0})

That is the entire write path. A single flusher process persists snapshots to Postgres on an interval and once more on shutdown, and a broadcaster fans live values out over Phoenix.PubSub so dashboards update without polling. On a laptop this sustains millions of increments a second across distinct counters; the Aurora Meter benchmark records around 5.5 million per second with eight processes.

The trade-off is honesty about durability. A buffered counter can lose at most one flush interval of increments in a hard crash. For a usage bar that is fine. For anything you invoice, write a raw event row synchronously as well, and treat the buffered counter as the fast view of the same truth.

Gating without the race

The naive gate is two calls: check, then increment.

if AuroraMeter.usage(org, :ai_generations) < limit do
  AuroraMeter.track(org, :ai_generations)
  generate()
end

Two concurrent requests both read 999 of 1,000, both pass, both increment, and the customer got 1,001. Under load this happens constantly, and it is why hard caps in hand-rolled systems are always slightly soft.

The fix is to reserve first. Increment, compare the returned value with the limit, and roll the increment back if it went over. The reservation is the usage, so a limit of n admits exactly n calls no matter how many arrive at once. Aurora Meter’s core does this in with_quota/4:

case AuroraMeter.with_quota(org, :ai_generations, fn -> generate() end) do
  {:ok, result} -> result
  {:error, :limit_exceeded} -> upgrade_prompt()
  {:error, :not_entitled} -> upgrade_prompt()
end

If the wrapped function raises, the reservation is released before the error re-raises, so a failed generation does not count against the customer.

Declaring plans once

The plan definition is the contract between your pricing page, your gates and Stripe. Keep it in code, validated at compile time, and reference it from everywhere else:

defmodule MyApp.Plans do
  use AuroraMeter.Plans

  plan :free do
    price 0
    limit :ai_generations, 50, :hard
    feature :api_access, false
  end

  plan :pro do
    price 2_000
    limit :ai_generations, 1_000, :hard
    feature :api_access, true
  end

  plan :scale do
    price 2_000
    metered :ai_generations, included: 1_000, unit_price: 2
    feature :api_access, true
  end
end

With that in place, AuroraMeter.check(org, :ai_generations) returns :ok, {:error, :limit_exceeded} or {:error, :not_entitled} depending on the plan, and AuroraMeter.quota/2 returns everything a dashboard needs: used, limit, remaining, percent and the current period. A companion post covers the entitlement side in depth.

Billing periods

The free core uses UTC calendar months. A new month starts a fresh counter with no reset job to run and nothing to forget. That is enough for a product with a flat monthly price and a fair-use cap.

The moment money follows usage, the period has to be the subscription’s period. A customer who subscribed on the 20th expects their 1,000 included generations to reset on the 20th, and Stripe will invoice on the 20th whether your counters agree or not. Aurora Meter Pro swaps the period source for one that reads the subscription’s current_period_start and current_period_end, synced from Stripe webhooks, so the meter and the invoice line up to the second.

Reporting usage to Stripe

Stripe’s current model for metered billing is Billing Meters: you define a meter (say ai_generations), attach a usage-based price to it with graduated tiers, and send meter events. The included allowance is a first tier at $0; the overage unit price is the tier above it. Stripe sums the events per period and applies the tiers on the invoice.

The important property of a reporter is exactly-once delivery. If a job sends the same hour’s usage twice, the customer is overbilled; if it crashes after sending and before recording, the next run sends it again. The reliable shape is a ledger: record the total you have reported so far per subscription and feature, compute the delta against the durable usage, send the delta, and only then advance the ledger. Run it on a schedule (Oban’s cron plugin is the natural fit) and let it settle the previous period one last time after the boundary, so late increments still land on the right invoice.

Aurora Meter Pro ships that reporter, along with the Stripe Checkout and webhook sync that keeps plan assignments current, hosted usage dashboards, quota alerts and daily rollups with CSV export. It is licensed per application with a 14-day free trial, claimable once per customer, and you can see every Pro screen running on sample data before you subscribe.

Showing usage to the customer

Usage-based pricing only feels fair when customers can see the meter. Because the counters broadcast over PubSub, a LiveView can subscribe once on mount and render live values with no polling:

def mount(_params, _session, socket) do
  if connected?(socket), do: AuroraMeter.LiveView.subscribe(socket.assigns.org)
  {:ok, socket}
end
<.usage_meter tenant={@org} feature={:ai_generations} />
<.usage_summary tenant={@org} />

Every increment on the server shows up in the browser within a second. The real-time metering post goes into how the ETS layer, the flusher and the broadcaster fit together, including what happens across a cluster.

Putting it together

A complete usage-based billing setup in Phoenix, in order:

  1. Add {:aurora_meter, "~> 0.3"}, generate the migration, add AuroraMeter to your supervision tree after your Repo and PubSub.
  2. Declare your plans in one module. Hard caps, metered overage and feature flags together.
  3. Wrap billable actions in with_quota/4. Mark invoiced features durable.
  4. Render usage_meter components so customers can see where they stand.
  5. When you are ready to charge, add Aurora Meter Pro: Stripe Checkout, webhook sync, subscription-aligned periods and the usage reporter.

The free core is MIT, has no email gate, no trial and no expiry, and its docs cover configuration, clustering and telemetry. If you would rather start from a finished product, the Aurora API Starter from PhxTemplates is a complete metered-API SaaS built on this core, with API keys, per-plan rate limits, quotas and a live usage dashboard already wired.