We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
From the blog
Overage Billing in Phoenix: Charge for Usage Beyond the Plan Allowance
By Liam Killingback ·
Overage Billing in Phoenix: Charge for Usage Beyond the Plan Allowance
Your $49 plan includes 2,000 AI generations a month. On the 19th, a customer runs generation 2,001. What happens next is a pricing decision, but it is also code, and most Phoenix apps end up with one of two bad answers: a hard wall that interrupts a paying customer in the middle of their work, or no limit at all and a surprise on your own OpenAI bill.
Overage billing is the third answer. The plan includes an allowance, usage past it keeps working, and every unit over the line is charged on the next invoice at a known price. It is the model most AI and API products settle on, and it is the one customers find easiest to accept, as long as it is visible and never a surprise.
This post builds overage billing in Phoenix end to end: declaring the allowance, counting on the hot path, letting usage continue past the line without letting it run away, showing the customer what they owe before the invoice does, and getting the overage onto a Stripe invoice with a graduated price. The examples use Aurora Meter, the free MIT metering library for Phoenix, but every step explains what it does so you can hand-roll it if you prefer.
Overage, hard caps and prepaid credit
There are three common shapes for “the customer used more than the plan covers”. They are easy to confuse, and choosing the wrong one creates support tickets you cannot fix with code.
| Shape | At the allowance | Who carries the risk | Fits |
|---|---|---|---|
| Hard cap | Request is refused until the period resets or they upgrade | The customer (blocked work) | Free plans, strict budgets |
| Overage | Request succeeds and is billed per unit in arrears | You (you bill later, you might not collect) | Paid plans with a card on file |
| Prepaid credit | Request succeeds while the balance lasts | The customer (they paid up front) | Pay-as-you-go, unpredictable usage |
If you are still deciding between per-seat and usage pricing in the first place, metered vs seat-based pricing covers that choice. This post assumes you have chosen an allowance plus overage on at least one paid plan.
When overage is the right call
Overage is right when saying no costs more than the work does. A customer halfway through a batch import, a support team answering tickets with your AI assistant, an API client in production: refusing them damages trust far more than a few cents of compute. They would rather be billed than interrupted.
It is the wrong call on a free plan. A metered free plan bills someone who never gave you a card, so you cannot collect, and they get the product for nothing. Free plans should hit a wall; paid plans should bill. The cleanest design uses both on the same feature name, which is exactly what we will do.
Step 1: Declare the allowance and the unit price
Put the numbers in one place. In Aurora Meter that is the plan module, validated at compile time:
defmodule MyApp.Plans do
use AuroraMeter.Plans
plan :free do
price 0
limit :generations, 50, :hard
end
plan :growth do
price 4_900
metered :generations, included: 2_000, unit_price: 3
end
plan :scale do
price 19_900
metered :generations, included: 10_000, unit_price: 2
end
end
price and unit_price are integers in cents. On :growth, the first 2,000 generations a month are included, and each one after that is 3 cents. On :free the same feature is a hard cap at 50.
Choosing the overage price
A few rules of thumb that hold up:
- Price overage at or near the effective included rate. $49 for 2,000 is about 2.45 cents each, so 3 cents for overage is a modest premium. Punitive overage (10x the included rate) gets you a burst of revenue and then a cancellation.
-
Make the next plan the better deal for regular overage. A
:growthcustomer who uses 8,000 a month pays $49 + 6,000 x $0.03 = $229. On:scalethey would pay $199. That gap is your upgrade prompt, and you can show it to them (step 5). -
Know your floor. If a generation costs you 1.2 cents in model fees, a 2 cent unit price on
:scalestill has margin. Write that number down next to the plan; it is the one that changes when your provider changes prices.
Step 2: Count on the hot path
Counting has to be cheap enough to put on every request. In Aurora Meter it is an ETS increment with no database call; a background flusher writes deltas to Postgres every few seconds:
defmodule MyApp.Generation do
def run(org, prompt) do
AuroraMeter.with_quota(org, :generations, fn ->
MyApp.AI.generate(prompt)
end)
end
end
with_quota/3 reserves the unit, runs the function and rolls the reservation back if the function raises, so a crashed request is not billed. On :free it returns {:error, :limit_exceeded} at 50. On :growth and :scale the feature is metered, so it never refuses: it counts and lets the work through. One call, and the plan decides the behaviour.
If you would rather count after the fact (for example, by tokens from the model’s response), use AuroraMeter.track(org, :generations, n) with the real quantity.
When a unit is worth real money
The default counter is buffered. If a node dies, increments not yet in an acknowledged flush die with it, which is usually a few seconds of usage. For a 3 cent unit that is a fine trade. If a unit is worth a dollar, record each one as a durable event with an identity of your own, so a retried request is recognised as a duplicate instead of being charged twice:
# config/config.exs
config :aurora_meter, feature_sources: %{generations: :events}
# after the work succeeds
AuroraMeter.record(org, :generations, 1, id: request_id, occurred_at: DateTime.utc_now())
That is a database write per call, so choose it per feature, not for everything.
Step 3: Never refuse, but never run away either
“Overage never blocks” is a promise to honest customers. It should not also be a promise to a leaked API key in a loop. Add two guards on top of the metered feature.
An abuse ceiling you set
A ceiling far above any legitimate use, checked before the work runs. It is not a pricing limit and customers should essentially never see it:
defmodule MyApp.Generation do
@abuse_ceiling 200_000
def run(org, prompt) do
if AuroraMeter.usage(org, :generations) >= @abuse_ceiling do
{:error, :contact_support}
else
AuroraMeter.with_quota(org, :generations, fn -> MyApp.AI.generate(prompt) end)
end
end
end
Alert yourself when anyone gets within 20% of it. A customer heading there is either a fraud case or your biggest account, and both deserve a human.
A spend cap the customer sets
Many customers, especially at companies with procurement rules, will only accept overage if they can cap it. “Bill me for overage, up to $100 a month” is a reasonable request and it is a few lines:
defmodule MyApp.Billing.SpendCap do
@doc "Projected overage in cents if one more unit is used."
def next_unit_cost(org, feature) do
case AuroraMeter.quota(org, feature) do
%{kind: :metered, used: used, included: included, unit_price: price} ->
max(0, used + 1 - included) * price
_other ->
0
end
end
def allow?(%{overage_cap_cents: nil}, _feature), do: true
def allow?(%{overage_cap_cents: cap} = org, feature),
do: next_unit_cost(org, feature) <= cap
end
def run(org, prompt) do
cond do
AuroraMeter.usage(org, :generations) >= @abuse_ceiling -> {:error, :contact_support}
not SpendCap.allow?(org, :generations) -> {:error, :spend_cap_reached}
true -> AuroraMeter.with_quota(org, :generations, fn -> MyApp.AI.generate(prompt) end)
end
end
Be honest with yourself about what this is: the check and the work are not atomic, so ten concurrent requests at the boundary can overshoot the cap by a few units. For a soft, customer-chosen budget that is acceptable. Say “about $100” in the settings copy, or round the cap down by a small margin, rather than promising to the cent.
Step 4: Show the customer what they owe, before the invoice does
The single biggest cause of overage complaints is not the price, it is the surprise. Put the running total where the customer already looks.
AuroraMeter.quota(org, :generations)
# %{kind: :metered, used: 2_340, included: 2_000, overage: 340,
# unit_price: 3, remaining: :unlimited, percent: 100, period: %{...}}
Note percent: it is clamped to 100, so it reads the same at 2,001 and at 20,000. It is right for drawing a progress bar and wrong for describing the situation. Read overage and say the number out loud:
<div class="usage">
<p><%= @q.used %> of <%= @q.included %> generations included this month</p>
<p :if={@q.overage > 0} class="usage__overage">
<%= @q.overage %> over the allowance, about
<%= AuroraMeter.Credits.Money.format(@q.overage * @q.unit_price * 10_000) %>
on your next invoice
</p>
</div>
Money.format/1 takes micro-dollars, hence the * 10_000: 340 units x 3 cents = 1,020 cents = $10.20. Subscribe the LiveView with AuroraMeter.LiveView.subscribe(org) in mount/3 and the line updates as usage happens, with no polling.
The drop-in <.usage_meter tenant={@org} feature={:generations} /> component already flags overage with a “+340 over” marker if you would rather not build your own.
Project it, and show the cheaper plan
Two more lines make this genuinely helpful rather than merely accurate:
defmodule MyApp.Billing.Projection do
def month_end_overage_cents(org, feature, today \\ Date.utc_today()) do
%{used: used, included: included, unit_price: price} = AuroraMeter.quota(org, feature)
days_in_month = Date.days_in_month(today)
projected = div(used * days_in_month, max(today.day, 1))
max(0, projected - included) * price
end
end
A linear projection is crude, so label it “at your current pace”. When the projected total on the current plan exceeds the next plan’s price, show a one-line upgrade suggestion with both numbers. Customers remember the vendor who told them how to pay less.
Step 5: Warn at 80% and at 100%, once each
A banner only helps if they open the app. An email at 80% of the allowance and another when overage starts covers the rest. The rules for doing that without spamming anyone (once per threshold per period, sweep rather than hook, roll back the “sent” mark if delivery fails) are in quota alerts in Phoenix, which builds them on the free core. Aurora Meter Pro ships them as a handler callback:
config :aurora_meter_pro, alert_handler: &MyApp.Billing.quota_alert/1
defmodule MyApp.Billing do
def quota_alert(%{tenant_key: key, feature: feature, percent: percent}) do
org = MyApp.Orgs.get_by_key!(key)
cond do
percent >= 100 -> MyApp.Mailer.overage_started(org, feature)
percent >= 80 -> MyApp.Mailer.approaching_allowance(org, feature, percent)
true -> :ok
end
end
end
Step 6: Put the overage on a Stripe invoice
Now the money. Stripe bills usage through a Billing Meter (which aggregates the events you send) and a metered price attached to the subscription. For an allowance plus overage, the price should be graduated: the first tier is the allowance at $0, the second tier is the overage unit price.
Create the meter once:
stripe post /v1/billing/meters \
-d display_name="Generations" \
-d event_name=generations \
-d "default_aggregation[formula]=sum" \
-d "customer_mapping[type]=by_id" \
-d "customer_mapping[event_payload_key]=stripe_customer_id" \
-d "value_settings[event_payload_key]=value"
Then the graduated, metered price for :growth (use the meter id from the previous response):
stripe post /v1/prices \
-d product=prod_GROWTH \
-d currency=usd \
-d lookup_key=growth_generations_overage \
-d "recurring[interval]=month" \
-d "recurring[usage_type]=metered" \
-d "recurring[meter]=mtr_123" \
-d billing_scheme=tiered \
-d tiers_mode=graduated \
-d "tiers[0][up_to]=2000" \
-d "tiers[0][unit_amount]=0" \
-d "tiers[1][up_to]=inf" \
-d "tiers[1][unit_amount]=3"
The subscription carries two items: the flat $49 price and this metered price. Stripe charges the flat price in advance and the metered price in arrears at the end of the period.
The mistake that double-discounts the allowance
Report total usage, not overage. The $0 first tier already gives away the first 2,000. If your reporter subtracts the allowance before sending (because “we only bill overage”), the customer gets 4,000 free and you find out at the end of the quarter. Send every unit; let the tiers do the arithmetic.
Report deltas, with an identifier
Meter events are summed, so each report should be the change since the last successful report, sent with an identifier Stripe uses to deduplicate. Then a retried request does not bill the same units twice:
defmodule MyApp.Billing.MeterReport do
def send_delta(%{stripe_customer_id: customer}, delta, batch_id) when delta > 0 do
Req.post("https://api.stripe.com/v1/billing/meter_events",
auth: {:bearer, Application.fetch_env!(:my_app, :stripe_secret_key)},
form: [
event_name: "generations",
identifier: "generations:#{batch_id}",
timestamp: System.system_time(:second),
"payload[stripe_customer_id]": customer,
"payload[value]": delta
]
)
end
end
The batch_id must be stable across retries of the same batch, so persist it with the “reported up to” watermark before you send, not after. Run the reporter from an Oban cron job every few minutes. The stale-watermark, partial-failure and end-of-period cases are where hand-rolled reporters drift; that bookkeeping is what Aurora Meter Pro’s usage reporter does for you:
config :aurora_meter, provider: AuroraMeter.Pro.Stripe, period_source: AuroraMeter.Pro.Period
config :aurora_meter_pro,
stripe_prices: %{growth: "price_flat_growth", scale: "price_flat_scale"},
stripe_metered_prices: %{growth: ["price_growth_overage"], scale: ["price_scale_overage"]},
stripe_meters: %{generations: "generations"}
Pro also aligns the usage period to the Stripe subscription rather than the calendar month, which matters as soon as customers subscribe on any day other than the 1st: otherwise your “2,000 included” and Stripe’s “2,000 included” are counted over different windows.
Keep the two “included” numbers in step
included: 2_000 in the plan module and up_to: 2000 in the Stripe tier describe the same thing, and nothing forces them to agree. The plan module drives your screens; Stripe’s tiers decide the invoice. If they drift, the invoice is still what Stripe says and your dashboard is now wrong. A test that fetches the price (with expand[]=tiers, because tiers are not returned by default) and compares it with the plan, run in CI against your Stripe test account, catches that the day it happens.
Plan changes mid-period
Decide the policy before a customer asks. With Aurora Meter the counter is per feature, not per plan, so an upgrade with AuroraMeter.subscribe(org, :scale) keeps the period’s usage and reads it against the new allowance straight away. That is the customer-friendly outcome: upgrading on the 19th makes their earlier overage part of a bigger allowance.
Stripe has its own view of which usage belongs to which price after a mid-period change, and metered prices are not prorated the way flat prices are. Do not promise anything in your pricing copy until you have run the exact upgrade and downgrade paths in a Stripe sandbox with a test clock and read the resulting invoices. The Stripe Customer Portal post covers keeping your app in sync when the change happens in the portal instead of your UI.
Corrections and refunds
Things go wrong: a bug double-counts a batch, a customer’s job failed after it was metered. Correct the counter with a negative increment so your dashboard is right:
AuroraMeter.track(org, :generations, -120)
If the wrong units were already reported to Stripe, recent meter events can be cancelled by their identifier through Stripe’s meter event adjustments, within a short window. After that, issue a credit note on the invoice. Either way, log the reason: a pattern of corrections is a metering bug you have not found yet.
Testing overage without charging anyone
The plan rules are plain function calls, so the important behaviour is easy to pin down in ExUnit:
test "growth plan bills past the allowance instead of refusing" do
org = "org_#{System.unique_integer([:positive])}"
AuroraMeter.subscribe(org, :growth)
AuroraMeter.track(org, :generations, 2_000)
assert {:ok, :done} = AuroraMeter.with_quota(org, :generations, fn -> :done end)
assert %{overage: 1, unit_price: 3} = AuroraMeter.quota(org, :generations)
end
test "free plan stops at the cap" do
org = "org_#{System.unique_integer([:positive])}"
AuroraMeter.subscribe(org, :free)
AuroraMeter.track(org, :generations, 50)
assert {:error, :limit_exceeded} = AuroraMeter.with_quota(org, :generations, fn -> :done end)
end
For the Stripe side, use a test clock: create a subscription, send meter events, advance the clock past the period end and assert on the draft invoice’s line items. That is the only test that proves the graduated tiers and your reporter agree.
Or skip the plumbing
Everything up to step 5 runs on the free core: the plan DSL, the ETS counters, the never-refusing metered gate, the overage figures and the LiveView components. It is MIT, on GitHub, and the core docs walk through setup in about ten minutes.
When Stripe should start charging, Aurora Meter Pro adds the usage reporter with stable batch identifiers, subscription-aligned periods, Checkout and webhook sync, quota alerts and reconciliation. It is licensed per app from $99 a month, with a 14-day free trial (one trial per customer), so you can run the whole overage flow above against your Stripe test account before you pay anything.
If you are starting the product itself from scratch, the Aurora API Starter is a complete metered-API SaaS built on the core.
Summary
Overage billing in Phoenix comes down to six decisions made explicit in code. Declare an allowance and a unit price per plan, with a hard cap on free plans and a metered allowance on paid ones. Count on the hot path and let metered usage through, but keep an abuse ceiling and offer customers a spend cap. Show the running overage and a month-end projection where customers already look, and warn them at 80% and 100%. Bill it with a graduated metered price in Stripe, report total usage as idempotent deltas, and keep the two “included” numbers in step with a test. Get those right and overage stops being a source of support tickets and becomes the part of your pricing that grows with your best customers.