Getting started with Pro
The free core counts usage and enforces plans. Pro is the part that touches money: it sells subscriptions and prepaid credit through Stripe, keeps the two in step through webhooks, reports metered overage, and gives you a dashboard, an audit log and alerts.
If you have not read the core’s Concepts, read that first. Everything here assumes you know what a tenant, a feature, a period and a plan are.
The one idea to hold on to
Stripe is the payment rail. Your database is the source of truth.
Money arrives as a webhook, and every webhook can arrive twice, late, out of order, or never. So every write Pro makes is keyed on something Stripe will repeat (the PaymentIntent id, the dispute id), and a redelivery finds the work already done and changes nothing.
You will see that idea everywhere in these guides. It is the reason the code looks more careful than it “needs” to.
1. Install
Pro is served from the Aurora Meter package repository, not from hex.pm. Add the repository once on each machine (and in CI) with the key from your account page:
mix hex.repo add aurorameter https://aurorameter.com/repo \
--fetch-public-key SHA256:+g07CrUcsckpovkT9THeaiPWYLyyAP0p/OS+iZ1qTT8 \
--auth-key <KEY>
Then declare both packages:
def deps do
[
{:aurora_meter, "~> 1.0"},
{:aurora_meter_pro, "~> 1.2", repo: "aurorameter"}
]
end
Pro 1.x requires core 1.x. It declares aurora_meter as
>= 1.0.0-rc.1 and < 2.0.0, so upgrading Pro ahead of core fails to resolve
rather than compiling into something that does not work.
Every machine that runs mix deps.get needs the repository: CI and Docker
builds too. Installing Pro in CI, Docker and Fly has the
GitHub Actions step, the Dockerfile change and the fly deploy flag.
Run the installer
With core installed (mix aurora_meter.install) and
Igniter in your dependencies, one task does the
rest of this page’s wiring:
mix aurora_meter_pro.install --repo MyApp.Repo
mix ecto.migrate
It adds config :aurora_meter_pro with placeholder secrets
({:system, "STRIPE_WEBHOOK_SECRET"} and the like, never a value), adds
AuroraMeter.Pro to your supervision tree after AuroraMeter, generates the
migration, and adds Pro’s workers to your Oban crontab. It refuses, writing
nothing, if core is not installed yet. It does not touch your endpoint, so
step 4 (mounting the webhook) is still yours, and it never contacts Stripe.
Without Igniter, generate the migrations by hand and follow sections 3 to 5:
mix aurora_meter.gen.migration -r MyApp.Repo # core, if you have not already
mix aurora_meter_pro.gen.migration -r MyApp.Repo # pro
mix ecto.migrate
On an existing install, pass --from N to generate a migration that applies
only the new versions. The version lists are in AuroraMeter.Migration and
AuroraMeter.Pro.Migration.
2. What to create in Stripe
Pro does not create your products for you, and the names have to line up with your config. Doing this in the wrong order is the most common way a first install misbehaves, so here is the whole list.
For subscriptions:
-
A Product per plan (
Writer,Studio). -
A recurring Price on each: this is the flat monthly fee. Copy the price
id (
price_...). -
If the plan bills overage, a Billing Meter (say,
generations) and a second, metered Price attached to that meter. Give it a graduated tier: the included allowance at $0, everything above at your unit price.
For prepaid credit:
-
A Product for credit. Pro will find or create one by metadata if you do
not set
credits_product_id, which is fine for development and worth pinning explicitly in production.
For everything:
-
A webhook endpoint pointing at your app (
https://your.app/webhooks/stripe), subscribed to the events in section 4, and its signing secret (whsec_...).
The endpoint must use the Snapshot payload style. When you add an event
destination in the Stripe Dashboard (Workbench), Stripe asks for a payload style
and recommends Thin for new integrations. Pro needs Snapshot: a thin
event carries only the object’s id, with no data.object, so every handler
finds nothing to apply and the event is acknowledged with a 200 that changes
nothing. No error, and no credit. A classic webhook endpoint created through the
API or the Stripe CLI is always Snapshot:
stripe webhook_endpoints create \
-d url=https://your.app/webhooks/stripe \
-d api_version=2025-11-17.clover \
-d "enabled_events[]=checkout.session.completed" \
-d "enabled_events[]=customer.subscription.created" \
-d "enabled_events[]=customer.subscription.updated" \
-d "enabled_events[]=customer.subscription.deleted" \
-d "enabled_events[]=payment_intent.succeeded" \
-d "enabled_events[]=payment_intent.payment_failed" \
-d "enabled_events[]=charge.refunded" \
-d "enabled_events[]=charge.refund.updated" \
-d "enabled_events[]=refund.created" \
-d "enabled_events[]=refund.updated" \
-d "enabled_events[]=refund.failed" \
-d "enabled_events[]=charge.dispute.funds_withdrawn" \
-d "enabled_events[]=charge.dispute.funds_reinstated"
api_version pins the version events are serialised at to the one Pro sends
its own requests at (see “Mode, account and the version an event arrives at”
below). The response carries the signing secret once.
The one Thin destination Pro can use is optional and separate: Stripe’s meter error reports exist only as thin events, and reconciliation explains the second destination that receives them.
3. Configure
# The core learns to use Stripe for billing and for period boundaries
config :aurora_meter,
provider: AuroraMeter.Pro.Stripe,
period_source: AuroraMeter.Pro.Period,
# let Pro react to the ledger running low
credits_low_balance_handler: &AuroraMeter.Pro.Credits.on_low_balance/1
config :aurora_meter_pro,
stripe_prices: %{writer: "price_flat_writer", studio: "price_flat_studio"},
stripe_metered_prices: %{writer: ["price_writer_overage"]},
stripe_meters: %{generations: "generations"},
webhook_secret: {:system, "STRIPE_WEBHOOK_SECRET"}
period_source: AuroraMeter.Pro.Period is worth a moment. Without it a period
is a calendar month, so a customer who subscribes on the 20th gets a fresh
allowance eleven days later. With it, the period is their billing cycle, and
the allowance resets when they are charged.
Every secret may be given as {:system, "VAR"} and is read at runtime, so a
release does not bake it into the build.
Everything you can set
| Key | Default | What it does |
|---|---|---|
stripe_account_id |
n/a |
the acct_... this installation belongs to. Required once anything is mapped for export |
stripe_mode |
from the API key |
:test or :live. Set it and it must agree with the key’s prefix |
stripe_account_check |
:best_effort |
whether boot confirms the account against Stripe: :skip, :best_effort or :require |
api_version |
"2025-11-17.clover" |
the Stripe-Version Pro sends on every request |
webhook_api_version |
:pinned |
the version received events are expected to carry; see below |
mapping_version |
1 |
bump when a mapping change moves where usage lands |
stripe_prices |
%{} |
plan → flat price id |
stripe_metered_prices |
%{} |
plan → overage price ids |
stripe_meters |
%{} |
feature → Stripe Billing Meter name |
webhook_secret |
n/a | signing secret; may be a list during rotation |
stripe_portal_configuration |
n/a | a specific Billing Portal configuration |
alert_thresholds |
[80, 100] |
percentages that fire quota alerts |
alert_handler |
nil |
fun/1 called with the alert payload |
credits_product_id |
found by metadata | the Stripe product credit is sold as |
credits_presets_cents |
[1_000, 2_500, 5_000, 10_000] |
the buttons on your top-up page |
credits_min_cents |
500 |
smallest top-up |
credits_max_cents |
100_000 |
largest top-up |
credits_tax |
:none |
:exclusive adds tax on top through Stripe Tax |
credits_handler |
nil |
fun/1 called on credit events |
auto_top_up_cooldown_seconds |
600 |
minimum gap between auto charges |
auto_top_up_max_failures |
3 |
declines before auto top-up switches off |
audit_log_retention_days |
30 |
how long audit rows are kept |
audit_log_writer |
:async |
:sync in tests |
exporter |
AuroraMeter.Pro.Exporter.StripeMeterEvents |
the exporter the outbox delivers through |
outbox_lease_seconds |
120 |
how long a claim holds an item |
outbox_max_attempts |
20 |
attempts before an item escalates to uncertain |
outbox_batch_size |
100 |
items claimed per deliverer run |
outbox_backoff_base_seconds |
5 |
first retry delay; it doubles per attempt |
outbox_backoff_cap_seconds |
900 |
largest computed retry delay |
outbox_rate_limit_delay_seconds |
30 |
delay after a rate limit |
outbox_retention_days |
90 |
how long a confirmed item is kept |
uncertainty_horizon_seconds |
82_800 |
how long an item may still be resent |
reporter_batch_size |
200 |
subscriptions per reporter page |
docs/api.md section 5 carries the same list with types and stability classes.
Add AuroraMeter.Pro to your supervision tree
children = [
MyApp.Repo,
AuroraMeter,
AuroraMeter.Pro
]
After your Repo. AuroraMeter.Pro starts no process: it runs three boot
checks and returns :ignore, which is a clean start a supervisor accepts and
never restarts. Observer will show the child with no pid, which is correct.
-
Your configuration is validated, as a whole, against a schema. A
misspelled key refuses the boot, with the valid keys listed.
{:system, "VAR"}is resolved here, once, so a variable unset after boot cannot change what a running node does. Five checks that a type cannot express run with it:stripe_account_idis required once anything is mapped for export,stripe_modemust agree with the API key’s prefix, a meter name must not be empty,api_versionmust look like a Stripe version, and the variable behind a required{:system, "VAR"}must actually be set. Any of them raisesAuroraMeter.Pro.ConfigError, which prints an API key’s prefix and never the key. - The source cutover check, which is the subject of the cutover guide.
-
The account assertion:
stripe_account_idis confirmed against the account your API key actually reaches.stripe_account_check: :best_effort(the default) warns and carries on when Stripe cannot be reached, because a provider outage must not stop a deploy;:requirerefuses to boot;:skipmakes no call, which is what a test configuration wants. Until a boot confirms it, a live-mode installation quarantines outbox items with:account_unconfirmedrather than addressing work to an account it has not verified.
You do not have to add it. Without it the first configuration read validates lazily and logs one warning naming this module, and a validation that fails on that path is logged rather than raised, because a configuration problem belongs at boot and not on a metering call. What you lose is checks 2 and 3: they run from here and nowhere else.
AuroraMeter.Pro.validate!/0 is check 1 on its own, for a release health check
that wants to fail a deploy without booting the application.
4. Mount the webhook
# lib/my_app_web/endpoint.ex, ABOVE plug Plug.Parsers
plug AuroraMeter.Pro.Webhook, path: "/webhooks/stripe"
In the endpoint, before Plug.Parsers, with :path. Stripe signs the raw
request body. Once a parser has read it, the plug sees an empty body and every
signature fails, so a router forward (which always runs after the endpoint’s
parsers) cannot work. With path: the plug handles only a POST to exactly
that path and passes every other request through untouched. Without path: it
treats every request as a Stripe event, which in an endpoint answers 400 to
every page of your app.
These are the event types Pro handles. Subscribe the endpoint to all of them:
| Event | Why |
|---|---|
customer.subscription.created .updated .deleted |
keep the plan in step (Pro syncs every customer.subscription.* type, so .paused and .resumed work too if you use pausing) |
checkout.session.completed |
a subscription or a top-up finished |
payment_intent.succeeded |
credit lands in the ledger |
payment_intent.payment_failed |
an auto top-up was declined |
charge.refunded |
take credit back |
charge.refund.updated, refund.created, refund.updated, refund.failed |
reconcile current refunds, restoring credit when a refund fails or is cancelled |
charge.dispute.funds_withdrawn |
a chargeback |
charge.dispute.funds_reinstated |
you won it; put the credit back |
A subscription event for a subscription that carries no tenant_key metadata
(one your app sells outside Aurora Meter on the same Stripe account) is answered
200 and logged as refused, so Stripe does not retry it. Subscriptions created
through AuroraMeter.Billing.checkout/3 always carry it.
Miss charge.dispute.* and a chargeback takes your money while the customer
keeps spendable credit, and nothing downstream will notice.
invoice.paid is not on the list: AuroraMeter.Pro.Webhook.handle_event/1
returns :ignore for it. Subscribing to it costs nothing and it is acknowledged
with a 200, but nothing here depends on it.
Mode, account and the version an event arrives at
Three things are checked before a handler sees an event, and all three answer 500 rather than 200 when they refuse, because a 200 tells Stripe to stop redelivering and a misconfiguration that silently discards events is one nobody finds until the month’s invoices are wrong.
-
The event id is recorded in
aurora_meter_webhook_eventsbefore the handler runs and marked applied after. A redelivery of an applied event answers 200 immediately and does no work. A redelivery whose first delivery crashed runs the handler again, because every handler is idempotent and refusing would strand it. -
livemodemust agree withstripe_mode. An event carrying nolivemodeis refused too: Stripe sends it on every event, so an event without one is not an event this package recognises. A host that points a test endpoint at a live installation will see Stripe retry for days; the log line says so on every delivery and the[:aurora_meter, :pro, :webhook]telemetry carriesoutcome: "error". -
The account must be this installation’s. A Connect event’s
accountmust equalstripe_account_id, and a payment event’s tenant must not already be stamped with a different one. A tenant that has never been stamped is stamped on first use; a null is “not yet stamped”, never “mismatch”.
The version an event is serialised at is not the version Pro sends. Stripe
serialises a webhook event at the version the endpoint was created with, or,
when the endpoint names none, at your account’s default version. Pro’s own
requests go out at api_version. On this project’s own test account those were
2024-06-20 and 2025-11-17.clover respectively, in the same flow, reconciled
against each other.
So Pro records every event’s version, compares it with webhook_api_version
(:pinned, the default, meaning api_version) and logs once per distinct
version when they differ. It does not refuse the event: the event is a real
fact about real money and dropping it would be worse than reconciling it. To
make the two agree, create or update your webhook endpoint with an api_version
equal to api_version. To record that you intend the difference, set
webhook_api_version to the version your endpoint actually sends. To stop
checking, set it to :any.
Rotating the secret
webhook_secret accepts a list. Put the new secret alongside the old one,
deploy, switch Stripe over, then remove the old one. Events signed with either
are accepted in between, so a rotation does not drop anything on the floor.
config :aurora_meter_pro,
webhook_secret: [
{:system, "STRIPE_WEBHOOK_SECRET"},
{:system, "STRIPE_WEBHOOK_SECRET_NEXT"}
]
The list may mix plain strings and {:system, "VAR"} tuples; each element is
resolved on its own, and an element whose variable is unset is dropped rather
than breaking the rest. At most the first 5 secrets are used, and at most
the first 10 v1= signatures in one header are checked. Stripe sends one
signature, or two while it is rotating, so neither cap is reachable in practice;
they are there because signature checking happens before anything is
authenticated, and an unbounded cross product of header values and configured
secrets is work an anonymous caller could ask for. A sixth secret is ignored, so
do not leave one in the list expecting it to work.
Configuring no secret at all is not a quiet failure: every event is rejected with a 400 and one loud error line saying paid top-ups will not be credited.
5. Schedule the workers
config :my_app, Oban,
queues: [aurora_meter: 5],
plugins: [
{Oban.Plugins.Cron,
crontab: [
{"*/5 * * * *", AuroraMeter.Pro.UsageReporter},
{"*/5 * * * *", AuroraMeter.Pro.Outbox.Deliverer},
{"*/10 * * * *", AuroraMeter.Pro.Outbox.Reconciler},
{"*/10 * * * *", AuroraMeter.Pro.Alerts},
{"15 2 * * *", AuroraMeter.Pro.Rollup},
{"30 3 * * *", AuroraMeter.Pro.Retention.Pruner},
{"*/5 * * * *", AuroraMeter.Pro.Credits.AutoTopUpSweeper},
{"*/5 * * * *", AuroraMeter.Pro.Transitions.Applier},
{"*/30 * * * *", AuroraMeter.Oban.CreditExpiry}
]},
{Oban.Plugins.Lifeline, rescue_after: :timer.minutes(20)}
]
What each one is for:
- UsageReporter: works out what each subscription owes and stages it. It makes no network call. Without it, overage is counted and never billed.
- Outbox.Deliverer: sends what the reporter staged. Register both: a host that adds only the reporter stages work and never delivers it.
- Outbox.Reconciler: asks Stripe whether it agrees, and records findings.
- Alerts: fires the threshold callbacks.
- Rollup: daily and monthly aggregates for the history charts.
-
Retention.Pruner deletes the operational rows the retention allow list
names: audit events, confirmed outbox items, webhook events, recovery actions
and resolved reconciliation items. Those are the tables that grow with
traffic, so a host that never prunes them is a host whose database grows
without a ceiling. Nothing financial is ever deleted, at any age.
AuroraMeter.Pro.AuditLog.Pruneris the deprecated name for the audit-log part of the same work: move the entry, and do not register both. - AutoTopUpSweeper: catches accounts sitting below their threshold that the edge-triggered hook missed, because it fires only on a crossing.
-
AuroraMeter.Oban.CreditExpiry: expires promotional credit that has passed
its date. It comes from the free core, not from Pro.
AuroraMeter.Pro.Credits.Expireris the deprecated name for the same work: move the entry, and do not register both, whichAuroraMeter.Oban.validate!/1refuses. -
Oban.Plugins.Lifeline is not one of Aurora Meter’s workers and it is in
the list for a reason. A node killed while any worker above is running leaves
that job
executingfor ever, because nothing observed the death, and nothing in either package can clear it. Pickrescue_afterlonger than your slowest run: Lifeline rescues on elapsed time alone and cannot tell a dead node from a slow one.AuroraMeter.Oban.validate!/1warns if you leave it out. See a node that dies mid job.
Only schedule what you use. A product with no prepaid credit does not need the last two; a product with no metered features does not need the first two. The scheduler map is the full list with what each one does if it runs twice.
6. Tests should never reach Stripe
# config/test.exs
config :aurora_meter_pro,
credits_stripe_client: AuroraMeter.Pro.Credits.StripeClient.Fake,
audit_log_writer: :sync
The fake records every call and answers like Stripe does, including answering a repeated idempotency key with the PaymentIntent it made the first time, which is what makes it possible to test that a retried charge does not take the money twice.
audit_log_writer: :sync makes audit rows land before the assertion instead of
in a task you would have to wait for.
alias AuroraMeter.Pro.Credits.StripeClient.Fake
setup do
Fake.reset()
:ok
end
test "a top-up funds the ledger exactly once" do
org = org_fixture()
event = %{
"type" => "payment_intent.succeeded",
"data" => %{"object" => %{
"id" => "pi_1",
"amount_received" => 2_500,
"customer" => "cus_1",
"payment_method" => "pm_1",
"metadata" => %{"tenant_key" => MyApp.tenant_key(org), "kind" => "top_up"}
}}
}
{:ok, _} = AuroraMeter.Pro.Credits.handle_event(event)
{:ok, _} = AuroraMeter.Pro.Credits.handle_event(event) # Stripe redelivered
assert AuroraMeter.Credits.available(org) == 25_000_000 # not 50_000_000
end
That second handle_event/1 is the test worth copying into your own suite. A
redelivery is not a rare edge case: Stripe retries any non-2xx for days.
Where next
- Subscriptions: selling a plan, the portal, and what each webhook does
- Top-ups: prepaid credit, auto-recharge, refunds and disputes
-
Usage reporting: getting overage onto the invoice.
Read “Reporting source” there for which features this reporter stages: a
feature whose core
feature_sourcesentry is:eventsis exported at record time instead, and moving a feature you are already billing from one to the other needs a cutover - Operations: the dashboard, audit log, alerts, rollups and the reconciler
- Installing Pro in CI, Docker and Fly: the package repository on machines that are not yours