Every software business you have ever paid for does the same three things. It counts what you used. It stops you when you have had your share. It charges you for the rest.
This guide builds all three, from an empty folder to money in a Stripe account. We are going to be slow and say everything out loud, because the parts people get wrong are never the hard parts. They are the small decisions made in a hurry on day one.
#What we are building
Notewell turns a recording of a meeting into a written summary.
Three plans:
| Plan | Price | Summaries | Seats | PDF export |
|---|---|---|---|---|
| Free | $0 | 5, then it stops | 1 | no |
| Starter | $19 a month | 200, then it stops | 5 | yes |
| Studio | $49 a month | 1,000 included, then 5 cents each | 25 | yes |
Read that table again, because it already contains three different ideas:
- A wall. Free and Starter stop dead. The customer cannot make a sixth summary on Free, no matter how much they want one.
- An allowance. Studio does not stop. The 1,001st summary works, costs five cents, and turns up on next month’s invoice.
- A number and a switch. Seats is just a number the plan carries. PDF export is just on or off.
Aurora Meter has a different tool for each of those, and picking the wrong one is the single most common mistake. We will meet all three.
#Before you start
You need:
- Elixir 1.15 or newer, and Phoenix 1.7 or newer.
- PostgreSQL running somewhere you can reach.
- A Stripe account in test mode. Free, takes two minutes, no card.
You do not need to have read anything else. If a word here is unfamiliar, Every word, explained once defines all of them in ten minutes, and this guide will still be here afterwards.
Two package names, so you know what is what:
-
aurora_meteris the free core. MIT licence, on Hex, no account, no expiry. It counts and it gates. Steps 1 to 8 use only this. -
aurora_meter_prois the commercial part. It talks to Stripe. Steps 9 and 10 use it. There is a 14 day free trial, so you can finish this guide without paying anything.
#Step 1: A new Phoenix app
mix phx.new notewell
cd notewell
mix ecto.create
Nothing special yet. If you already have an app, use that instead and skip to step 2.
We need something to hang a customer off. In Notewell an organisation pays, and the people inside it share the allowance, so five colleagues on one Starter plan get 200 summaries between them, not 200 each.
mix phx.gen.context Accounts Org orgs name:string
mix ecto.migrate
This is the decision that matters most. Whoever you decide pays is the thing you will count against for the rest of the product’s life. Pick the organisation, not the person, unless you are certain each person buys their own. Changing your mind later means every customer’s history is attached to the wrong thing.
#Step 2: Add Aurora Meter
# mix.exs
def deps do
[
{:aurora_meter, "~> 0.4"}
]
end
mix deps.get
mix aurora_meter.gen.migration -r Notewell.Repo
mix ecto.migrate
That migration creates the tables the library keeps its own records in. You will not write to them yourself.
Now tell it three things: which database to use, which PubSub to broadcast on, and where your plans will live.
# config/config.exs
config :aurora_meter,
repo: Notewell.Repo,
pubsub: Notewell.PubSub,
plans: Notewell.Plans,
default_plan: :free
default_plan is what a customer gets when nothing else says otherwise: a
fresh signup, or somebody whose subscription has lapsed. Set it to your free
tier and a cancellation quietly becomes a downgrade instead of a locked door.
Then start it, after your repo and your PubSub:
# lib/notewell/application.ex
children = [
Notewell.Repo,
{Phoenix.PubSub, name: Notewell.PubSub},
AuroraMeter,
NotewellWeb.Endpoint
]
Order matters. Aurora Meter reads the database and broadcasts over PubSub, so both have to be awake before it is.
#Step 3: Say who the customer is
Every Aurora Meter call takes the customer first. Internally it needs a short text key for that customer, because the key becomes the name of a counter, a row in the database and a PubSub topic.
Out of the box you can pass a string or a number and it will use it as is. That
is fine, but you will be passing %Org{} structs around your app, and
converting them by hand at every call site is how a bug gets in. Teach the
library to do it instead:
defmodule Notewell.MeterTenant do
@behaviour AuroraMeter.Tenant
@impl true
def to_key(%Notewell.Accounts.Org{id: id}), do: "org:#{id}"
def to_key(key) when is_binary(key), do: key
end
# config/config.exs
config :aurora_meter, tenant: Notewell.MeterTenant
Now AuroraMeter.track(org, :summaries) works with the struct you already have
in your assigns.
The key has to be stable and it has to be unique. Use the database id. Never use the name: a customer who renames their company would look like a brand new customer with a fresh, empty allowance, and their history would vanish.
#Step 4: Write the plans down
Here is the whole pricing table from the top of this guide, in code:
defmodule Notewell.Plans do
use AuroraMeter.Plans
plan :free do
price 0
limit :summaries, 5, :hard
feature :seats, 1
feature :pdf_export, false
end
plan :starter do
price 1_900
limit :summaries, 200, :hard
feature :seats, 5
feature :pdf_export, true
end
plan :studio do
price 4_900
metered :summaries, included: 1_000, unit_price: 5
feature :seats, 25
feature :pdf_export, true
end
end
Read it line by line, because four different things are happening.
price is in cents, and Aurora Meter never charges it. It is a label for
your pricing page so the number lives in one place. Stripe is what actually
takes the money.
limit :summaries, 5, :hard is a wall. The sixth attempt is refused.
metered :summaries, included: 1_000, unit_price: 5 is an allowance. It
never refuses. After 1,000 it starts counting overage at five cents each.
Notice that :summaries is a wall on two plans and an allowance on the third.
That is allowed and it is normal. The counter is the same; only the rule read
against it changes.
feature does two different jobs. feature :pdf_export, true is a switch.
feature :seats, 5 is a number the plan carries, which you read yourself.
Why is
:seatsnot a limit? Because a limit only ever goes up until the month ends. People join and people leave, so seats go down as well as up. A limit can never go down. The rule of thumb: if removing one makes the number smaller, it is afeaturevalue you compare against your ownCOUNT(*). If it can only climb until the period resets, it is alimitor ameteredfeature.
This module is checked when your app compiles. A duplicate feature, a negative limit or a misspelled mode is a compile error, not a 3am surprise.
Put a new customer on the free plan when they sign up:
def create_org(attrs) do
with {:ok, org} <- %Org{} |> Org.changeset(attrs) |> Repo.insert() do
AuroraMeter.subscribe(org, :free)
{:ok, org}
end
end
AuroraMeter.subscribe/2 has nothing to do with Stripe. It records, locally,
which plan’s rules to read for this customer.
#Step 5: Count the work
One line:
AuroraMeter.track(org, :summaries)
Five if a single upload produced five summaries:
AuroraMeter.track(org, :summaries, 5)
That is the whole hot path, and it is worth knowing why you can put it anywhere, including inside a loop.
The increment lands in an in-memory table (ETS) using an atomic operation. No process is asked for permission, no row is locked, nothing waits. A background worker collects the totals and writes them to Postgres every five seconds, and again on a clean shutdown. On the bundled benchmark that is around 5.5 million increments a second across distinct counters.
The trade is right there in the sentence: if the machine dies, up to five seconds of counts die with it.
For a five cent summary, losing a handful is cheaper than the machinery to never lose one. If that is not true for you, say so per feature:
config :aurora_meter, durable_features: [:summaries]
A durable feature is written straight to the database on every single call. Correct, slower, and a database write per request. Choose it for the things you invoice, not for everything.
#Step 6: Stop people at the line
Now the interesting part. Here is code that looks right and is wrong:
# WRONG. Do not copy this.
def summarise(org, upload) do
with :ok <- AuroraMeter.check(org, :summaries) do
result = Notewell.AI.summarise(upload)
AuroraMeter.track(org, :summaries)
{:ok, result}
end
end
Two people in the same company upload at the same moment. The plan allows five and four are used. Both requests ask “am I allowed?” Both are told yes, because both read four. Both run. Both count. The company now has six summaries on a plan that allows five, and nothing anywhere will ever notice.
The fix is one function that reserves the permission and the count together, in one atomic step:
defmodule Notewell.Summaries do
def summarise(org, upload) do
AuroraMeter.with_quota(org, :summaries, fn ->
Notewell.AI.summarise(upload)
end)
end
end
Notewell.Summaries.summarise(org, upload)
# {:ok, "The team agreed to..."}
# {:error, :limit_exceeded} they are out
# {:error, :not_entitled} their plan does not include this at all
Under any amount of concurrency, a cap of five admits exactly five.
Three things to remember about it:
The reservation is the count. with_quota/4 increments the counter itself.
Do not also call track/3, or everything counts twice.
A crash gives the slot back. If your function raises or the process exits (a database timeout, say), the reservation is released before the error travels on. A failed summary does not burn an allowance.
It works the same on the Studio plan. A metered feature never refuses, so the call simply always succeeds and keeps counting. Your code does not need to know which plan the customer is on, which is the whole point.
For several at once, pass a quantity, and it is all or nothing:
AuroraMeter.with_quota(org, :summaries, length(uploads), fn ->
Enum.map(uploads, &Notewell.AI.summarise/1)
end)
Twelve uploads against ten remaining refuses all twelve rather than doing ten and failing halfway.
#The switch and the seat count
The other two rules are simpler. A switch:
AuroraMeter.entitled?(org, :pdf_export)
# false on :free, true on :starter and :studio
<.link :if={AuroraMeter.entitled?(@org, :pdf_export)} href={~p"/notes/#{@note}/pdf"}>
Download PDF
</.link>
And a seat count, which you compare against your own row count:
def can_invite?(org) do
allowed = AuroraMeter.feature_value(org, :seats, 1)
used = Repo.aggregate(from(m in Member, where: m.org_id == ^org.id), :count)
used < allowed
end
That third argument to feature_value/3 is the answer used when the plan says
nothing about that feature. Pick it like you mean it: 1 is a safe floor, 0
locks everybody out, 999 gives the shop away.
Two questions that look identical and are not.
entitled?/2asks “does their plan include this at all?”allowed?/2asks “and is there room right now?” A free customer who has used all five summaries is entitled but not allowed. Use the first to decide whether to show a feature, the second to decide whether this click works. Get it backwards and you show an upgrade prompt for something they already pay for.
#Step 7: Show them where they are
One call gives you everything a dashboard card needs, for any kind of feature:
AuroraMeter.quota(org, :summaries)
# %{feature: :summaries, kind: :hard, used: 5, limit: 5, included: 5,
# remaining: 0, overage: 0, percent: 100, enabled: true, unit_price: nil,
# period: %{start: ~U[2026-09-01 00:00:00Z], end: ~U[2026-10-01 00:00:00Z],
# source: :calendar}}
The kind tells you how to draw it, and they do not all draw the same way:
kind |
What it is | How to draw it |
|---|---|---|
:hard |
a wall | a bar, full at 100% |
:metered |
an allowance you may pass | a bar and the overage number |
:counter |
measured, nothing to compare to | a bare number, no bar |
:boolean |
a switch | a tick or a cross |
:feature |
a number the plan carries | the number |
Two rules that keep the page honest:
percent never goes above 100. At 1,240 of 1,000 it says 100, and it says
100 at 10,000 too. That is right for drawing a bar and useless for writing a
sentence. When kind is :metered, read overage to find out whether they
went past and by how much.
percent: nil means there is no bar to draw. Turn that into a zero and
your page tells a paying customer they have used “0% of 0”.
Which makes a genuinely useful upgrade prompt easy to write:
<div :if={@quota.kind == :hard and @quota.remaining == 0} class="upgrade">
<p>
You have used all <%= @quota.limit %> summaries on Free.
They reset on <%= Calendar.strftime(@quota.period.end, "%e %B") %>.
</p>
<.link href={~p"/billing/upgrade"}>Starter gives you 200 a month</.link>
</div>
That is a message a customer can act on, instead of the word “Upgrade!” next to a red bar.
If you would rather not write the markup at all, the shipped component already knows all of the rules above:
<AuroraMeter.Components.usage_meter tenant={@org} feature={:summaries} />
<AuroraMeter.Components.usage_summary tenant={@org} />
They paint with currentColor, so they take the colours your design already
set. No stylesheet to import and no JavaScript.
#Step 8: Make it live
Usage totals are broadcast about once a second over PubSub, so a page can show the number climbing without asking the database anything.
defmodule NotewellWeb.UsageLive do
use NotewellWeb, :live_view
def mount(_params, _session, socket) do
org = socket.assigns.current_org
if connected?(socket), do: AuroraMeter.LiveView.subscribe(org)
{:ok, assign(socket, org: org, quota: AuroraMeter.quota(org, :summaries))}
end
def handle_info({:aurora_meter, :usage, %{feature: :summaries}}, socket) do
{:noreply, assign(socket, quota: AuroraMeter.quota(socket.assigns.org, :summaries))}
end
def handle_info({:aurora_meter, :usage, _other}, socket), do: {:noreply, socket}
end
Two details worth copying exactly.
Subscribe only when connected?/1 is true. A LiveView mounts twice: once
to render the page as plain HTML, once when the browser opens the socket.
Subscribing in the first one gives you a subscription belonging to a process
that is about to die.
Keep the catch-all clause. You subscribed to the customer, not to one
feature, so you get a message every time they use anything. Without the second
handle_info/2, an unrelated feature crashes the page.
An idle customer produces no messages at all, because a broadcast only carries what changed.
For a chart, ask for daily buckets. They come back already filled in, oldest first, so a quiet Sunday is a zero and not a hole:
AuroraMeter.history(org, :summaries, days: 30)
# [%{date: ~D[2026-08-15], value: 41}, %{date: ~D[2026-08-16], value: 0}, ...]
Everything up to here is the free core. No card, no account, no expiry. You have a product that counts, enforces its own plans and shows a customer where they stand. What is missing is the money.
#Step 9: Take the money
Aurora Meter Pro is the part that talks to Stripe. It sells the subscription, keeps your database in step through webhooks, and reports overage.
def deps do
[
{:aurora_meter, "~> 0.4"},
{:aurora_meter_pro, "~> 0.3", organization: "phxtemplates"}
]
end
mix aurora_meter_pro.gen.migration -r Notewell.Repo
mix ecto.migrate
#What to make in Stripe first
Do this before you touch config, because the names have to line up. In test mode:
-
A Product called Starter, with a recurring Price of $19 a month.
Copy the price id, it looks like
price_1AbC.... - A Product called Studio, with a recurring Price of $49 a month.
-
A Billing Meter called
summaries. - A second, metered Price on the Studio product, attached to that meter, with a graduated tier: the first 1,000 units at $0, everything above at $0.05.
-
A webhook endpoint pointing at your app, and its signing secret, which
starts
whsec_.
Step 4 is the one people skip. Without it, Stripe has nowhere to put overage and the 1,001st summary is free forever.
#Configure it
# config/config.exs
config :aurora_meter,
provider: AuroraMeter.Pro.Stripe,
period_source: AuroraMeter.Pro.Period
config :aurora_meter_pro,
stripe_prices: %{starter: "price_flat_starter", studio: "price_flat_studio"},
stripe_metered_prices: %{studio: ["price_studio_overage"]},
stripe_meters: %{summaries: "summaries"},
webhook_secret: {:system, "STRIPE_WEBHOOK_SECRET"}
period_source: AuroraMeter.Pro.Period deserves a moment. Without it a period
is a calendar month, so somebody who subscribes on the 20th gets a fresh
allowance eleven days later and feels robbed. With it, the period is their
billing cycle and the allowance resets when they are charged.
Anything written {:system, "VAR"} is read from the environment at runtime, so
a release never has your secret baked into it.
#The checkout button
defmodule NotewellWeb.BillingController do
use NotewellWeb, :controller
def upgrade(conn, %{"plan" => plan}) do
org = conn.assigns.current_org
{:ok, url} =
AuroraMeter.Billing.checkout(org, String.to_existing_atom(plan),
success_url: url(~p"/billing?upgraded=1"),
cancel_url: url(~p"/billing")
)
redirect(conn, external: url)
end
end
checkout/3 finds the price for that plan, attaches the metered price too so
overage can be invoiced, writes your customer key into the session’s metadata,
and hands back a URL to send the browser to.
String.to_existing_atom/1, neverString.to_atom/1. That parameter arrives from a browser, andto_atomon anything a stranger can type will fill the atom table until the VM falls over.
#Catching the answer
# lib/notewell_web/endpoint.ex, BEFORE plug Plug.Parsers
plug AuroraMeter.Pro.Webhook
Before the parser, not after. Stripe signs the raw bytes of the request. As soon as a parser has read and re-encoded them, the bytes are different and every signature check fails. If that is awkward in your endpoint, forward one path to a pipeline that keeps the raw body.
Subscribe the Stripe endpoint to these events:
| Event | Why you need it |
|---|---|
customer.subscription.created .updated .deleted |
keep the plan in step |
checkout.session.completed |
a payment finished |
invoice.paid |
a new billing period began |
That is the whole integration. You do not write a handler. When the customer comes back from Stripe, this is already true:
AuroraMeter.plan(org).id # :studio
AuroraMeter.check(org, :summaries) # :ok
There is no sync job to wait for and no cache to clear.
#Cancellation takes care of itself
A subscription grants its plan only while Stripe says its status is active,
trialing or past_due. Anything else falls back to your default_plan, so a
cancellation downgrades the customer on its own. You do not write a nightly
sweep, and there is no window where a cancelled customer keeps paid features.
past_due staying entitled is deliberate. A failed renewal is usually an
expired card, and locking somebody out while Stripe retries their payment is
how a billing hiccup turns into a cancellation.
Finally, let Stripe host the boring screens:
def portal(conn, _params) do
case AuroraMeter.Billing.portal_url(conn.assigns.current_org,
return_url: url(~p"/billing")) do
{:ok, url} -> redirect(conn, external: url)
{:error, :no_customer} -> redirect(conn, to: ~p"/pricing")
end
end
{:error, :no_customer} means this customer has never paid you, so there is
nothing to manage. Send them to the pricing page rather than showing an error.
#Step 10: Bill what they went over
Studio customers can pass 1,000 summaries. The core counted it. Nothing has told Stripe yet.
config :notewell, Oban,
queues: [aurora_meter: 5],
plugins: [
{Oban.Plugins.Cron,
crontab: [
{"*/5 * * * *", AuroraMeter.Pro.UsageReporter},
{"*/10 * * * *", AuroraMeter.Pro.Alerts},
{"15 2 * * *", AuroraMeter.Pro.Rollup}
]}
]
- UsageReporter sends usage to the Stripe Billing Meter. Without it, overage is counted and never charged.
- Alerts fires your warning callback as customers approach and pass their allowance.
- Rollup builds the daily and monthly totals the history charts read.
Every run works out the difference since the last successful report and sends
only that. Stripe applies your graduated price to the total, which is why the
included: 1_000 in your plan module and the free tier in Stripe have to be
the same number. The plan module drives your dashboard’s estimate; Stripe
decides the actual charge.
Report often enough that a retry still lands before Stripe finalises the invoice. Every five minutes is a sensible default; hourly can miss a short window.
Now warn people before the invoice does:
config :aurora_meter_pro, alert_handler: &Notewell.Billing.quota_alert/1
defmodule Notewell.Billing do
def quota_alert(%{tenant_key: key, feature: feature, percent: percent}) do
org = Notewell.Accounts.get_org_by_key!(key)
cond do
percent >= 100 -> Notewell.Mailer.overage_started(org, feature)
percent >= 80 -> Notewell.Mailer.approaching(org, feature, percent)
true -> :ok
end
end
end
Alerts are deduplicated per customer, per feature, per period, so somebody sitting at 81% for a fortnight is emailed once, not every ten minutes.
#Step 11: Test it
Two helpers do the work. reset!/0 empties the in-memory counters between
tests, and flush!/0 pushes the pending numbers to Postgres immediately
instead of waiting five seconds for the timer.
defmodule Notewell.SummariesTest do
use Notewell.DataCase, async: false
setup do
AuroraMeter.Test.reset!()
:ok
end
test "the free plan stops at five" do
org = org_fixture()
AuroraMeter.subscribe(org, :free)
for _ <- 1..5 do
assert {:ok, _} = Notewell.Summaries.summarise(org, upload_fixture())
end
assert {:error, :limit_exceeded} = Notewell.Summaries.summarise(org, upload_fixture())
end
test "studio keeps going and records the overage" do
org = org_fixture()
AuroraMeter.subscribe(org, :studio)
AuroraMeter.track(org, :summaries, 1_240)
AuroraMeter.Test.flush!()
quota = AuroraMeter.quota(org, :summaries)
assert quota.kind == :metered
assert quota.overage == 240
end
end
And keep Stripe out of your suite entirely:
# config/test.exs
config :aurora_meter_pro,
credits_stripe_client: AuroraMeter.Pro.Credits.StripeClient.Fake,
audit_log_writer: :sync
The one test everybody should copy is the one that proves a repeated webhook does not charge twice. Stripe retries any non-2xx response for days, so a redelivery is not an edge case, it is Tuesday.
#Step 12: The go live checklist
Work down this before the first real card:
-
default_planis set, and it is your free tier. - Every feature you sell appears on every plan. An undeclared feature is allowed by default, which is kind to half-built features and unkind to typos.
-
The webhook plug is mounted before
Plug.Parsers. -
The Stripe graduated price and your
included:number are the same. - The UsageReporter is scheduled, and you have watched it run once.
-
You have decided which features are
durable_features, and written down why. - You have an alarm on failed flushes. A stream of them means usage is piling up in memory and will be lost if the machine restarts.
-
You ran a real payment in Stripe test mode with the card
4242 4242 4242 4242and watched the plan change.
The last one is not optional. Everything up to it can be right in theory.
#What you built
# a customer signs up
AuroraMeter.subscribe(org, :free)
# they do the work, safely, under any load
Notewell.Summaries.summarise(org, upload) # {:ok, _} | {:error, :limit_exceeded}
# the page says something true
AuroraMeter.quota(org, :summaries) # %{used: 5, limit: 5, percent: 100, ...}
# they pay
AuroraMeter.Billing.checkout(org, :studio, success_url: ..., cancel_url: ...)
# the plan applies itself, and cancelling undoes it
AuroraMeter.plan(org).id # :studio
# once every five minutes, Stripe hears about the overage, exactly once
Nine function calls, one plan module and one webhook plug. No usage table, no
nightly downgrade job, no cache to bust and no UPDATE counts SET n = n + 1 on
the hot path of every request.
#Where to go next
- Selling per unit instead of per month? Sell credit up front, spend it per request is the other money shape.
- Want the billing page to look finished? Put the numbers on screen.
- About to put this in front of real customers? Ship it: the production checklist covers clusters, alarms and the tests worth having.
Keep going
The core is free. Pro is the Stripe half.
Everything the free MIT package does is yours with no account and no expiry. Aurora Meter Pro adds Stripe checkout, metered usage reporting, prepaid top-ups, dashboards and alerts, and every plan starts with a free trial.