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:

  1. A Product per plan (Writer, Studio).
  2. A recurring Price on each: this is the flat monthly fee. Copy the price id (price_...).
  3. 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:

  1. 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:

  1. 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.

  1. 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_id is required once anything is mapped for export, stripe_mode must agree with the API key’s prefix, a meter name must not be empty, api_version must look like a Stripe version, and the variable behind a required {:system, "VAR"} must actually be set. Any of them raises AuroraMeter.Pro.ConfigError, which prints an API key’s prefix and never the key.
  2. The source cutover check, which is the subject of the cutover guide.
  3. The account assertion: stripe_account_id is 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; :require refuses to boot; :skip makes no call, which is what a test configuration wants. Until a boot confirms it, a live-mode installation quarantines outbox items with :account_unconfirmed rather 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_events before 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.
  • livemode must agree with stripe_mode. An event carrying no livemode is 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 carries outcome: "error".
  • The account must be this installation’s. A Connect event’s account must equal stripe_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.Pruner is 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.Expirer is the deprecated name for the same work: move the entry, and do not register both, which AuroraMeter.Oban.validate!/1 refuses.
  • 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 executing for ever, because nothing observed the death, and nothing in either package can clear it. Pick rescue_after longer than your slowest run: Lifeline rescues on elapsed time alone and cannot tell a dead node from a slow one. AuroraMeter.Oban.validate!/1 warns 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_sources entry is :events is 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