Skip to content

Diagnostic Stripe

metered subscription items with no usage reported

The invoice goes out with the usage line at zero. The customer has been hammering the API all month and your own dashboards say so, but Stripe has no record of a single unit. The subscription is active, the price is metered, nothing has errored anywhere, and the invoice has already finalized, which is the point past which usage cannot be added to it.

Read-only key Python and Node.js Tests included
Man in black jacket walking on sidewalk during daytime
Photo by Roma Kaiuk🇺🇦 on Unsplash
The short answer

For every active subscription item whose price.recurring.usage_type is metered, read the meter it points at and ask what that meter aggregated for that customer this period: GET /v1/billing/meters/{meter}/event_summaries?customer={cus}&start_time=...&end_time=....

No rows at all means the events never matched the meter's event_name or its customer_mapping.event_payload_key, so Stripe dropped them on arrival. Rows that all aggregate to 0 mean the events landed but the value key did not match value_settings.event_payload_key. The two look identical on the invoice and have different repairs.

The problem in plain words

The way this is normally found is a customer asking why their bill is so low, which is a conversation nobody wants to have in either direction. Everything upstream of the meter looks healthy: the emitter is running, its logs show events being posted, the HTTP responses are 2xx, the subscription is active and renewing. The gap is entirely inside Stripe, between accepting an event and counting it.

What makes it expensive rather than merely wrong is the finalization boundary. Meter events belong to a period; once that period's invoice finalizes, backfilling into it is not possible. Every cycle that passes undetected is a cycle of revenue that has to be recovered as a separate charge and explained to the customer, or written off. A check that runs mid-period is worth several times one that runs monthly, because mid-period there is still something to do about it.

Emitter postsusage2xx on every callMeter comparesevent_name andpayload keysNo matchevent droppedsilentlyMeteraggregates 0nothing to billInvoicefinalizesbackfillimpossible
Every request succeeds. The meter ignores what it does not recognise, and ignoring is not an error.

Why it happens

A meter drops what it does not recognise, and dropping is not an error. Meter events are matched by event_name. An emitter sending api_requests to a meter named api_request is posting valid, accepted events into nothing. There is no failed request and no alert to fire, because from the API's point of view nothing went wrong.

The customer is resolved by a payload key, not by the request. The meter's customer_mapping.event_payload_key names the field inside payload that holds the customer id, and it defaults to stripe_customer_id. An emitter that sends customer_id instead produces events that exist but attach to nobody, so the summaries for the actual customer stay empty while a count somewhere else looks reassuringly non-zero.

The value is a second, separate key. value_settings.event_payload_key defaults to value. Send units and the events arrive, attach to the right customer, and aggregate to zero. This is the version of the bug that survives longest, because the meter clearly is receiving traffic.

Reconciliation happens at invoice time and nowhere else. Billing Meters do not push a "your usage is suspiciously zero" signal. The first moment the mismatch becomes visible in Stripe is the invoice, which is also the last moment you could have done anything cheap about it.

The fix, as a flow

The script asks the meter what it aggregated for that customer this period, because the emitter's own logs cannot tell you whether Stripe counted an event or quietly dropped it.

GET event_summariesper customer, per periodUsage aggregatedreporting, leave itPeriod hours oldtoo early to judgeNo summary rowscheck event_name firstRows all zerocheck the value payload keyCycles billed at 0revenue already lost
No summary rows and rows that all read zero look identical on the invoice and have different repairs.

How to fix it

Find the metered items and the meter each one points at

GET /v1/subscriptions?status=active&limit=100&expand[]=data.items.data.price. An item is metered when price.recurring.usage_type is metered, and price.recurring.meter holds the id of the meter that bills it. Prices created before Billing Meters have no meter field, and those have to be matched against GET /v1/billing/meters?status=active by hand.

Ask the meter what it aggregated for that customer, this period

GET /v1/billing/meters/{meter}/event_summaries?customer={cus}&start_time=...&end_time=.... The period bounds live on the subscription item as current_period_start and current_period_end on recent API versions, and on the subscription itself on older ones, so read the item first and fall back. Floor both timestamps to the hour; the endpoint expects bounds aligned to the meter's grouping window and rejects them otherwise.

Separate no rows from zero-valued rows

Empty data means nothing ever matched this meter for this customer: suspect event_name first, then customer_mapping.event_payload_key. Rows present with every aggregated_value at 0 means the matching worked and the value did not: that is value_settings.event_payload_key. Reporting these as one finding wastes the afternoon.

