From the blog

Metered vs Seat-Based Pricing: How to Choose, and How to Build Either in Phoenix

By Liam Killingback ·

Metered vs Seat-Based Pricing: How to Choose, and How to Build Either in Phoenix

The metered vs seat-based pricing question usually arrives about a week before launch, when the pricing page is the last unfinished thing and somebody says “should we just charge per seat?” It gets answered in fifteen minutes, and then the answer quietly shapes the next two years: your margins, your churn, your forecast, and how much of your support inbox is people arguing about an invoice.

It is worth more than fifteen minutes. This post is the honest comparison, with the part most pricing articles leave out: the Phoenix code for both models, and for the hybrid that most SaaS products actually land on.

The two models, side by side

Seat-based Metered
Charged for People with access Units consumed
Revenue predictability High, known at renewal Low, known at period end
Aligns with your cost Only if cost scales with headcount Usually yes
Buyer’s first reaction Understands it instantly Asks “so what will I actually pay?”
Expansion revenue Needs a new hire or a new team Happens on its own
Downgrade pressure Concentrated at renewal, seat by seat Continuous, but small
Worst failure mode Password sharing, one login for a team of nine Bill shock, then a chargeback
Build cost A number and a COUNT(*) Counting, aggregation, limits, invoicing

Neither column is the winner. The columns describe different businesses.

What seat pricing is genuinely good at

It is not the lazy option, whatever the usage-billing vendors say.

People understand it without being taught. “Forty-nine dollars per user per month” needs no explanation, no calculator, no worked example on the pricing page. A buyer can approve it without a spreadsheet, and procurement can approve it without a meeting. That alone closes deals.

Revenue is knowable. You can write next quarter’s number down in advance and be roughly right. If you are raising money, or just want to sleep, this matters more than a few points of margin.

Expansion is a conversation, not an accident. When a customer adds twelve people, somebody at your company notices. That is a relationship touchpoint, and often an upsell.

It is cheap to build. One integer on the plan, one COUNT(*) against your own data, one invoice line. You can ship it in an afternoon.

Where seat pricing breaks

When your costs stop tracking headcount. This is the big one, and it is why the question has become urgent again. If every seat runs AI features, your cost per customer is driven by tokens, not by people. A ten-seat account that generates two hundred thousand documents a month can cost you more than your revenue from them, and seat pricing gives you no lever to pull.

Seat sharing. A team of nine buys three seats and shares logins. You cannot detect it without being creepy about it, and if you try to enforce it you look like the villain.

Renewal is a negotiation about deleting people. Every renewal, somebody audits the user list and cuts the eleven accounts that logged in twice. That is a structural downward pressure you get to face once a year, all at once.

What metered pricing is genuinely good at

Cost and price move together. If you pay per token, per gigabyte, or per API call, charging per unit means a customer cannot be unprofitable. Your gross margin stops depending on who signed up.

The entry price can be zero. A prospect can start with no commitment and grow into a large account without a single conversation. For a developer tool, this is often the entire growth model.

It prices the value, not the headcount. A two-person team running a million API calls is getting more from you than a fifty-person team clicking around once a week. Metered pricing notices that. Seats do not.

Where metered pricing breaks

Bill shock, which is a trust problem, not a billing problem. A customer who gets a $4,000 invoice they did not see coming does not pay it and grumble. They dispute it, and then they leave. Any metered product that does not show live usage in the product is setting this up on purpose.

You cannot forecast. Your revenue is now a function of your customers’ traffic, which is a function of their business, which you do not control.

Buyers hesitate. “It depends on usage” is a genuinely worse answer than a price, especially to someone who needs budget approval before they can try you.

It is real engineering. You need counting on the hot path that does not add a database write per request, aggregation that survives a deploy, limits that hold under concurrency, a UI that shows the number, and a reliable push to your billing provider. That is weeks, not an afternoon, and it is the reason a lot of teams pick seats by default.

People will game it. If a unit is cheap to avoid, some customers will restructure their usage to avoid it, and your revenue per customer drifts down while your support load does not.

The question that actually decides it

Strip away the strategy talk and one question decides the model:

Does your cost of serving a customer scale with the number of people, or with the amount of work?

  • Project management, CRM, design tools, internal wikis: cost scales with people. Seats.
  • AI generation, document processing, transcription, API platforms, data enrichment: cost scales with work. Metered.
  • Anything with a human-collaboration surface and an expensive machine underneath it: both, and pretending otherwise leaves money on one side and margin on the other.

