Your app counted 1,240 generations. The plan includes 1,000. Somebody owes you $4.80 and Stripe has never heard of any of it.
This guide closes that gap. It is the shortest amount of code in the whole product and the part with the most ways to go subtly wrong, so most of this page is about failure rather than about setup.
#What is actually happening
your app counts -> Postgres counter row
|
| the reporter, every few minutes:
| "how much since I last told Stripe?"
v
Stripe Billing Meter
|
| your graduated price decides the money
v
the invoice
Three things have to agree for that to produce the right number:
-
Your plan’s
included:and Stripe’s free tier. -
Your plan’s
unit_price:and Stripe’s per-unit price. - Your period boundaries and Stripe’s billing cycle.
The plan module drives your dashboard’s estimate. Stripe decides the actual charge. If those two disagree, your screens lie and the invoice is still right.
#1. Build it in Stripe first
For a plan that reads:
plan :writer do
price 2_900
metered :generations, included: 1_000, unit_price: 2
end
in the Stripe dashboard, in test mode:
-
Create a Billing Meter named
generations. Set its aggregation to sum, its customer mapping key tostripe_customer_id, and its value key tovalue. - Create a recurring Price attached to that meter, on the same product as the flat Writer price.
- Give that price a graduated tier: the first 1,000 units at $0, then $0.02 per unit above.
Step 3 is the one people skip, and skipping it means overage is counted for ever and never charged.
#2. Configure
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 a feature atom to a Stripe meter event name, not to a
price id. The two are easy to confuse and the error is silent.
period_source: AuroraMeter.Pro.Period matters more here than anywhere else. It
makes your usage buckets line up with the subscription’s own billing bounds, so
the usage the reporter sends and the period Stripe invoices are the same window.
#3. Schedule the worker
config :my_app, Oban,
queues: [aurora_meter: 5],
plugins: [{Oban.Plugins.Cron, crontab: [
{"*/5 * * * *", AuroraMeter.Pro.UsageReporter}
]}]
Report more often than you think you need to. Choose an interval that leaves room for retries and for Stripe’s own asynchronous aggregation before the invoice is finalised. An hourly schedule can miss a short finalisation window, and reporting after an invoice is finalised does not change it.
Every five minutes is a reasonable default for a monthly subscription.
#4. What one run does
Each run:
- Flushes this node’s pending counts to Postgres.
- Reads the persisted counters.
- Works out the difference from what it last successfully reported.
- Sends that difference to the meter.
- Advances its record only after Stripe acknowledges.
Two consequences follow from that list.
Only completed, persisted work is reported. A with_quota/4 callback that
is still running is not included, and neither is a count that has not been
flushed yet. That is what you want: you should not bill for work that has not
finished.
Other nodes flush their own usage. Each node writes its own deltas. The reporter reads the shared total in Postgres, so it does not need to reach across the cluster, but a node that has crashed without flushing has taken its last few seconds of counts with it.
#5. The hard part: a send that neither succeeded nor failed
The request times out. You do not know whether Stripe recorded it.
Send again and you might double bill. Do not send and you might never bill. This is the only genuinely hard problem in usage reporting, and it is worth knowing exactly what happens.
Before sending, Pro writes the exact payload it is about to send (customer, meter event name, quantity, timestamp and a unique identifier) into a pending row. That identifier is used as the idempotency key for both the meter event and the HTTP request. Then:
| What Stripe said | What happens next |
|---|---|
| Accepted | The record advances and the pending row is cleared |
| A definite rejection, including rate limiting | The rejected attempt is cleared; a later attempt gets a fresh identifier |
| Timeout, network error, server error, idempotency error | The exact same payload is retained and retried |
| Still uncertain after 23 hours |
It stops and asks for a human: :usage_reconciliation_required |
Two rules fall out of that:
New usage waits behind an uncertain attempt for that customer, feature and period. A retry never grows the old payload to include work that finished in the meantime, because that would turn one uncertain charge into a different uncertain charge.
The 23 hour cutoff is not arbitrary. It sits inside Stripe’s minimum 24 hour idempotency window, so a retry within it is guaranteed to be recognised as the same request rather than treated as a new one.
Never clear an uncertain attempt just to make the worker go green. It may already have been accepted. Compare the stored identifier, customer, meter, timestamp and quantity against Stripe’s request logs and meter records, decide whether it landed, and then resolve it.
#6. Rollover, cancellation and changing your prices
The reporter records the periods it has seen, even ones with no usage, so it can settle a late remainder into a period that has already closed by stamping the event just before that period’s stored end.
It also checks recently cancelled, unpaid and paused subscriptions for final-period usage, including a customer who cancelled before the first report ever ran. Pending payloads stay recoverable after cancellation.
Periods it has never seen are deliberately not inferred, because they might contain usage from before the customer ever subscribed. Stripe also refuses event timestamps older than 35 days, which is a second reason not to try.
The practical rule:
Settle usage before you change anything it depends on. Changing a customer’s Stripe customer, plan, meter mapping or billing anchor while usage is unreported will strand it. The reporter keeps the current configuration, not a history of every configuration you have had.
#7. What to watch
AuroraMeter.Pro.Reconcile.run()
# [] or the customers whose acknowledged totals exceed their persisted counters
Be precise about what that does and does not tell you. It flags local acknowledgement records that have run ahead of the persisted counters. It does not flag ordinary unreported usage, query Stripe, prove an invoice was paid, or detect a meter event Stripe rejected asynchronously.
So alarm on four things:
-
anything
Reconcile.run/0returns; -
failed or discarded
UsageReporterjobs; - pending rows older than a few hours;
- unreported usage, which you measure yourself by comparing counters against the reporter’s records.
Stripe can also reject meter events after accepting the request. Subscribe to its meter error events and match them back by request identifier. The Pro webhook handles subscription and payment events, not Stripe’s separate thin event API, so that piece is yours.
#8. Testing it
The package’s own tests cover repeated calls, an acceptance followed by a
timeout, new usage arriving during a retry, cancellation, rollover and
unfinished quota work. They use AuroraMeter.Pro.Stripe.Fake as the provider.
For your own suite, the test worth writing is an end-to-end price check against a Stripe test clock:
- Create an isolated test-clock customer and subscribe them.
- Track a known number of units.
- Run the reporter, twice, to prove the second run sends nothing.
- Advance the clock through invoice finalisation.
- Assert the quantity, the included allowance, the currency and the amount paid.
The release audit for this package did exactly that: five units, two included, three at 25 cents, which produced a 75 cent line. Confirm your own numbers the same way before you point real cards at it.
A successful API response is not a correct invoice. The only proof that a pricing configuration works is an actual finalised invoice with the right total on it.
#The short version
- Build the meter and the graduated price in Stripe first.
-
Make
included:in your plan and the $0 tier in Stripe the same number. - Schedule the reporter every few minutes, not hourly.
- Alarm on reconciliation drift, failed jobs and old pending rows.
- Settle usage before changing a customer’s plan, meter or billing anchor.
- Prove it with a test-clock invoice, not with a 200 response.
#What to read next
- Take the money: Stripe subscriptions if you have not wired checkout and the webhook yet.
- Ship it: the production checklist for the rest of the alarms.
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.