Getting metered usage onto the invoice
The core counts completed usage. Pro reports deltas to Stripe Billing Meters;
Stripe’s prices determine the invoice amount. Flat subscriptions and prepaid
counter features do not need the usage reporter.
What this guarantees, exactly, is one row of the guarantee page in the core package: export is at least once with provider-side idempotency. Each send carries an identifier naming the window, which is what Stripe deduplicates on, so a retry does not bill those units twice; a send whose answer never arrives is left uncertain rather than resent blindly past Stripe’s deduplication horizon; and nothing here reports that an invoice was finalised or paid. The page is the contract and this guide is how it works; where they disagree, the page is right and this guide is a bug.
Configure matching prices and periods
For a plan with metered :generations, included: 1_000, unit_price: 2, create
a Stripe Billing Meter and a graduated recurring price: the first 1,000 units
at zero, then 2 cents per unit. Attach that price alongside the flat price.
The plan defines the dashboard estimate; Stripe defines the actual charge.
Verify both configurations when changing prices.
config :aurora_meter,
provider: AuroraMeter.Pro.Stripe,
period_source: AuroraMeter.Pro.Period
config :aurora_meter_pro,
stripe_prices: %{writer: "price_flat_writer"},
stripe_metered_prices: %{writer: ["price_writer_overage"]},
stripe_meters: %{generations: "generations"}
stripe_meters maps feature atoms to meter event names. Configure the
meter’s sum aggregation, customer mapping key stripe_customer_id, and value
key value. A successful API receipt does not validate the resulting invoice;
verify an actual test-clock invoice before enabling a new pricing configuration.
mix aurora_meter.features (core 1.0.0) reads this map and reports any feature
in it that no plan declares, which is the usual cause of a meter that never
receives an event.
Planned: feature atoms come from the plan, not from the stored string
A feature is an atom in a plan and a string in the database, and the reporter
currently turns the stored string back into an atom with
String.to_existing_atom/1 (its private plan_atom/1, applied to row.feature
when it builds a reporting result). That conversion can fail for a feature whose
atom the running node has never loaded, and it fails by returning nil, which
then travels on as a feature name.
Core 1.0.0 guarantees that every feature reaching the facade is already an atom:
a binary is warned about now and refused in 1.0. Pro 1.0.0 therefore
rewrote the reporter to carry the atom from the plan definition rather than
converting the stored string, and no new String.to_existing_atom/1 on a feature
name is added anywhere in Pro. Nothing in your configuration changes.
Reporting source: which features this reporter stages
The reporter stages a window for a feature only when that feature’s reporting
source is :buffered, which is the default and is every feature in core 0.4.x.
The source is core configuration (config :aurora_meter, feature_sources: %{...},
core 1.0.0); see
metering for the full contract.
| Source | What the quantity is | How it reaches Stripe |
|---|---|---|
:buffered (default) |
the persisted counter in aurora_meter_counters |
this reporter: delta = counter - last_reported, staged per window |
:events |
the sum of the durable events core recorded | at record time, through the export intent core stages inside the record transaction |
A feature has exactly one source. Core refuses a configuration that declares a
feature both as a legacy durable feature and as an events source, and it raises
from AuroraMeter.track/4 and AuroraMeter.reserve/2,3 for an events-source
feature, so a call site that was missed fails loudly instead of producing a
second, separately billable count. Core also keeps a recorded event for a
:buffered feature out of the export path: the event is stored, and its export
intent is marked {:ineligible, :feature_buffered}, because this reporter is
already staging that feature’s counter delta.
Migrating a feature from :buffered to :events
A feature that has never been reported needs nothing: declare it :events
from the start and record it with AuroraMeter.record/4.
A feature you are already billing needs a cutover watermark: a period
boundary recorded for that feature, before which this reporter keeps staging
buffered windows and at or after which that feature’s events become eligible for
export. The two comparisons are exact complements, period_start < w here and
period_start >= w in the outbox, so every period belongs to one path and none
to both.
$ mix aurora_meter_pro.cutover --feature api_calls --tenant org_42 --at 2026-10-01T00:00:00Z
$ mix aurora_meter_pro.cutover --feature api_calls --status # until drained? is true
then deploy config :aurora_meter, feature_sources: %{api_calls: :events}.
The cutover guide is the whole procedure, including what happens in
the window between the watermark passing and your deploy landing, and what a
period_buffered quarantine means when you see one.
Two things this reporter does differently once a watermark exists:
-
it skips a period at or after the watermark, and leaves its
aurora_meter_usage_reportsrow untouched, so a corrected watermark can still stage it. Skipping is reversible; a marker would not be; -
it writes
usage_reports.sourcewhen it stages, usingcoalesce, so a row that already sayseventsis never relabelled. A period is reported one way, once. A null means “buffered, unrecorded”, which is what every row written before Pro schema version 10 is.
Booting with an :events feature that has report history and no cutover row
raises AuroraMeter.Pro.CutoverRequiredError, naming the feature, the row count
and the exact Mix command. That is the backstop, not the plan.
Two workers, and what each one does
Since Pro 1.0.0 reporting is two steps. Register both; a host that adds only the first stages work and never delivers it.
config :my_app, Oban,
queues: [aurora_meter: 5],
plugins: [{Oban.Plugins.Cron, crontab: [
{"*/5 * * * *", AuroraMeter.Pro.UsageReporter},
{"*/5 * * * *", AuroraMeter.Pro.Outbox.Deliverer}
]}]
AuroraMeter.Pro.UsageReporter computes what is owed. It flushes this node,
reads persisted counters, works out delta = counter - last_reported, and
writes one immutable row into aurora_meter_outbox_items together with a
pointer to it on the usage ledger, in one transaction. It makes no network
call at all. Other nodes must flush their own usage. Unfinished with_quota
callbacks and unpersisted gossip are excluded, and core buffering still has its
documented Store or VM crash-loss limitation.
AuroraMeter.Pro.Outbox.Deliverer sends. It claims a bounded batch of due
items, calls the exporter outside any transaction, and applies each answer.
The outbox is the full state machine; this section is what an
operator needs to run it.
Five minutes is the schedule, and it is the only one. An earlier release of the getting-started guide said hourly, which is wrong for the reason the scheduler map sets out: an hourly tick can leave up to an hour of metered usage unstaged when Stripe finalises an invoice, and it is not what keeps two runs from overlapping. That is the job’s uniqueness, for the fifteen minutes its period covers, and after that it is the item’s identity: see the note on the advisory lock below, and a node that dies mid job for why the period is bounded rather than infinite.
Choose an interval and a Stripe invoice finalisation grace period that allow for retries and asynchronous meter aggregation. An hourly schedule can miss a short finalisation window. Reporting late does not guarantee modification of an already finalised invoice. Monitor worker errors, the outbox gauge telemetry and invoice totals.
If you need usage out immediately, run both by hand:
AuroraMeter.Pro.UsageReporter.report_all()
Oban.insert(AuroraMeter.Pro.Outbox.Deliverer.new(%{}))
Upgrading from 0.3.x: stop the old reporter first
Pro schema version 10 converts every in-flight pending_entry into an outbox
item, preserving the identifier Stripe deduplicates on, and deliberately leaves
the pending_* columns populated so a partly upgraded fleet is diagnosable.
That means a node still running the 0.3.x reporter would send the same usage
from pending_entry that the deliverer is sending from the outbox.
Stop every 0.3.x reporter before running the migration, and fix forward rather
than rolling back afterwards. The deliverer enforces the other half at
runtime: while a pending_entry row exists with no matching outbox item, every
run returns {:cancel, :legacy_pending_reports_present} and delivers nothing.
Version 10 also needs config :aurora_meter_pro, stripe_account_id: "acct_..."
when there is anything staged to migrate, because the account is part of an
item’s identity and the 0.3.x database never recorded it. Without it the
migration raises and names the key, which is the intended outcome: an invented
account id would give every migrated item an identity that can never match a
future one.
Completed usage and immutable retries
The staged payload is the financial intent. It holds the exact customer, meter
event name, quantity, timestamp and identifier, and nothing ever updates it:
the schema has no update changeset and the table has a BEFORE UPDATE trigger
that refuses a change to the payload or to any part of the identity. Every
attempt sends the same bytes under the same idempotency key, so a resend after a
lost acknowledgement costs nothing.
Only an acknowledgement advances last_reported, and it does so in the same
transaction as the item’s accepted transition.
| Provider result | What happens |
|---|---|
| Accepted |
The ledger advances and the item is accepted, in one transaction |
| HTTP 429, a rate limit |
pending again after outbox_rate_limit_delay_seconds, same identifier. A rate limit means “not now”, never “never” |
| HTTP 5xx, 409, or an idempotency error |
pending again after an exponential backoff with jitter, same identifier |
| A network error, a timeout, or an answer nobody could parse |
uncertain. It may have landed, so it is neither resent blindly nor written off |
| An HTTP 4xx validation error |
rejected, terminal and visible. No later run claims it |
| The attempt cap, or the idempotency horizon |
uncertain. Never rejected: exhaustion is not evidence that the provider refused anything |
| A mapping, mode or account problem found at claim time |
quarantined with the reason, and only an operator releases it |
A later window for the same feature and period is a different identity, because the window’s bounds are in it, so it neither collides with an unacknowledged item nor waits behind one. An identical restage of a window already in flight collapses into the existing item.
Never mark an uncertain item accepted merely to make the worker succeed: it may have already been accepted, and it may not. Compare its identifier against Stripe’s request logs and meter records first. See Stripe’s idempotency contract.
The 23 hour horizon, and why it is a refusal rather than a retry
An item whose first attempt is older than uncertainty_horizon_seconds
(default 82,800, twenty-three hours) is not resent. It becomes uncertain and
waits for a human or for reconciliation.
The bound is Stripe’s, not ours. Stripe documents that a meter event
identifier is unique “within a rolling period of at least 24 hours”, and that
idempotency keys may be removed once they are “at least 24 hours old”, after
which a reused key starts a new request. So a resend inside 24 hours is
deduplicated and a resend outside it is a second charge. Twenty-three hours
keeps an hour of margin under the documented floor.
The choice is between two costs and it is not symmetrical. Resending past the horizon risks billing a customer twice for usage that already landed, silently, in a way indistinguishable from real usage. Refusing risks leaving that usage unbilled until somebody looks, which is lost revenue that is bounded, visible in the gauge telemetry, and recoverable. Aurora Meter refuses.
Retry-After is not read. stripity_stripe 3.3.2 discards response headers
before an error reaches this package, so a delay Stripe named is not observable;
the delay is computed instead. Stripe’s rate-limit documentation names
Stripe-Rate-Limited-Reason as the header a 429 carries and advises exponential
backoff with jitter, which is what the deliverer does.
Oban uniqueness serialises reporter runs, including manual report_all/0 calls.
There is no advisory lock any more: two overlapping runs are safe because
staging is idempotent on the item’s identity, not because a lock prevents the
second one. All hosts must use the same configured database.
Rollover and cancellation
The reporter records periods it has seen even when their usage is zero. It can settle a late remainder in a previously seen period using a timestamp just before that period’s stored end. It also checks recent cancelled, unpaid and paused subscriptions for completed final-period usage, including cancellation before the first report. Pending payloads remain recoverable after cancellation.
Use AuroraMeter.Pro.Period so usage buckets align with subscription bounds.
Previously unseen historical periods are not inferred: they might contain
pre-subscription usage. The lookback scan is bounded, and Stripe’s separate
35-day timestamp limit still applies. Keep reporting active through rollover.
Current subscription and meter configuration select new reports. Settle usage before changing a tenant’s customer, plan, meter mapping or billing-period anchor; the reporter does not maintain a full history of those configurations. An immutable pending payload retains its original target. A host must reconcile usage and invoice effects when changing pricing or immediately cancelling an already invoiced subscription.
Reconciliation and monitoring
AuroraMeter.Pro.Reconcile.run()
# [] or acknowledgement-ledger values exceeding persisted counters
AuroraMeter.Pro.Reconcile.run/1 is the local check, and it is cheap: it
flags acknowledgement-ledger values that exceed the persisted counter they
reported, which is a physically impossible state. It does not flag ordinary
unreported usage, does not query Stripe, does not prove a paid invoice and does
not detect asynchronous rejection. It returns a map; the drifts are under
:local_drift.
Everything it cannot see is AuroraMeter.Pro.Outbox.Reconciler‘s, which asks
Stripe. Add it to your crontab, read what it finds with
AuroraMeter.Pro.Reconcile.list_items/1, and close a finding with
resolve_item/3. The reconciliation guide is what each
kind means and what the whole thing can and cannot prove.
What to put a page on, and the health check to run it from, are sections 5 and 6 of Operations rather than here: one list of signals, in one place, is the only kind that stays true.
Stripe rejects meter events asynchronously, and it tells you through
v1.billing.meter.error_report_triggered. That is a thin event on Stripe’s
v2 event stream, so it reaches you only if you create an event destination with
the Thin payload style and subscribe to it; a classic webhook endpoint never
receives one. Pro handles it when it arrives, records the sanitised body on the
event row, and opens findings from it on the reconciler’s next run. Stripe
documents the destination and the error codes in
recording usage.
For an old uncertain stage, preserve its payload and compare the identifier, customer, meter, timestamp and quantity with Stripe’s request logs and meter records. Establish whether it was accepted before choosing acknowledgement or resubmission. Do not infer rejection from an invoice that has not yet aggregated.
Plan versions and what a period is billed under
A usage_reports row is stamped with the {plan_id, plan_version} effective for
its period when the row is created, and never updated afterwards. A report
for a closed period therefore reports against the plan version that period was
sold under, not against whatever the plan definition says on the day the
report is staged.
That matters in three places.
A plan change mid-period does not restate the closed part of it. The row keeps the pair it was created with, so the first half of the period is still billed at the first half’s contract.
A version that stopped metering a feature stops it for its own periods only. If version 2 of a plan turns a metered feature into a hard limit, a period sold under version 1 is still staged and a period sold under version 2 is not.
A tenant on version 1 is metered under version 1. The reporter resolves the tenant’s own version rather than the one effective today, so shipping version 2 changes nothing for the customers who are not on it.
The meter is shared, the price is not
stripe_meters is keyed by feature alone and deliberately not by version:
config :aurora_meter_pro,
stripe_meters: %{ai_generations: "ai_generations"},
stripe_metered_prices: %{
{:scale, "1"} => ["price_v1_overage"],
{:scale, "2"} => ["price_v2_overage"]
}
A meter is a measurement channel. Both versions measure the same thing and Stripe bills it at different rates through the two metered prices attached to the two subscriptions. Keying the meter by version would create a second channel measuring the same events, and the two would have to be summed to answer “how much did this tenant use”.
Rows written before Pro schema version 11
They have no plan pair, and there is nothing honest to fill them with:
inventing a version for a period whose history predates the transitions table
would say a closed period was sold under a contract nobody recorded. Such a row
falls back to the subscription’s current pair and is counted, through
[:aurora_meter, :pro, :usage_report, :plan_fallback] with the tenant, the
feature and the period. The count goes to zero on its own as those periods age
out, and the gauge is how you see that it has.
Testing
Automated package tests use AuroraMeter.Pro.Stripe.Fake as the core billing
provider; the separate credits_stripe_client fake is for prepaid payments.
The reporting tests cover repeated calls, accepted-then-timeout retries,
new usage during a retry, cancellation, rollover and unfinished quota work.
For an end-to-end pricing test, create an isolated Stripe test-clock customer, track a known number of units, report repeatedly, advance through invoice finalization, then assert quantity, included allowance, currency and paid amount. The release audit includes both a renewal invoice and a final cancellation invoice: five units with two included and three at 25 cents yielded 75 cents.
See Subscriptions and Operations.