That third case is where most products land once they add AI features to something people log into.

The hybrid nearly everyone converges on

The shape that works, and that customers accept:

  1. A platform fee for being a customer at all.
  2. Seats for access, because that is what the buyer understands.
  3. An included allowance of the expensive thing, generous enough that most accounts never think about it.
  4. Overage above the allowance, at a published unit price.

The allowance does the important work. It gives you a real price on the pricing page, it keeps the small accounts on a flat bill, and it only starts metering the customers whose usage is large enough that they already expect to pay for it. Bill shock mostly disappears, because the customers who go over are the ones watching.

Building it in Phoenix

Here is the part the pricing posts skip. Aurora Meter is a Phoenix library (MIT, free core, on GitHub) that covers all three shapes with the same plan module, so you can change your mind later without rewriting the app.

Add it and declare your plans in one place:

defmodule MyApp.Plans do
  use AuroraMeter.Plans

  # Pure seats. Flat price, access capped by headcount.
  plan :team do
    price 4_900                      # $49.00, in cents
    feature :seats, 20
    feature :api_access, true
    limit :projects, 50, :hard
  end

  # Pure metered. No allowance, every unit is billable.
  plan :payg do
    price 0
    metered :ai_generations, included: 0, unit_price: 3
    feature :api_access, true
  end

  # The hybrid: platform fee, seats, allowance, then overage.
  plan :scale do
    price 19_900                     # $199.00
    feature :seats, 25
    metered :ai_generations, included: 10_000, unit_price: 2
    feature :api_access, true
  end
end

The module is validated when it compiles. A duplicate feature, a negative limit or a misspelled mode raises at compile time rather than at 3am.

Seats are a value, not a counter

This is the detail people get wrong, and it is worth stating plainly: seats are not usage. A usage counter only goes up within a period. Seats go up and down as people join and leave. If you model seats as a metered feature, a customer who removes someone never gets the slot back, and you will be issuing refunds by Friday.

So :seats is a plan value you read, and you compare it against your own count:

defmodule MyApp.Members do
  import Ecto.Query
  alias MyApp.Repo

  def seats_used(org), do: Repo.aggregate(from(m in Member, where: m.org_id == ^org.id), :count)

  def seats_allowed(org), do: AuroraMeter.feature_value(org, :seats, 1)
end

The naive enforcement is a read followed by a write, and two invitations accepted in the same second can both pass. If seats are what you are paid for, close that gap with a lock on the row everyone contends on:

def add_member(org, user) do
  Repo.transaction(fn ->
    _locked = Repo.one!(from o in Org, where: o.id == ^org.id, lock: "FOR UPDATE")

    if seats_used(org) >= seats_allowed(org) do
      Repo.rollback(:seat_limit_reached)
    else
      Repo.insert!(%Member{org_id: org.id, user_id: user.id})
    end
  end)
end

That is a handful of contended transactions per customer per month, so the lock costs you nothing.

Metered usage belongs on a hot path that never touches Postgres

The opposite is true for the metered half. A generation, an API call or a parsed page can happen thousands of times a second, and UPDATE usage SET count = count + 1 on every one of them is how you end up with a database on fire.

Aurora Meter increments a shared ETS table with :ets.update_counter/4, which is atomic and lock-free, and flushes idempotent deltas to Postgres on an interval:

AuroraMeter.track(org, :ai_generations)          # +1, no database write
AuroraMeter.track(org, :ai_generations, 5)       # +5
AuroraMeter.usage(org, :ai_generations)          # => 6

For anything billable, wrap the work instead of counting around it. with_quota/4 reserves first, runs your function, and rolls the reservation back if it raises, so a crashed job does not invoice the customer:

AuroraMeter.with_quota(org, :ai_generations, fn ->
  generate_report(org)
end)
# => {:ok, report} | {:error, :limit_exceeded} | {:error, :not_entitled}

On a metered feature, with_quota/4 still counts and still releases on a crash, and simply never refuses: above the allowance the customer keeps working and the overage accrues. On a limit … :hard feature the same call is the thing that stops them. Switching a plan from a hard cap to metered overage is therefore a one-line change in the plan module, not a change at the call site.

If you are invoicing on the number, mark the feature durable so every increment also writes an event row synchronously:

config :aurora_meter, durable_features: [:ai_generations]

Counters are buffered by default, which is the right trade for a dashboard and the wrong one for an invoice.

Showing the customer the bill before Stripe does

This is the difference between metered pricing that works and metered pricing that generates chargebacks:

AuroraMeter.quota(org, :ai_generations)
# %{feature: :ai_generations, kind: :metered, used: 12_400, included: 10_000,
#   overage: 2_400, unit_price: 2, limit: nil, remaining: :unlimited,
#   percent: 100, enabled: true, period: %{start: ..., end: ...}}

One trap worth knowing: percent is clamped to 100, so it reads 100 at 10,001 units and 100 at 100,000 too. It is fine for drawing a bar and useless for describing the situation. Read overage and put the number and the money next to the bar:

<div class="rounded-lg border p-4">
  <p><%= @q.used %> of <%= @q.included %> generations this period</p>

  <p :if={@q.overage > 0} class="text-amber-600">
    <%= @q.overage %> over your allowance, about
    <%= AuroraMeter.Credits.Money.format(@q.overage * @q.unit_price * 10_000) %>
    on your next invoice
  </p>
</div>

The drop-in components read the live ETS counters over PubSub, so the number in the browser moves as the work happens rather than after a nightly job:

# mount/3
if connected?(socket), do: AuroraMeter.LiveView.subscribe(org)
<.usage_meter tenant={@org} feature={:ai_generations} />
<.usage_summary tenant={@org} />

A customer who has watched the number climb all month does not dispute the invoice. That is the whole argument for building the dashboard before you build the billing.

Getting it onto the invoice

Seats are easy: quantity on a Stripe subscription item, updated when someone joins or leaves. Overage is the harder half, because it has to be aggregated, deduplicated, and pushed before the invoice is finalised, and it has to survive retries without double-charging anyone.

That is what Aurora Meter Pro does: completed, persisted metered usage reported to Stripe Billing Meters with immutable retries and bounded idempotency, billing periods aligned to the Stripe subscription instead of the calendar month, Checkout and webhook sync, and quota alerts so a customer hears from you at 80 percent rather than on the invoice. Pro is licensed per app from $99 a month, with a 14-day free trial claimed once per customer. Prices and the full free versus Pro table are on the pricing page.

The free core stays the entire metering and entitlements engine either way: counting, plan gating, atomic quotas, LiveView components, history and telemetry, all MIT.

Changing your mind later, without breaking trust

Most teams start on seats and add metering when the AI bill arrives. That migration is the risky moment, because you are telling existing customers that something they had for free now has a price.

The order that works:

  1. Measure before you charge. Declare the feature as a counter, which is measured, never blocked and never billed, and let it run for a full billing cycle:

    plan :team do
      price 4_900
      feature :seats, 20
      counter :ai_generations        # measured, never blocked, never billed
    end
  2. Look at the distribution, not the average. Set the included allowance so the large majority of existing accounts sit comfortably under it. If your allowance puts 40 percent of customers into overage on day one, you have not changed your pricing, you have announced a price rise.

  3. Show the number for a cycle before it costs anything. Ship the usage meter with a “not billed yet” note. People need to see where they sit before you attach money to it.

  4. Grandfather loudly. Existing customers keep their current bill for a stated period. Say so in the email, put a date on it, and honour it.

  5. Flip the counter to metered in the plan module. One line, because the call sites never changed.

So which one

  • Cost scales with people, buyer is a team lead, and you want a forecast: seats. Ship it in an afternoon and move on. Do not build a metering system you do not need.
  • Cost scales with work, especially AI or infrastructure you pay for by the unit: metered, with live usage visible in the product from day one.
  • Both, which is most products with an expensive feature inside a collaborative app: the hybrid. Platform fee, seats for access, a generous included allowance, published overage above it.

The mistake is not picking the wrong model. It is picking one and then discovering the code assumed it forever. Keeping the plan definition in one declarative module, with the call sites written as with_quota/4 regardless of whether the feature is capped, metered or free, is what makes the second decision cheap.

Start with the free core: the Aurora Meter docs cover the plan DSL and the three jobs in about ten minutes. The deeper dives are in Usage-Based Billing in Elixir and Phoenix and Plan-Based Feature Gating and Entitlements in Phoenix. If you are starting the app itself from scratch, phx_saas ships auth, Stripe and email already wired, so the pricing model is the only thing left to decide.