We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
From the blog
Quota Alerts in Phoenix: Warn Customers Before They Hit the Limit
By Liam Killingback ·
Quota Alerts in Phoenix: Warn Customers Before They Hit the Limit
A customer on your Pro plan is halfway through a demo for their own boss when your API starts returning 429 limit exceeded. They had used 1,000 of 1,000 AI generations and nobody told them they were close. You get an angry support ticket, they get embarrassed, and a customer who was ready to upgrade now thinks about leaving.
The metered version of the same story is quieter and worse: a customer on an allowance plan sails past their included usage, nobody tells them, and the first they hear of it is an invoice three times the size of last month’s.
Both are fixed by the same small feature: quota alerts. Tell the customer at 80% so they can plan, and tell them again at 100% so the limit or the overage is never a surprise. This post shows how to build quota alerts in Phoenix properly: once per threshold, never lost when the mail provider hiccups, never on the request hot path, and reset for free when the billing period rolls over. We build it first on the free, MIT Aurora Meter core, then show what Aurora Meter Pro does for you.
If you have not set up plan limits yet, start with rate limits vs quotas in Phoenix, which covers the enforcement side. This post is about the conversation around the limit.
What deserves an alert
Not every metered number needs one. Aurora Meter’s plan DSL has several kinds of feature, and they want different treatment:
defmodule MyApp.Plans do
use AuroraMeter.Plans
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
plan :payg do
price 0
counter :requests
feature :api_access, true
end
end
-
Hard caps (
limit ..., :hard). At 100% the customer is blocked. An 80% warning gives them time to upgrade; a 100% notice explains why requests are failing. -
Metered allowances (
metered ..., included:). At 100% nothing is blocked, but every unit from here on is billed. The 100% alert is the important one: “overage has started”. -
Counters (
counter). Measured, never blocked, never billed, and with no denominator. A percentage is meaningless, so there is nothing to alert on. - Prepaid credits. The core’s credit ledger already fires a low-balance signal once per crossing (telemetry, a PubSub message and an optional handler), so you do not need to build that part.
AuroraMeter.quota/2 tells you which case you are in, along with the numbers you need for the message:
AuroraMeter.quota(org, :ai_generations)
# => %{kind: :hard, used: 812, limit: 1_000, remaining: 188, percent: 81,
# overage: 0, period: %{start: ~U[2026-09-01 00:00:00Z], end: ~U[2026-10-01 00:00:00Z], ...}, ...}
For a hard cap, percent is relative to limit. For a metered feature it is relative to included, so 100% means overage billing has begun. For a counter, percent is nil. That one map is enough to drive every alert in this post.
The four rules of a good alert
Before any code, the properties we want. Most home-grown alert systems break at least one.
- Once per threshold per period. A customer sitting at 81% for two weeks gets one email, not one every ten minutes.
- Not lost on failure. If the email provider is down when the alert fires, the alert is retried, not silently marked as sent.
-
Not on the hot path. Metering in Aurora Meter is an ETS increment measured in microseconds. Putting a database write and an email send inside
track/2would throw that away. - Reset by the period, not by a job. When September ends, October’s 80% alert must fire again without anyone remembering to clear a flag.
A fifth, softer rule: send the highest threshold crossed, not all of them. If a burst takes a customer from 60% to 100% between two checks, they want one message that says “you have hit your limit”, not an 80% warning followed a second later by the 100% one.
Sweep, don’t hook
There are two ways to notice a crossing: react to every increment, or check periodically. For alerts, a periodic sweep wins.
Reacting to increments means doing work on the hot path, or subscribing to the PubSub usage messages in a long-lived process and deciding there. Both work, but on a cluster each node’s usage message carries that node’s converged view, and you end up deduplicating across nodes as well as across time. A sweep every five or ten minutes reads the same numbers, runs in one place (an Oban job runs once, on one node), and is easy to reason about. A customer learns they are at 80% a few minutes late, and nobody minds.
The in-app banner is different: that one should be live, and we do it with PubSub further down.
Building quota alerts on the free core
A table that remembers what was sent
The dedup record is the whole trick. One row per org, feature, period, threshold and limit, with a unique index so the database enforces “once”.
defmodule MyApp.Repo.Migrations.CreateQuotaAlerts do
use Ecto.Migration
def change do
create table(:quota_alerts) do
add :org_key, :string, null: false
add :feature, :string, null: false
add :period_start, :utc_datetime, null: false
add :threshold, :integer, null: false
add :limit, :integer, null: false
timestamps(type: :utc_datetime, updated_at: false)
end
create unique_index(:quota_alerts, [:org_key, :feature, :period_start, :threshold, :limit])
end
end
A matching schema lets insert_all cast the timestamps for us:
defmodule MyApp.Billing.QuotaAlert do
use Ecto.Schema
schema "quota_alerts" do
field :org_key, :string
field :feature, :string
field :period_start, :utc_datetime
field :threshold, :integer
field :limit, :integer
timestamps(type: :utc_datetime, updated_at: false)
end
end
Keying on period_start is what gives us rule 4: October has a different period start, so its rows do not exist yet, and the alerts fire again with no reset job.
Including limit in the key handles a case that is easy to miss. A customer hits 80% of 1,000, upgrades to a 10,000 plan mid-month, and later reaches 80% of the new limit. Without limit in the key, the old row would swallow the second warning.
The sweep worker
defmodule MyApp.Billing.QuotaAlerts do
use Oban.Worker, queue: :billing, max_attempts: 3, unique: [period: 300]
alias Ecto.Multi
alias MyApp.Repo
@thresholds [80, 100]
@features [:ai_generations, :api_calls]
@impl Oban.Worker
def perform(_job) do
MyApp.Orgs.active_org_ids()
|> Stream.chunk_every(200)
|> Enum.each(fn ids ->
for org <- MyApp.Orgs.get_many(ids), feature <- @features, do: check(org, feature)
end)
end
def check(org, feature) do
case AuroraMeter.quota(org, feature) do
%{kind: kind, percent: percent} = quota
when kind in [:hard, :metered] and is_integer(percent) ->
crossed = Enum.filter(@thresholds, &(percent >= &1))
if crossed != [], do: record_and_notify(org, feature, quota, crossed)
_counter_or_boolean ->
:ok
end
end
defp record_and_notify(org, feature, quota, crossed) do
cap = quota.limit || quota.included
org_key = MyApp.Tenant.to_key(org)
now = DateTime.utc_now() |> DateTime.truncate(:second)
rows =
for threshold <- crossed do
%{
org_key: org_key,
feature: Atom.to_string(feature),
period_start: DateTime.truncate(quota.period.start, :second),
threshold: threshold,
limit: cap,
inserted_at: now
}
end
Multi.new()
|> Multi.insert_all(:alerts, MyApp.Billing.QuotaAlert, rows,
on_conflict: :nothing,
conflict_target: [:org_key, :feature, :period_start, :threshold, :limit],
returning: [:threshold]
)
|> Multi.run(:notify, fn _repo, %{alerts: {_count, inserted}} ->
case Enum.map(inserted, & &1.threshold) do
[] ->
{:ok, :already_sent}
new_thresholds ->
%{
org_id: org.id,
feature: Atom.to_string(feature),
threshold: Enum.max(new_thresholds),
kind: Atom.to_string(quota.kind),
used: quota.used,
cap: cap,
period_end: quota.period.end
}
|> MyApp.Billing.QuotaAlertEmail.new()
|> Oban.insert()
end
end)
|> Repo.transaction()
end
end
The important move is in the Multi. The dedup rows and the email job are written in one transaction. on_conflict: :nothing with returning: hands back only the thresholds that were not already recorded, so a threshold we have already told the customer about produces nothing. If there are new ones, we enqueue a single email for the highest.
Because the email is an Oban job inserted in the same transaction (a transactional outbox), there is no window where the row says “sent” but no email exists. If the transaction rolls back, neither happened, and the next sweep tries again. If the mail provider is down, Oban retries the email job with backoff. Rule 2 holds without a single try.
Schedule the sweep with the Oban cron plugin:
config :my_app, Oban,
queues: [billing: 5, mailers: 10],
plugins: [
{Oban.Plugins.Cron, crontab: [{"*/10 * * * *", MyApp.Billing.QuotaAlerts}]}
]
MyApp.Orgs.active_org_ids/0 is whatever lists your paying customers. Chunking keeps the memory flat once you have thousands of orgs; the quota/2 reads themselves are cheap because the counters live in ETS and plan lookups are cached.
Writing the email
The three messages that matter say different things, so branch on kind and threshold:
defmodule MyApp.Billing.QuotaAlertEmail do
use Oban.Worker, queue: :mailers, max_attempts: 10
@impl Oban.Worker
def perform(%Oban.Job{args: args}) do
org = MyApp.Orgs.get!(args["org_id"])
email =
case {args["kind"], args["threshold"]} do
{"hard", 100} -> MyApp.Mailer.QuotaEmails.limit_reached(org, args)
{"metered", 100} -> MyApp.Mailer.QuotaEmails.overage_started(org, args)
{_kind, _threshold} -> MyApp.Mailer.QuotaEmails.approaching(org, args)
end
case MyApp.Mailer.deliver(email) do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
end
end
Keep each email concrete: the number used, the number included, the date the period resets, and one clear action. For a hard cap at 80% that action is “upgrade”. For metered overage it is “here is what each extra unit costs, and here is where to set a spend cap if you want one”. Honest numbers build trust; vague “you’re using a lot!” copy does not.
The in-app banner, live
Email is for customers who are not looking. For the ones who are, show the state in the app, and update it as usage moves. Aurora Meter broadcasts usage over the Phoenix.PubSub you already run:
defmodule MyAppWeb.DashboardLive do
use MyAppWeb, :live_view
@features [:ai_generations]
def mount(_params, _session, socket) do
org = socket.assigns.current_org
if connected?(socket), do: AuroraMeter.LiveView.subscribe(org)
{:ok, assign(socket, org: org, banners: banners(org))}
end
def handle_info({:aurora_meter, :usage, %{feature: feature}}, socket)
when feature in @features do
{:noreply, assign(socket, banners: banners(socket.assigns.org))}
end
def handle_info({:aurora_meter, _tag, _payload}, socket), do: {:noreply, socket}
defp banners(org) do
for feature <- @features,
%{percent: p} = quota <- [AuroraMeter.quota(org, feature)],
is_integer(p) and p >= 80,
do: {feature, quota}
end
end
<div :for={{feature, quota} <- @banners} class="rounded border p-3 text-sm">
<%= if quota.percent >= 100 do %>
You have used all <%= quota.limit || quota.included %> <%= feature %> this period.
<% else %>
You have used <%= quota.percent %>% of your <%= feature %> this period.
<% end %>
</div>
<.usage_meter tenant={@org} feature={:ai_generations} />
The banner reads the same quota/2 map as the sweep, so the app and the email always agree. <.usage_meter> is the drop-in progress bar from the core, which updates over the same PubSub topic. For the full dashboard treatment, see building a real-time usage dashboard with Phoenix LiveView.
Edge cases worth deciding up front
- Clusters. On several nodes, a hard cap is enforced against each node’s view and converges within the broadcast interval (one second by default). A ten-minute sweep does not care about a one-second lag. Counting usage across a Phoenix cluster covers the details.
-
Periods are UTC. The free core uses calendar months as half-open UTC intervals. Put the reset date in the email in the customer’s timezone, but compute it from
quota.period.end. -
Downgrades. A customer who downgrades from 10,000 to 1,000 while at 900 used is instantly at 90%. Because
limitis in the dedup key, the next sweep sends the 80% email for the new plan. Decide whether that is what you want, or whether the downgrade confirmation screen already said it. - Trials. Alerting a trial user at 80% of a small trial allowance is one of the better conversion nudges you have. Word it as an invitation, not a warning.
-
Internal accounts. Exclude your own staff and test orgs from
active_org_ids/0, or your team will receive a lot of mail.
Or let Aurora Meter Pro do it
Everything above is roughly a hundred lines you now own and test. Aurora Meter Pro ships the same design as a supported feature. You set thresholds and a handler:
config :aurora_meter_pro,
alert_thresholds: [80, 100],
alert_handler: &MyApp.Billing.quota_alert/1
and schedule the sweep:
{Oban.Plugins.Cron, crontab: [{"*/10 * * * *", AuroraMeter.Pro.Alerts}]}
Your handler receives a payload with tenant_key, feature, kind (:hard or :metered), threshold, usage, limit, percent, period_start and period_end:
defmodule MyApp.Billing do
def quota_alert(%{tenant_key: key, feature: feature, kind: kind, percent: percent} = alert) do
org = MyApp.Orgs.get_by_key!(key)
case {kind, percent} do
{:hard, p} when p >= 100 -> MyApp.Mailer.limit_reached(org, feature, alert.period_end)
{:metered, p} when p >= 100 -> MyApp.Mailer.overage_started(org, feature, alert.usage - alert.limit)
{_kind, p} -> MyApp.Mailer.approaching(org, feature, p)
end
end
end
Pro deduplicates per tenant, feature, period and threshold. If your handler raises or returns {:error, reason}, the dedup record is rolled back and the next sweep tries again, so a failed delivery is never recorded as a delivered one. Every alert also emits [:aurora_meter, :alert] telemetry, which is handy for a Slack notification to your own team when a big customer hits a cap. With Pro, periods can also align to the customer’s Stripe subscription rather than the calendar month, so “resets on the 14th” is actually true.
Pro sits alongside Stripe billing, usage reporting to Stripe Billing Meters, the styled usage dashboard and the reconciler. It is licensed per app from $99/mo, with a 14-day free trial (one trial per customer). See pricing for the tiers.
Summary
- Quota alerts stop hard caps from feeling like outages and metered overage from feeling like an ambush.
- Alert on hard caps (relative to the limit) and metered allowances (relative to what is included). Skip counters; let the credit ledger handle low balances.
- Follow the four rules: once per threshold per period, never lost on failure, never on the hot path, reset by the period itself.
-
On the free core: a
quota_alertstable keyed on org, feature, period start, threshold and limit; an Oban sweep that readsAuroraMeter.quota/2; and a transactional outbox that writes the dedup rows and the email job together. -
Show the same numbers live in the app with
AuroraMeter.LiveView.subscribe/1and<.usage_meter>. - Or configure two keys and one cron line in Aurora Meter Pro.
The metering, quotas and live components used here are all in the free core: read the Aurora Meter docs to add it to your Phoenix app today, and reach for Pro when you want alerts, Stripe billing and reconciliation handled for you.