Give a fresh period the benefit of the doubt

A period that opened an hour ago legitimately has no usage on a product billed daily. Suppress anything inside a few hours of current_period_start, and say so in the output rather than silently, so nobody concludes the check is broken when it reports nothing on the first of the month.

Count the cycles already billed at zero

GET /v1/invoices?subscription={sub}&status=paid&limit=10 and look for lines at amount = 0. That count is the difference between "fix the emitter" and "fix the emitter and decide what to do about four months of unbilled usage", and it is the number the finance conversation actually needs.

Compare the emitter's payload with the meter, field by field

GET /v1/billing/meters/{id} returns the three fields that have to match: event_name, customer_mapping.event_payload_key, value_settings.event_payload_key. Put them next to one real payload from your emitter. The mismatch is always visible in that comparison, and almost never visible in the emitter's own logs.

How to check it worked

Re-run the script mid-period once the emitter is corrected. Every metered item should report usage against its meter.

python3 stripe_metered_usage.py
# reporting  sub_123 / si_abc  meter mtr_9: 41,208 unit(s) so far this period

The full code

Three GETs and no writes — a restricted key with read access to Subscriptions, Billing Meters and Invoices is enough, and is what you should give it. The classifier is pure and takes the aggregated total, the number of summary rows, how far into the period we are, and how many closed invoices already billed zero, because those four facts are what separate a quiet Tuesday morning from four months of lost revenue.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 114 Stripe fixes, free and open source.
stripe_metered_usage.py
"""Report metered subscription items that no usage has been recorded against.

Read only. Three GETs, no writes: give this a RESTRICTED key with read access to
Subscriptions, Billing Meters and Invoices. The repair is printed, never
performed, because this script holds a credential to a live payments account.
"""
import argparse
import logging
import os
import sys
import time

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stripe_metered_usage")

API = "https://api.stripe.com/v1"

# A period that opened minutes ago has no usage yet on almost any product, and
# reporting that as a fault trains people to ignore the check on the 1st.
GRACE_HOURS = 6


def verdict(aggregated_value, summary_rows, hours_into_period, zero_billed_cycles):
    """Classify one metered subscription item. Pure, so the rules can be tested.

    `aggregated_value` is the sum of aggregated_value across the meter's event
    summaries for this customer and period, `summary_rows` how many rows came
    back, `hours_into_period` how far into the current period we are, and
    `zero_billed_cycles` how many already-paid invoices carry a zero line.
    Returns (state, detail).
    """
    if aggregated_value:
        return ("reporting",
                "%s unit(s) so far this period" % format(aggregated_value, ",g"))
    if hours_into_period < GRACE_HOURS:
        return ("early",
                "the period is %.1fh old; too early to call zero a fault"
                % hours_into_period)

    if summary_rows:
        cause = ("%d summary row(s) and every one aggregates to 0: the events "
                 "arrive and carry no value. Check value_settings."
                 "event_payload_key against the payload." % summary_rows)
        state = "zero-valued"
    else:
        cause = ("no meter event summaries at all for this customer: the events "
                 "never matched. Check event_name first, then customer_mapping."
                 "event_payload_key.")
        state = "silent"

    if zero_billed_cycles:
        return ("billed-zero",
                "%d closed invoice(s) already billed a zero line. %s"
                % (zero_billed_cycles, cause))
    return (state, cause)


def get(session, path, **params):
    r = session.get(API + path, params=params, timeout=30)
    if r.status_code == 401:
        raise SystemExit("401 from Stripe: the key is wrong, or is for the other mode")
    if r.status_code == 403:
        raise SystemExit("403 from Stripe: the restricted key lacks read access to "
                         + path)
    r.raise_for_status()
    return r.json()


def paginate(session, path, **params):
    params = dict(params, limit=params.get("limit", 100))
    while True:
        page = get(session, path, **params)
        data = page.get("data", [])
        for row in data:
            yield row
        if not data or not page.get("has_more"):
            return
        params["starting_after"] = data[-1]["id"]


def period_bounds(sub, item):
    """Current period for an item.

    current_period_start and current_period_end moved from the subscription onto
    each subscription item, because items on one subscription can now bill on
    different cycles. Read the item, fall back to the subscription, so this works
    either side of that change.
    """
    start = item.get("current_period_start") or sub.get("current_period_start")
    end = item.get("current_period_end") or sub.get("current_period_end")
    return start, end


