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_reports row untouched, so a corrected watermark can still stage it. Skipping is reversible; a marker would not be;
  • it writes usage_reports.source when it stages, using coalesce, so a row that already says events is 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.