A pre-aggregated total of the usage reported to one billing meter, for one customer, over one window of time.
Wire object billing.meter_event_summary, ids prefixed mtrusg_, served from a
single parent-scoped path: GET /v1/billing/meters/:meter_id/event_summaries.
That is the only path Stripe serves for this object — there is no top-level
/v1/billing/meter_event_summaries collection and no GET /{summary_id} — which
is why the meter id is a positional argument to list/4 and stream!/4 rather
than a filter, and why this module ships no retrieve function.
Two ways to read a confidently wrong number
The returned object never says which customer it belongs to. It has exactly seven fields, not one of them names a customer, and it cannot be expanded to add one. The customer is an input to the query and never an output. So a reconciler that lists summaries for several customers and merges the results has lost the attribution completely — and the warning sign is easy to miss, because grouping the merged list by a customer field is not something you can even attempt: the field does not exist. Keep the association out of band, alongside the customer id you filtered on.
The figure is not live. Stripe's own specification states that these summaries are eventually consistent. The object carries no freshness field and Stripe publishes no staleness SLA, so a caller has no way to tell how old a number is. Label the figure in your UI with the time you fetched it, never present it as real-time, and never treat it as a billing source of truth — Stripe bills from the meter, not from these summaries.
The window boundary is ambiguous
Stripe's specification contradicts itself about whether the end of the window is
included. The end_time query parameter and the end_time field on the returned
object are both documented as exclusive, while aggregated_value's own
description — on that same object — says the aggregation covers start_time through
end_time inclusive. Two of the three say exclusive.
All three descriptions ship verbatim into every SDK's generated documentation, so the same contradiction is waiting in Stripe's other libraries. This library asserts neither reading and does not adjust your window to compensate for either. If one boundary event would change a decision you are making, do not settle it by reading documentation — measure it against your own account.
Listing
customer, start_time and end_time are all required filters. Stripe answers a
call missing any of them with an HTTP 400; list/4 and stream!/4 raise
ArgumentError before the request leaves the process instead.
Stripe's limit ranges from 1 to 100 and defaults to 10 — and that default is the
trap. Ask for hourly buckets across a 31-day month and the window holds 744 of
them. A caller who sums the ten rows that come back, without checking whether more
exist, gets a plausible-looking number that is about one and a third percent of the
truth. (Illustrative, assuming usage spread evenly across the buckets; the real
fraction depends on your data.)
The fix is usually not to paginate harder — it is to ask the question you actually have.
For a total, omit value_grouping_window. Stripe aggregates server-side and
returns a single bucket, in one request, with no pagination and no client-side float
summation. This is what an admin screen showing "usage this period" almost always
wants:
params = %{
"customer" => "cus_123",
"start_time" => 1_753_660_800,
"end_time" => 1_753_747_200
}
resp = LatticeStripe.Billing.MeterEventSummary.list!(client, "mtr_123", params)
[summary] = resp.data.data
summary.aggregated_valueFor a series, pass value_grouping_window as "hour" or "day" and stream it, so
that has_more is followed for you rather than silently ignored:
buckets = Map.put(params, "value_grouping_window", "hour")
client
|> LatticeStripe.Billing.MeterEventSummary.stream!("mtr_123", buckets)
|> Enum.map(&{&1.start_time, &1.aggregated_value})The difference compounds across a customer base: rendering one usage figure for 200
customers costs roughly 15,000 requests at the default page size, 1,600 at
limit=100, and 200 with no window at all. See LatticeStripe.List for the memory
guidance that applies to any stream you do not bound with Stream.take/2.
Timestamp alignment
Stripe requires start_time and end_time to be aligned to minute boundaries on
every query, to UTC hour boundaries when value_grouping_window is "hour", and
to UTC day boundaries (00:00 UTC) when it is "day". The timezone is UTC — not
the account's, not the customer's.
The most natural inputs are the ones that violate this. A subscription's
current_period_start and current_period_end derive from its billing cycle anchor,
so they land on an arbitrary second and are almost never aligned to anything.
list/4 and stream!/4 raise ArgumentError on a misaligned timestamp before the
request leaves the process, naming the offending value and the boundary it missed.
Stripe would answer with an HTTP 400 whose error code it does not document, so the
failure can be prevented but not improved after the fact.
This library will not align them for you. Rounding changes what the query means: floor the start and you sweep in usage from before the period; ceil it and you drop usage that belongs to it. That is a business decision, not a formatting detail, and a library that makes it silently produces a wrong number its caller never sees. Do the arithmetic yourself, where you can see it:
start_time = Integer.floor_div(start_time, 86_400) * 86_400
end_time = -Integer.floor_div(-end_time, 86_400) * 86_400Integer.floor_div/2 rather than div/2 — div/2 truncates toward zero, which
rounds the wrong way for negative inputs.
Design
Three things this module deliberately does not have:
- No retrieve function. Stripe serves no get-by-summary-id route, so there is no operation to wrap. This is an absence in the API, not a gap to apologise for.
- No window-aligning helper. Rejected on domain grounds: any snap has to choose floor or ceil, that choice changes which usage the window covers, and adjacent metering platforms that do auto-align demonstrate the cost — one of them turns a three-day range into four windows without saying so.
- No convenience accessor on
LatticeStripe.Billing.Meter. Parent-scoping is expressed in this module's signatures, not by a delegator on the parent. Stripe's own Java SDK documentation once advertised exactly such a method; it never existed, and Stripe's answer was that the documentation was wrong.
Note the read/write type asymmetry: aggregated_value comes back as a float,
while LatticeStripe.Billing.MeterEvent writes take the value as a decimal
string. See guides/metering.md for the payload contract.
See the Stripe Meter Event Summary API.
Summary
Types
Functions
Decode a Stripe-shaped string-keyed map into a %MeterEventSummary{}.
aggregated_value arrives as a JSON number and stays a float — it is never
rounded and never coerced to an integer. Note the asymmetry with the write path:
reads return a float, while LatticeStripe.Billing.MeterEvent writes take the
value as a decimal string.
Idempotent: applied to an already-decoded struct it returns it unchanged, and
from_map(nil) returns nil. Unknown top-level keys land in :extra.
@spec list(LatticeStripe.Client.t(), String.t(), map(), keyword()) :: {:ok, LatticeStripe.Response.t()} | {:error, LatticeStripe.Error.t()}
List one page of usage summaries for a meter.
Sends GET /v1/billing/meters/:meter_id/event_summaries.
params must carry "customer", "start_time" and "end_time" — Stripe
marks all three required. Each guard raises ArgumentError before any network
call, in that order, and so does an empty or nil meter_id.
Those guards check key presence, not value emptiness, and read string keys
only (Stripe wire format): a "customer" key whose value is "" passes the
guard and fails at Stripe instead, and an atom-keyed params map bypasses the
guards entirely.
Timestamps are Unix seconds. Stripe requires them aligned to minute boundaries
always, to UTC hour boundaries when value_grouping_window is "hour", and to
UTC day boundaries (00:00 UTC) when it is "day". A misaligned value raises
ArgumentError here too, printing the arithmetic rather than applying it — see
the module docs for why the choice of floor versus ceil is yours.
Also supports Stripe's limit (default 10, max 100) and starting_after /
ending_before cursors. A 31-day hourly window is 744 buckets, so a bare
list/4 over one silently returns the first ten.
@spec list!(LatticeStripe.Client.t(), String.t(), map(), keyword()) :: LatticeStripe.Response.t()
Like list/4 but raises on failure.
@spec stream!(LatticeStripe.Client.t(), String.t(), map(), keyword()) :: Enumerable.t()
Returns a lazy stream of every usage summary in the window (auto-pagination).
Emits individual %MeterEventSummary{} structs, following has_more and fetching
each subsequent page as the stream is consumed. Raises LatticeStripe.Error if any
page fetch fails, so a partial enumeration surfaces as an error rather than as a
short — and silently wrong — series.
This is the correct entry point for a bucketed series. Stripe's limit defaults to
10 while a 31-day hourly window is 744 buckets, so a bare list/4 over one
returns the first ten and no indication that a sum over them is a fraction of the
truth. For a single total, omit value_grouping_window and use list/4 — one
server-aggregated bucket, one request, no pagination.
The same guards list/4 applies fire here, and they raise at call time rather
than at the first Enum step, so the failure lands at the call site.
Consume it with Enum.to_list/1 when you intend to hold every bucket in memory, or
bound it with Stream.take/2 when you do not — see LatticeStripe.List for the
memory guidance:
client
|> LatticeStripe.Billing.MeterEventSummary.stream!(meter_id, params)
|> Enum.map(& &1.aggregated_value)There is no non-bang stream/4 twin — a lazy stream cannot return an error tuple at
construction time for a failure that happens pages later.