def usage(session, meter_id, customer, start, end):
    """Return (rows, total) from the meter's event summaries for one customer.

    Both bounds are floored to the hour: the summaries endpoint expects timestamps
    aligned to the meter's grouping window and returns an error for anything else.
    """
    hour = 3600
    params = {"customer": customer,
              "start_time": (start // hour) * hour,
              "end_time": (end // hour) * hour,
              "limit": 100}
    rows, total = 0, 0
    page = get(session, "/billing/meters/%s/event_summaries" % meter_id, **params)
    for row in page.get("data", []):
        rows += 1
        total += row.get("aggregated_value") or 0
    return rows, total


def zero_billed(session, subscription_id, look_back):
    """Count already-paid invoices for this subscription carrying a zero line.

    Matching a line back to a specific price is version dependent, so this counts
    invoices with any zero-amount line instead. It is a corroborating number, not
    the diagnosis.
    """
    count = 0
    for inv in paginate(session, "/invoices", subscription=subscription_id,
                        status="paid", limit=look_back):
        if any((line.get("amount") or 0) == 0
               for line in (inv.get("lines") or {}).get("data", [])):
            count += 1
        if count >= look_back:
            break
    return count


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--invoice-look-back", type=int, default=6,
                    help="paid invoices per subscription to check for zero lines")
    args = ap.parse_args()

    key = os.environ.get("STRIPE_API_KEY")
    if not key:
        log.error("set STRIPE_API_KEY (use a restricted, read-only key)")
        return 2

    s = requests.Session()
    s.headers.update({"Authorization": "Bearer " + key})

    now = int(time.time())
    findings = 0
    checked = 0

    for sub in paginate(s, "/subscriptions", status="active",
                        **{"expand[]": "data.items.data.price"}):
        for item in (sub.get("items") or {}).get("data", []):
            price = item.get("price") or {}
            recurring = price.get("recurring") or {}
            if recurring.get("usage_type") != "metered":
                continue
            meter_id = recurring.get("meter")
            if not meter_id:
                log.warning("legacy    %s / %s  metered price %s has no meter; "
                            "match it against GET %s/billing/meters by hand",
                            sub["id"], item["id"], price.get("id"), API)
                continue

            checked += 1
            start, end = period_bounds(sub, item)
            if not start:
                continue
            rows, total = usage(s, meter_id, sub["customer"], start, end or now)
            hours = max(0.0, (now - start) / 3600.0)
            zeros = zero_billed(s, sub["id"], args.invoice_look_back) if not total else 0
            state, detail = verdict(total, rows, hours, zeros)

            line = "%-11s %s / %s  meter %s: %s" % (state, sub["id"], item["id"],
                                                    meter_id, detail)
            if state in ("reporting", "early"):
                log.info(line)
                continue

            findings += 1
            log.warning(line)
            log.warning("  compare the emitter payload with the meter definition:")
            log.warning("  GET %s/billing/meters/%s", API, meter_id)
            log.warning("  then backfill this period before its invoice finalizes; "
                        "usage cannot be added to a finalized invoice")

    log.info("checked %d metered item(s), %d not reporting", checked, findings)
    return 1 if findings else 0


if __name__ == "__main__":
    sys.exit(main())
stripe-metered-usage.mjs
/**
 * Report metered subscription items that no usage has been recorded against.
 *
 * Read only. Three GETs, no writes: give this a RESTRICTED key with read access
 * to Subscriptions, Billing Meters and Invoices. The repair is printed, never
 * performed, because this script holds a credential to a live payments account.
 */
const API = 'https://api.stripe.com/v1';

// A period that opened minutes ago has no usage yet on almost any product, and
// reporting that as a fault trains people to ignore the check on the 1st.
export const GRACE_HOURS = 6;

/**
 * Classify one metered subscription item. Pure, so the rules can be tested.
 * Returns [state, detail].
 */
export function verdict(aggregatedValue, summaryRows, hoursIntoPeriod, zeroBilledCycles) {
  if (aggregatedValue) {
    return ['reporting', `${aggregatedValue.toLocaleString('en-US')} unit(s) so far this period`];
  }
  if (hoursIntoPeriod < GRACE_HOURS) {
    return ['early',
      `the period is ${hoursIntoPeriod.toFixed(1)}h old; too early to call zero a fault`];
  }

  let cause;
  let state;
  if (summaryRows) {
    cause = `${summaryRows} summary row(s) and every one aggregates to 0: the events ` +
            'arrive and carry no value. Check value_settings.event_payload_key ' +
            'against the payload.';
    state = 'zero-valued';
  } else {
    cause = 'no meter event summaries at all for this customer: the events never ' +
            'matched. Check event_name first, then customer_mapping.event_payload_key.';
    state = 'silent';
  }

  if (zeroBilledCycles) {
    return ['billed-zero',
      `${zeroBilledCycles} closed invoice(s) already billed a zero line. ${cause}`];
  }
  return [state, cause];
}

async function get(key, path, params = {}) {
  const url = new URL(API + path);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
  if (res.status === 401) {
    throw new Error('401 from Stripe: the key is wrong, or is for the other mode');
  }
  if (res.status === 403) {
    throw new Error(`403 from Stripe: the restricted key lacks read access to ${path}`);
  }
  if (!res.ok) throw new Error(`${res.status} from ${url.pathname}`);
  return res.json();
}

async function* paginate(key, path, params = {}) {
  const p = { limit: 100, ...params };
  for (;;) {
    const page = await get(key, path, p);
    const data = page.data ?? [];
    for (const row of data) yield row;
    if (data.length === 0 || !page.has_more) return;
    p.starting_after = data[data.length - 1].id;
  }
}

/**
 * Current period for an item. These fields moved from the subscription onto each
 * item, because items on one subscription can now bill on different cycles.
 */
export function periodBounds(sub, item) {
  return [item.current_period_start ?? sub.current_period_start,
          item.current_period_end ?? sub.current_period_end];
}

async function usage(key, meterId, customer, start, end) {
  const hour = 3600;
  const page = await get(key, `/billing/meters/${meterId}/event_summaries`, {
    customer,
    start_time: Math.floor(start / hour) * hour,
    end_time: Math.floor(end / hour) * hour,
    limit: 100,
  });
  let rows = 0;
  let total = 0;
  for (const row of page.data ?? []) {
    rows += 1;
    total += row.aggregated_value ?? 0;
  }
  return { rows, total };
}

async function zeroBilled(key, subscriptionId, lookBack) {
  let count = 0;
  for await (const inv of paginate(key, '/invoices',
    { subscription: subscriptionId, status: 'paid', limit: lookBack })) {
    if ((inv.lines?.data ?? []).some((line) => (line.amount ?? 0) === 0)) count += 1;
    if (count >= lookBack) break;
  }
  return count;
}

async function main() {
  const key = process.env.STRIPE_API_KEY;
  if (!key) {
    console.error('set STRIPE_API_KEY (use a restricted, read-only key)');
    process.exitCode = 2;
    return;
  }

  const lookBack = 6;
  const now = Math.floor(Date.now() / 1000);
  let findings = 0;
  let checked = 0;

  for await (const sub of paginate(key, '/subscriptions',
    { status: 'active', 'expand[]': 'data.items.data.price' })) {
    for (const item of sub.items?.data ?? []) {
      const recurring = item.price?.recurring ?? {};
      if (recurring.usage_type !== 'metered') continue;
      const meterId = recurring.meter;
      if (!meterId) {
        console.warn(`legacy      ${sub.id} / ${item.id}  metered price ` +
          `${item.price?.id} has no meter; match it against GET ${API}/billing/meters by hand`);
        continue;
      }

      checked += 1;
      const [start, end] = periodBounds(sub, item);
      if (!start) continue;
      const { rows, total } = await usage(key, meterId, sub.customer, start, end ?? now);
      const hours = Math.max(0, (now - start) / 3600);
      const zeros = total ? 0 : await zeroBilled(key, sub.id, lookBack);
      const [state, detail] = verdict(total, rows, hours, zeros);

      const line = `${state.padEnd(11)} ${sub.id} / ${item.id}  meter ${meterId}: ${detail}`;
      if (state === 'reporting' || state === 'early') { console.log(line); continue; }

      findings += 1;
      console.warn(line);
      console.warn('  compare the emitter payload with the meter definition:');
      console.warn(`  GET ${API}/billing/meters/${meterId}`);
      console.warn('  then backfill this period before its invoice finalizes; ' +
                   'usage cannot be added to a finalized invoice');
    }
  }

  console.log(`checked ${checked} metered item(s), ${findings} not reporting`);
  if (findings) process.exitCode = 1;
}

// Only run when invoked directly. The test file imports this module, and without
// the guard main() would run there too, fail on the missing key, and set a
// non-zero exit code that fails the whole test file even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The tests are about the distinction the invoice cannot show you: nothing arrived versus everything arrived empty. They also pin the grace window, because a check that cries wolf every time a billing period rolls over is a check that gets muted before it ever finds anything.

test_stripe_metered_usage.py
from stripe_metered_usage import verdict


def test_usage_present_is_reporting():
    state, detail = verdict(41208, 12, 300.0, 0)
    assert state == "reporting"
    assert "41,208" in detail


def test_a_fresh_period_is_not_a_fault():
    # Zero usage two hours into a period is normal on almost any product.
    assert verdict(0, 0, 2.0, 0)[0] == "early"
    assert verdict(0, 0, 6.0, 0)[0] == "silent"


def test_no_summaries_points_at_the_event_name():
    state, detail = verdict(0, 0, 240.0, 0)
    assert state == "silent"
    assert "event_name" in detail


def test_rows_that_aggregate_to_zero_point_at_the_value_key():
    # The events matched the meter and the customer. Only the value did not.
    state, detail = verdict(0, 9, 240.0, 0)
    assert state == "zero-valued"
    assert "value_settings.event_payload_key" in detail


def test_already_billed_cycles_escalate_and_keep_the_cause():
    state, detail = verdict(0, 9, 240.0, 4)
    assert state == "billed-zero"
    assert "4 closed invoice(s)" in detail
    assert "value_settings.event_payload_key" in detail
stripe-metered-usage.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict, periodBounds } from './stripe-metered-usage.mjs';

