We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
From the blog
Stripe Metered Billing in Elixir: Report Usage to Stripe Billing Meters
By Liam Killingback ·
Stripe Metered Billing in Elixir: Report Usage to Stripe Billing Meters
You have an Elixir app that counts something billable. API calls, AI tokens, rendered documents, minutes of transcription, rows synced. Counting is the easy part. The hard part is turning those counts into an invoice that is correct, that survives a retry storm, and that you can defend when a customer emails asking why they were charged for 41,206 units instead of 41,180.
This guide covers Stripe metered billing in Elixir end to end: creating a Billing Meter and a metered price, building a small reporting client with Req, sending meter events idempotently from an Oban worker, scaling to the high-throughput meter event stream, and reconciling your numbers against Stripe’s so the two never quietly drift apart.
It assumes you already have usage counted somewhere. If you do not, start with Phoenix usage metering in real time with ETS, which builds the hot-path counter this article reports from, or read the wider guide to usage-based billing in Elixir and Phoenix for how metering, entitlements and billing fit together.
What changed: usage records are out, Billing Meters are in
If you last looked at metered billing a few years ago, the model you remember was usage records: you created a subscription item with a metered price, then pushed usage_record objects at that subscription item ID. It worked, but it had two properties that hurt in practice. Your reporting code had to know the subscription item ID for every customer, and a customer with three metered dimensions meant juggling three item IDs.
Stripe’s current model is the Billing Meter. You define a meter once at the account level, and then you send meter events keyed by customer ID and a meter’s event name. Stripe does the aggregation and applies it to whatever metered price is attached to that meter. Your app no longer needs to track subscription item IDs at all, which removes an entire class of bug where a plan change silently orphans your reporting.
The mental model is worth stating plainly:
-
A meter defines a billable dimension, for example
api_calls, and how to aggregate raw events (sum,count, orlast). - A meter event is one datapoint: this customer, this meter, this value, at this time.
- A metered price is attached to a meter and turns aggregated usage into money at invoice time.
Your job in Elixir is only the middle one. Get the events in, correctly and exactly once, and Stripe handles the rest.
The architecture: count locally, report in batches
The single most common mistake in a metered billing integration is calling Stripe on the request path. A billable action happens, so the code POSTs a meter event before returning a response. It works beautifully until the day Stripe is slow, and then your API latency is Stripe’s latency, and your users feel every network hiccup in a payment provider they do not even know you use.
Do this instead:
- Count in memory on the hot path (ETS counters, microseconds, no network).
- Flush deltas to Postgres on a short interval so a node restart cannot lose much.
- Report to Stripe from a background job on a slower interval, with retries.
That is three layers, and each one exists for a reason. ETS absorbs traffic spikes. Postgres is your durable source of truth and the thing you show customers in a usage dashboard. Stripe is the billing ledger, and it is the layer you want the loosest coupling to.
request ──▶ ETS counter ──(every 5s)──▶ Postgres deltas ──(hourly Oban job)──▶ Stripe meter events
Reporting hourly, not per event, also means an outage at Stripe costs you nothing. The next job run sends the same accumulated number. Nothing is lost, because Postgres, not Stripe, is your source of truth.
Step 1: create the meter and a metered price
You only do this once per billable dimension, so a Mix task or a curl is fine. Here is the meter, created with Req so you can keep it in the repo as a setup script:
defmodule Billing.StripeSetup do
@base "https://api.stripe.com/v1"
def create_meter(display_name, event_name, formula \\ "sum") do
Req.post!("#{@base}/billing/meters",
auth: {:bearer, secret_key()},
form: [
display_name: display_name,
event_name: event_name,
"default_aggregation[formula]": formula,
"customer_mapping[type]": "by_id",
"customer_mapping[event_payload_key]": "stripe_customer_id",
"value_settings[event_payload_key]": "value"
]
)
end
defp secret_key, do: Application.fetch_env!(:my_app, :stripe_secret_key)
end
Two fields deserve attention.
default_aggregation[formula] decides how Stripe collapses your events over the billing period. Use sum when each event carries a quantity, which is what you want if you report deltas (“this hour the customer used 412 calls”). Use count when each event represents exactly one unit and the value is irrelevant. Use last when you report a cumulative running total each time and want the most recent value to win.
Getting this wrong is the classic metered billing bug. If you report cumulative totals into a sum meter, every report adds the whole running total again and the customer is billed for a number that grows quadratically. Pick sum plus deltas, or last plus cumulative totals, and never mix the two.
customer_mapping tells Stripe how to find the customer from your event payload. With by_id and the payload key stripe_customer_id, every event you send just needs that field, and Stripe resolves the rest.
Then attach a price to the meter:
def create_metered_price(product_id, meter_id, unit_amount_cents) do
Req.post!("https://api.stripe.com/v1/prices",
auth: {:bearer, secret_key()},
form: [
product: product_id,
currency: "usd",
"recurring[interval]": "month",
"recurring[usage_type]": "metered",
"recurring[meter]": meter_id,
billing_scheme: "per_unit",
unit_amount_decimal: unit_amount_cents
]
)
end
Use unit_amount_decimal rather than unit_amount for metered pricing. Usage-based prices are usually fractions of a cent per unit, and unit_amount only takes whole cents. Charging $0.002 per API call is expressed as "0.2" in unit_amount_decimal.
Step 2: a small Stripe meter client with Req
stripity_stripe is a good library and worth using for subscriptions, customers and webhooks. Meter endpoints are newer, so depending on the version you are pinned to they may not have first-class functions yet. Either drop to the library’s generic request builder or, as here, talk to the endpoint directly with Req. It is a single POST and there is no real benefit to wrapping it.
defmodule Billing.Stripe.Meter do
@moduledoc "Reports usage to Stripe Billing Meters."
require Logger
@endpoint "https://api.stripe.com/v1/billing/meter_events"
@doc """
Report a usage delta for one customer and one meter.
`identifier` must be deterministic for the (customer, meter, period)
tuple so retries cannot double-bill.
"""
def report(stripe_customer_id, event_name, value, identifier, timestamp \\ nil) do
form =
[
event_name: event_name,
identifier: identifier,
"payload[stripe_customer_id]": stripe_customer_id,
"payload[value]": to_string(value)
]
|> maybe_put_timestamp(timestamp)
case Req.post(@endpoint, auth: {:bearer, secret_key()}, form: form, retry: :transient) do
{:ok, %{status: 200, body: body}} ->
{:ok, body}
{:ok, %{status: status, body: body}} ->
Logger.error("stripe meter event rejected: #{status} #{inspect(body)}")
{:error, {:stripe, status, body}}
{:error, reason} ->
{:error, reason}
end
end
defp maybe_put_timestamp(form, nil), do: form
defp maybe_put_timestamp(form, %DateTime{} = ts),
do: Keyword.put(form, :timestamp, DateTime.to_unix(ts))
defp secret_key, do: Application.fetch_env!(:my_app, :stripe_secret_key)
end
Notice retry: :transient. Req will retry connection errors and 5xx responses with backoff by default, which handles the common case of a blip without you writing anything. It will not retry a 400, which is correct: a malformed event will be malformed on the second attempt too.
The timestamp field is optional and defaults to the time Stripe receives the event. Send it explicitly when you are reporting for a window that has already closed, so the usage lands in the right billing period rather than the one you happen to be running the job in. Stripe restricts how far in the past or the future a timestamp may be, so treat a very old backfill as a special case rather than assuming you can replay a year of history through this endpoint.
Step 3: report from an Oban worker
Now the piece that actually runs on a schedule. The worker reads the closed window’s totals out of Postgres and reports one event per customer per meter.
defmodule Billing.Workers.ReportUsage do
use Oban.Worker,
queue: :billing,
max_attempts: 10,
unique: [period: 3600, fields: [:worker, :args]]
alias Billing.Stripe.Meter
@meters ["api_calls", "ai_tokens", "documents_processed"]
@impl Oban.Worker
def perform(%Oban.Job{args: %{"window_start" => window_start}}) do
{:ok, window_start, _} = DateTime.from_iso8601(window_start)
window_end = DateTime.add(window_start, 3600, :second)
Billing.Usage.deltas_in_window(window_start, window_end)
|> Enum.filter(&(&1.meter in @meters and &1.value > 0))
|> Enum.reduce_while(:ok, fn delta, _acc ->
identifier = identifier_for(delta, window_start)
case Meter.report(
delta.stripe_customer_id,
delta.meter,
delta.value,
identifier,
window_end
) do
{:ok, _} -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
defp identifier_for(delta, window_start) do
"#{delta.account_id}:#{delta.meter}:#{DateTime.to_unix(window_start)}"
end
end
Three details make this safe rather than merely functional.
The identifier is deterministic. It is built from the account, the meter and the window start, so the same window always produces the same identifier. Stripe treats a repeated identifier as a duplicate and ignores it, which turns “did that job already run?” from a billing incident into a non-question. This is the single most important line in the whole integration. Never use a random UUID or a timestamp from DateTime.utc_now() here, because a retry would generate a different one and Stripe would happily record the usage twice.
reduce_while halts on the first failure. Combined with max_attempts: 10, a partial failure means the whole window is retried, and the deterministic identifiers make the already-sent events no-ops. Idempotency is what lets you use the blunt “retry everything” strategy without fear.
Oban uniqueness stops overlap. If a run is slow and the next one fires, the second job is discarded rather than racing the first.
Schedule it with Oban Cron:
config :my_app, Oban,
repo: MyApp.Repo,
queues: [billing: 5],
plugins: [
{Oban.Plugins.Cron,
crontab: [
{"5 * * * *", Billing.Workers.EnqueueUsageReports}
]}
]
Run at five past the hour, not on the hour, and report the window that has just closed. That small offset gives your ETS flush time to land in Postgres, so you are never reporting a window that is still being written to.
Step 4: high throughput with the meter event stream
The /v1/billing/meter_events endpoint is rate limited like the rest of the standard API. If you are reporting aggregated hourly deltas, one event per customer per meter per hour is nothing and you will never come close to a limit. If you genuinely need to stream raw events at volume, Stripe provides a separate high-throughput path built for exactly that.
It works in two steps. You create a short-lived meter event session, which returns an authentication token, and then you POST batches of events to the stream endpoint using that token instead of your secret key. The token expires quickly, so cache it in a GenServer and refresh it when it goes stale. That shape maps neatly onto Elixir:
defmodule Billing.Stripe.StreamToken do
@moduledoc "Caches a short-lived Stripe meter event session token."
use GenServer
@refresh_margin_seconds 60
def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
def token, do: GenServer.call(__MODULE__, :token)
@impl true
def init(_), do: {:ok, %{token: nil, expires_at: nil}}
@impl true
def handle_call(:token, _from, state) do
state = if fresh?(state), do: state, else: refresh()
{:reply, state.token, state}
end
defp fresh?(%{token: nil}), do: false
defp fresh?(%{expires_at: expires_at}) do
DateTime.diff(expires_at, DateTime.utc_now()) > @refresh_margin_seconds
end
defp refresh do
%{body: body} =
Req.post!("https://api.stripe.com/v2/billing/meter_event_session",
auth: {:bearer, Application.fetch_env!(:my_app, :stripe_secret_key)},
json: %{}
)
{:ok, expires_at, _} = DateTime.from_iso8601(body["expires_at"])
%{token: body["authentication_token"], expires_at: expires_at}
end
end
Be honest with yourself about whether you need this. For most SaaS products, hourly aggregation through the standard endpoint is simpler, cheaper and easier to reason about, and it keeps a single retryable job as the only thing that talks to Stripe. Reach for the stream when your billable unit is genuinely per-event and the events number in the millions, for example per-token AI billing where you want Stripe itself to hold the raw series. Check the current batch size and token lifetime in Stripe’s docs before you build against them, since those numbers are the sort of thing that changes.
Step 5: reconcile, because drift is inevitable
Your Postgres totals and Stripe’s meter totals will disagree at some point. A job died in a way retries did not fix, a timestamp landed on the wrong side of a period boundary, someone ran a backfill twice. The teams who find this out from a customer are the ones who never checked.
Stripe exposes the aggregated values back to you, so a daily reconciliation job is straightforward: read your total for the period, read Stripe’s, and alert on a difference beyond a small tolerance.
defmodule Billing.Workers.Reconcile do
use Oban.Worker, queue: :billing, max_attempts: 3
@tolerance 0.001
@impl Oban.Worker
def perform(%Oban.Job{args: %{"account_id" => account_id, "meter" => meter}}) do
account = Billing.get_account!(account_id)
{period_start, period_end} = Billing.current_period(account)
ours = Billing.Usage.total_for_period(account_id, meter, period_start, period_end)
theirs = Billing.Stripe.Meter.summary(account.stripe_customer_id, meter, period_start, period_end)
if drift?(ours, theirs) do
Billing.Alerts.usage_drift(account, meter, ours, theirs)
end
:ok
end
defp drift?(ours, theirs) when ours == 0 and theirs == 0, do: false
defp drift?(ours, theirs), do: abs(ours - theirs) / max(ours, 1) > @tolerance
end
Meter.summary/4 here is a thin wrapper over Stripe’s meter event summaries endpoint, built the same way as the report/5 function above: a GET with the customer, the meter ID and the period bounds. Run the job nightly per account and again just before invoices finalize. Alert to Slack, not to a log file nobody reads. The value here is not the code, it is knowing about a billing discrepancy on day two of the cycle rather than after the charge lands.
Testing without charging anyone
Metered billing is one of the areas where a real integration test earns its keep, because the failure mode is money.
Use test-mode keys and a fake in unit tests. Define a behaviour for the reporting client and swap the implementation with Mox. Assert on the exact arguments, especially the identifier, because that string is your idempotency guarantee:
test "reports one deterministic event per meter for the closed window" do
expect(Billing.Stripe.MeterMock, :report, fn "cus_123", "api_calls", 412, id, _ts ->
assert id == "acct_7:api_calls:1751328000"
{:ok, %{}}
end)
assert :ok = perform_job(Billing.Workers.ReportUsage, %{"window_start" => @window_start})
end
Then prove idempotency for real, once, in test mode. Run the worker twice against Stripe’s test environment with the same window and check the meter summary is unchanged. That single manual test validates the assumption the entire design rests on, and it takes ten minutes.
Use Stripe’s test clocks for the full cycle. Attach a customer to a test clock, report usage, advance the clock past the period end, and inspect the resulting invoice. This is the only way to see the whole path from an event to a line item without waiting a month.
The edge cases that bite
A few things that are obvious in retrospect and expensive in the moment:
- Mid-cycle plan changes. Meters are account-level, so a plan switch does not orphan your reporting the way subscription item IDs used to. Your entitlement checks still need to change immediately, but your usage reporting keeps working untouched. That is the main practical win of the meter model.
- Cancelled subscriptions. Usage reported after a subscription ends has nowhere to land. Skip customers with no active metered subscription in the enqueue step rather than discovering it as a stream of 400s.
- Credits and refunds. Do not report negative usage to unwind a mistake. Issue a credit note or an invoice item adjustment in Stripe instead, and keep the meter as an honest record of what happened.
- Zero is not nothing. Filter out zero-value deltas before reporting. They cost an API call and communicate nothing.
- Clock skew and window boundaries. Always send an explicit timestamp for closed windows. Relying on Stripe’s receive time means an hour-late retry can push usage into the next billing period.
Skip the plumbing
Everything above is maybe 400 lines of code you now own: the ETS counters, the flush, the reporting worker, the identifier scheme, the reconciliation job, the tests. It is not hard code, but it is code where a subtle bug means overbilling a customer, and it is the same code in every usage-based product.
That is precisely why we built it once. Aurora Meter meters usage on an ETS hot path, persists deltas to Postgres, and reports them to Stripe Billing Meters with deterministic identifiers and reconciliation already wired in. The core is free and MIT licensed at github.com/liamkillingback/aurora-meter, and Aurora Meter Pro adds the Stripe reporting pipeline, entitlements and the usage dashboard components on top.
If you also need the layers underneath billing, subscriptions, auth, transactional email and background jobs, phx_saas ships them wired together so metered pricing is a feature you add rather than a project you start. And the Builder Pass gets you every Phoenix template we make, for a one-time price, which is usually the right call if you expect to ship more than one thing.
Summary
Stripe metered billing in Elixir comes down to five decisions, and the code follows from them:
- Use Billing Meters, not usage records. Account-level meters keyed by customer ID remove subscription item bookkeeping entirely.
-
Match your aggregation to your reporting style.
sumwith deltas, orlastwith cumulative totals. Mixing them is the bug that produces impossible invoices. - Never call Stripe from the request path. Count in ETS, persist to Postgres, report from Oban.
-
Make identifiers deterministic. A stable
account:meter:windowidentifier makes retries free and idempotency structural rather than hopeful. - Reconcile on a schedule. Compare your totals to Stripe’s nightly, and alert on drift before the invoice does.
Get those right and metered billing becomes boring, which for anything that moves money is the highest compliment available.