Limited time 50% off everything

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

Flat monthly plans are quietly dying. The moment your product does anything that costs you money per request, an AI completion, a rendered PDF, a minute of compute, a gigabyte of storage, a fixed price per seat stops matching the value you deliver and the cost you carry. That is why usage-based billing has become the default pricing model of the AI era, and why so many founders now want it running in their stack on day one.

If that stack is Elixir and Phoenix, you are in an unusually good spot. The BEAM was built for exactly the workload metering demands: millions of small, concurrent events counted accurately under load. The catch is that almost none of the tooling written about online is for Elixir. Search for usage-based billing and you will find Stripe guides for Node, Python SDKs, and a company called OpenMeter that ships libraries for Go, Node, and Python but nothing for the BEAM. This guide fills that gap. It walks through the full picture of usage-based billing in Elixir: the pricing models, how to meter events accurately, how to report usage to Stripe, and how to gate features by plan, all with real Phoenix code.

This is the hub of our metering series. If you want the deep dives, we have companion posts on metering API usage in real time with ETS and the story behind Aurora Meter, our usage-billing library for Phoenix.

What “usage-based billing” actually means

Usage-based billing (sometimes called consumption or metered billing) charges customers according to what they consume rather than a fixed subscription fee. It is not one model but a family of them, and most real products blend several:

  • Pure metered. Pay per unit consumed. A transactional email service charges per thousand emails, an AI API charges per million tokens. Simple to reason about, but revenue is unpredictable for both sides.
  • Prepaid credits. The customer buys a bucket of credits up front and burns them down. This is popular with AI products because it caps the customer’s exposure and gives you cash before the cost is incurred.
  • Tiered / graduated. The unit price changes as volume grows. The first 10,000 API calls cost one rate, the next 100,000 a lower one. Rewards heavy users without a bespoke contract.
  • Hybrid: base plus overage. A flat platform fee that includes a generous allowance, then per-unit charges once the allowance is exhausted. This is the sweet spot for most SaaS: predictable base revenue, upside from your power users, and a clear upgrade path.

The hybrid model is what most Phoenix SaaS products should reach for first. It keeps the recurring revenue investors like while still capturing the value that pure seat pricing leaves on the table.

Why the BEAM is a natural fit for metering

Metering is deceptively hard. To bill correctly you need to count every billable event, never lose one, never double count one, and do all of that without adding latency to the request the customer is paying for. Those are exactly the properties the BEAM gives you for close to free.

  • Concurrency. Every request already runs in its own lightweight process. Recording a usage event is a fire-and-forget message, not a blocking database write.
  • ETS for the hot path. Erlang Term Storage is an in-memory, concurrent table that lives in the VM. Incrementing a counter in ETS takes microseconds and never touches Postgres, so metering never becomes the bottleneck.
  • GenServers for aggregation. A single supervised process can own the job of flushing buffered counts to the database and to Stripe on an interval, with retries and back-pressure handled by OTP rather than a cron job you have to babysit.

Contrast that with the typical Node or Rails setup, where every metered event is either a synchronous database round trip or a trip to Redis. On the BEAM the counter is already in memory, already concurrent, and already supervised.

Metering events accurately: the hot-path pattern

The core of any usage-based billing system is the meter: the thing that counts events. The pattern that works well in Phoenix is to increment a counter in ETS synchronously (fast, in memory) and flush aggregated totals to durable storage periodically.

Here is a minimal meter as a GenServer backed by an ETS table:

defmodule Billing.Meter do
  use GenServer

  @table :usage_counters
  @flush_interval :timer.seconds(30)

  def start_link(_opts) do
    GenServer.start_link(__MODULE__, nil, name: __MODULE__)
  end

  # Public API: called from anywhere in a request, non-blocking.
  def record(account_id, meter, amount \\ 1) do
    :ets.update_counter(@table, {account_id, meter}, amount, {{account_id, meter}, 0})
    :ok
  end

  @impl true
  def init(_) do
    :ets.new(@table, [:set, :public, :named_table, write_concurrency: true])
    schedule_flush()
    {:ok, %{}}
  end

  @impl true
  def handle_info(:flush, state) do
    flush_to_storage()
    schedule_flush()
    {:noreply, state}
  end

  defp schedule_flush, do: Process.send_after(self(), :flush, @flush_interval)

  defp flush_to_storage do
    # Atomically read-and-reset every counter, then persist the deltas.
    for {{account_id, meter}, count} <- :ets.tab2list(@table), count > 0 do
      :ets.update_counter(@table, {account_id, meter}, -count)
      Billing.Usage.persist_delta(account_id, meter, count)
    end
  end