test('usage present is reporting', () => {
  const [state, detail] = verdict(41208, 12, 300, 0);
  assert.equal(state, 'reporting');
  assert.ok(detail.includes('41,208'));
});

test('a fresh period is not a fault', () => {
  assert.equal(verdict(0, 0, 2, 0)[0], 'early');
  assert.equal(verdict(0, 0, 6, 0)[0], 'silent');
});

test('no summaries points at the event name', () => {
  const [state, detail] = verdict(0, 0, 240, 0);
  assert.equal(state, 'silent');
  assert.ok(detail.includes('event_name'));
});

test('rows that aggregate to zero point at the value key', () => {
  const [state, detail] = verdict(0, 9, 240, 0);
  assert.equal(state, 'zero-valued');
  assert.ok(detail.includes('value_settings.event_payload_key'));
});

test('already billed cycles escalate and keep the cause', () => {
  const [state, detail] = verdict(0, 9, 240, 4);
  assert.equal(state, 'billed-zero');
  assert.ok(detail.includes('4 closed invoice(s)'));
  assert.ok(detail.includes('value_settings.event_payload_key'));
});

test('the item period wins over the subscription period', () => {
  const bounds = periodBounds({ current_period_start: 1, current_period_end: 2 },
                              { current_period_start: 10, current_period_end: 20 });
  assert.deepEqual(bounds, [10, 20]);
});

FAQ

Why is the invoice zero when my emitter is clearly sending events?

Because a meter silently ignores anything it does not recognise. Events whose event_name does not match the meter are accepted and dropped, and events whose payload does not carry the customer under customer_mapping.event_payload_key attach to nobody. Neither produces an error, so the emitter's logs look perfect.

What is the difference between no summary rows and rows that are all zero?

No rows means the event never matched the meter or the customer, so look at event_name and customer_mapping.event_payload_key. Rows at zero means matching worked and the value did not, so look at value_settings.event_payload_key. The invoice looks the same either way, which is why the check reports them as different states.

Can I backfill usage after the invoice has finalized?

No. Meter events belong to a period, and once that period's invoice is finalized it cannot absorb more usage. Recovering the revenue means a separate charge and a conversation with the customer, so run this check mid-period rather than after the invoice.

Where do I find the meter for a metered price?

price.recurring.meter on an expanded price. Prices created before Billing Meters have no meter field at all; the script reports those separately, because they have to be matched against GET /v1/billing/meters by hand rather than guessed at.

Why does the script skip periods that just started?

Because zero usage six hours into a monthly period is normal, and a check that alerts every time a billing cycle rolls over gets muted long before it catches a real mismatch. The grace window is one constant at the top of the file.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.