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.

  1. Once per threshold per period. A customer sitting at 81% for two weeks gets one email, not one every ten minutes.
  2. Not lost on failure. If the email provider is down when the alert fires, the alert is retried, not silently marked as sent.
  3. 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/2 would throw that away.
  4. 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 limit is 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_alerts table keyed on org, feature, period start, threshold and limit; an Oban sweep that reads AuroraMeter.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/1 and <.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.