end

The important detail is :ets.update_counter/4. It is atomic, so two concurrent requests for the same account never clobber each other’s increment, and it is lock-free for reads. Recording an event from a controller or LiveView is now a single fast call:

def create(conn, params) do
  # ... do the actual work ...
  Billing.Meter.record(conn.assigns.current_account.id, :api_calls)
  json(conn, %{ok: true})
end

Because record/3 never blocks on Postgres or the network, metering adds no measurable latency to the request. The flush_to_storage/0 pass drains the counters into a durable usage_events table every 30 seconds. Read the real-time ETS metering post for the full version, including idempotency keys and how to survive a node restart without losing the last unflushed window.

Persisting deltas durably

The flush target is an ordinary Ecto schema. Store deltas rather than absolute totals so a crash between flushes can never double count:

defmodule Billing.Usage do
  import Ecto.Query
  alias Billing.{Repo, UsageEvent}

  def persist_delta(account_id, meter, count) do
    %UsageEvent{}
    |> UsageEvent.changeset(%{
      account_id: account_id,
      meter: to_string(meter),
      quantity: count,
      period: current_period()
    })
    |> Repo.insert!()
  end

  def total_for_period(account_id, meter, period) do
    from(e in UsageEvent,
      where: e.account_id == ^account_id and e.meter == ^meter and e.period == ^period,
      select: coalesce(sum(e.quantity), 0)
    )
    |> Repo.one()
  end

  defp current_period, do: Date.utc_today() |> Date.beginning_of_month()
end

Now total_for_period/3 gives you an authoritative usage figure for any account, which is what you will send to Stripe and what you will show the customer on their dashboard.

Reporting usage to Stripe

Counting events is only half the job. To actually charge money you report those totals to your payment processor. Stripe’s modern approach is the Billing Meter: you create a meter in Stripe, then push meter events to it, and Stripe handles the pricing math and invoicing against a metered price.

With the stripity_stripe library you report a usage event like this:

defmodule Billing.Stripe do
  @doc "Report a period's usage to a Stripe Billing Meter."
  def report_usage(stripe_customer_id, event_name, quantity) do
    Stripe.Request.new_request()
    |> Stripe.Request.put_endpoint("billing/meter_events")
    |> Stripe.Request.put_method(:post)
    |> Stripe.Request.put_params(%{
      event_name: event_name,
      payload: %{
        stripe_customer_id: stripe_customer_id,
        value: quantity
      }
    })
    |> Stripe.Request.make_request()
  end
end

You have two viable cadences for this:

  1. Stream every event. Report to Stripe on each billable action. Simplest mental model, but it couples your hot path to Stripe’s API and its rate limits, which is exactly what the ETS buffer was designed to avoid.
  2. Report aggregated deltas on a schedule. Reuse the same flush window (or a slower Oban job) to send the period’s accumulated total to Stripe. This is the resilient choice: Stripe never sees your traffic spikes, and a failed report just retries on the next pass.

Option 2 is almost always right. An Oban worker that runs hourly is a clean home for it:

defmodule Billing.Workers.ReportUsage do
  use Oban.Worker, queue: :billing, max_attempts: 5

  @impl true
  def perform(%Oban.Job{args: %{"account_id" => account_id}}) do
    account = Billing.get_account!(account_id)
    period = Date.utc_today() |> Date.beginning_of_month()

    for meter <- ["api_calls", "ai_tokens"] do
      total = Billing.Usage.total_for_period(account_id, meter, period)
      Billing.Stripe.report_usage(account.stripe_customer_id, meter, total)
    end

    :ok
  end
end

Oban gives you retries, uniqueness, and observability out of the box, so a transient Stripe error never drops a charge. Stripe deduplicates meter events by timestamp and identifier, so reporting a cumulative total repeatedly is safe as long as you configure the meter’s aggregation correctly (use last value semantics for cumulative reporting, or send deltas with sum).

Enforcing plan limits: entitlements and quotas

Billing for usage is one side of the coin. The other is enforcement: making sure a customer on the Starter plan cannot consume Enterprise volumes without paying. This is where entitlements come in, the rules that say what a plan is allowed to do.

Keep entitlements as plain data, not scattered if statements:

defmodule Billing.Plans do
  @plans %{
    "free"       => %{api_calls: 1_000,    ai_tokens: 50_000,     seats: 1},
    "starter"    => %{api_calls: 50_000,   ai_tokens: 1_000_000,  seats: 5},
    "pro"        => %{api_calls: 500_000,   ai_tokens: 20_000_000, seats: 25},
    "enterprise" => %{api_calls: :unlimited, ai_tokens: :unlimited, seats: :unlimited}
  }

  def limit(plan, feature), do: get_in(@plans, [plan, feature])

  def allowed?(account, feature, requested \\ 1) do
    case limit(account.plan, feature) do
      :unlimited -> true
      nil -> false
      cap ->
        used = Billing.Usage.total_for_period(account.id, to_string(feature), current_period())
        used + requested <= cap
    end
  end

  defp current_period, do: Date.utc_today() |> Date.beginning_of_month()
end

A Phoenix plug then guards metered endpoints, returning a clean 402 when the customer is out of quota:

defmodule MyAppWeb.Plugs.EnforceQuota do
  import Plug.Conn
  import Phoenix.Controller, only: [json: 2]

  def init(feature), do: feature

  def call(conn, feature) do
    account = conn.assigns.current_account

    if Billing.Plans.allowed?(account, feature) do
      conn
    else
      conn
      |> put_status(402)
      |> json(%{error: "quota_exceeded", feature: feature, upgrade_url: "/settings/billing"})
      |> halt()
    end
  end
end

The difference between a quota and a rate limit matters here. A quota is a total for the billing period (500,000 API calls this month). A rate limit is a ceiling per unit of time (100 requests per second), and it protects your infrastructure rather than your revenue. Most SaaS products need both, and both fit the same ETS-plus-GenServer pattern. We cover the distinction in depth in the rate-limits-vs-quotas material in this series.

Showing usage to the customer

Usage-based pricing only works if customers can see what they are spending. An opaque bill is a support ticket and a churn risk. Phoenix LiveView makes the live usage dashboard almost trivial: subscribe to a PubSub topic, push the running total, and the meter on the customer’s screen ticks up in real time.

defmodule MyAppWeb.UsageLive do
  use MyAppWeb, :live_view

  @impl true
  def mount(_params, _session, socket) do
    account = socket.assigns.current_account
    if connected?(socket), do: Phoenix.PubSub.subscribe(MyApp.PubSub, "usage:#{account.id}")

    {:ok, assign_usage(socket, account)}
  end

  @impl true
  def handle_info({:usage_updated, meter, total}, socket) do
    {:noreply, update(socket, :usage, &Map.put(&1, meter, total))}
  end

  defp assign_usage(socket, account) do
    period = Date.utc_today() |> Date.beginning_of_month()
    usage = %{
      api_calls: Billing.Usage.total_for_period(account.id, "api_calls", period),
      ai_tokens: Billing.Usage.total_for_period(account.id, "ai_tokens", period)
    }
    assign(socket, usage: usage, plan: account.plan)
  end
end

Broadcast on the flush pass and the customer watches their consumption climb toward their limit, which nudges the upgrade without a single marketing email. If you are new to real-time UI on the BEAM, our writeup on streaming responses in Phoenix LiveView shows the same push mechanics applied to an AI product.

Putting it together

A production usage-based billing system in Phoenix comes down to five parts:

  1. A meter that counts events on the ETS hot path with zero added latency.
  2. A durable store of usage deltas per account and billing period.
  3. A reporter (an Oban job) that pushes period totals to Stripe Billing Meters with retries.
  4. An entitlements layer that gates features and enforces quotas by plan.
  5. A live dashboard so customers always know where they stand.

None of these pieces is exotic. They are the standard BEAM building blocks (ETS, GenServer, Oban, LiveView) pointed at a billing problem. But wiring them together correctly, handling idempotency, node restarts, Stripe reconciliation, and proration, is a few weeks of careful work you probably would rather spend on your actual product.

Skip the plumbing

That is exactly why we built it once, properly, and packaged it. Or skip the plumbing: Aurora Meter meters usage on an ETS hot path and reports it to Stripe Billing Meters for you, with entitlements, quotas, and a drop-in LiveView usage component included. The core is free and MIT licensed on GitHub; Aurora Meter Pro adds Stripe reconciliation, multi-meter dashboards, and proration for teams that want it handled end to end.

And if usage-based billing is one piece of a larger SaaS you are shipping, the rest of the stack (auth, subscriptions, transactional email, background jobs, admin) is already wired in our templates. A Builder Pass gives you unlimited access to every PhxTemplates starter for a single lifetime price, so you can go from idea to a metered, billable product in an afternoon instead of a quarter.

Usage-based billing is where SaaS pricing is heading, and Elixir is quietly one of the best languages to implement it in. The concurrency model that makes Phoenix great at real-time features is the same one that makes it great at counting money. Now you have the pattern. Go meter something.