How to Set Up Usage-Based Billing with Stripe Meters (2026)
This guide is for engineers adding pay-as-you-go pricing to a product that already bills through Stripe. By the end you will have a meter, a metered price, a subscription, a usage sender that is safe to retry, a high-throughput path through v2 meter event streams, a webhook handler for the errors Stripe reports asynchronously, and a test-clock simulation of a full billing cycle. Commands and field names follow Stripe's docs (the sample responses are adapted from them) for API version 2026-10-28.endive, stripe-python 16.1.0 and Stripe CLI 1.55.6 on 31 October 2026. You need a Stripe sandbox, its secret key in STRIPE_SECRET_KEY, and Python 3.11 or newer.
Before you start: Billing Meters or Metronome?
Read this first, because Stripe changed its advice this year. After completing the Metronome acquisition in January 2026, Stripe's Billing Meters implementation guide now opens with a "Not Recommended" box telling new integrations to use Metronome. Existing Billing Meters integrations stay fully supported.
Stripe's own line is strict: the guide calls Billing Meters appropriate only if you already bill with them and your pricing will stay simple pay-as-you-go. The comparison page adds the practical exception: Connect, Adaptive Pricing, Workflows and the Stripe Dashboard are all unsupported on Metronome, so if you depend on one of those, Billing Meters may be your only Stripe-native option. Choose Metronome if you need prepaid credits that burn down in real time, enterprise commits and ramps, per-dimension pricing, or live usage visibility, because basic meters only reconcile credits at invoice time. If you want the wider picture first, I compared the vendors in usage-based billing in 2026.
If you are on the legacy usage records API: it was removed in API version 2025-03-31.basil, and metered prices must now be backed by meters. Follow Stripe's migration guide before anything below.
Step 1: Install the Stripe CLI and pin your API version
The CLI is for forwarding webhooks and triggering test events; the API calls below use plain curl so you can see every parameter.
# macOS (Homebrew). Linux and Windows builds: https://github.com/stripe/stripe-cli/releases
brew install stripe/stripe-cli/stripe
stripe version # expect: stripe version 1.55.6 (or newer)
stripe login # opens a browser, pairs the CLI with your sandbox
# Python SDK, pinned. 16.1.0 defaults to API version 2026-10-28.endive.
python3 -m venv .venv && . .venv/bin/activate
pip install "stripe==16.1.0" "flask==3.1.3"
Pin the API version on your account or on every request. Billing behaviour (for example, whether legacy usage records work at all) is version-dependent, and a silent upgrade is the last thing you want in a billing path.
Step 2: Create a meter
A meter defines how events aggregate over a billing period. After creation you can change only the display name, so get the event name and payload keys right the first time (Stripe docs).
curl https://api.stripe.com/v1/billing/meters \
-u "$STRIPE_SECRET_KEY:" \
-H "Stripe-Version: 2026-10-28.endive" \
-d "display_name=LLM tokens" \
-d event_name=llm_tokens \
-d "default_aggregation[formula]=sum" \
-d "customer_mapping[type]=by_id" \
-d "customer_mapping[event_payload_key]=stripe_customer_id" \
-d "value_settings[event_payload_key]=value"
Expected response (trimmed):
{
"id": "mtr_test_61Q8nQMqIFK9fRQmr41CMAXJrFdZ5MnA",
"object": "billing.meter",
"default_aggregation": { "formula": "sum" },
"event_name": "llm_tokens",
"event_time_window": null,
"status": "active"
}
Use sum for quantities (tokens, GB, seconds), count for one-event-per-action billing, and last for gauges such as "seats active at period end". If you pre-aggregate on your side, set event_time_window=hour or day: Stripe then keeps only the most recent event per window instead of adding them up. Each event_name can belong to only one meter.
Step 3: Create a metered price
Attach a price to the meter with recurring[usage_type]=metered. This example charges $0.04 per 100 tokens, rounding up, billed monthly, which is the pattern from Stripe's own implementation guide.
export METER_ID=mtr_test_61Q8nQMqIFK9fRQmr41CMAXJrFdZ5MnA
curl https://api.stripe.com/v1/prices \
-u "$STRIPE_SECRET_KEY:" \
-H "Stripe-Version: 2026-10-28.endive" \
-d currency=usd \
-d unit_amount=4 \
-d billing_scheme=per_unit \
-d "transform_quantity[divide_by]=100" \
-d "transform_quantity[round]=up" \
-d "recurring[usage_type]=metered" \
-d "recurring[interval]=month" \
-d "recurring[meter]=$METER_ID" \
-d "product_data[name]=LLM tokens"
For tiered pricing (cheaper per unit at volume), use billing_scheme=tiered with tiers instead of unit_amount. For a platform fee plus usage, add a second, licensed (non-metered) price to the same subscription.
Step 4: Create a customer and a subscription
curl https://api.stripe.com/v1/customers \
-u "$STRIPE_SECRET_KEY:" \
-d "name=Test Tenant" \
--data-urlencode "email=tenant@example.com"
# => {"id": "cus_...", ...}
curl https://api.stripe.com/v1/subscriptions \
-u "$STRIPE_SECRET_KEY:" \
-d "customer=$CUSTOMER_ID" \
-d "items[0][price]=$PRICE_ID"
Store the cus_... ID against your tenant at signup. Every meter event must carry it (or whatever key you set in customer_mapping). Stripe also notes that for billing_mode=flexible subscriptions the first invoice excludes metered items, since there is no prior usage to bill; classic mode adds a zero-value line instead. Stripe's docs now also suggest modelling customers as customer-configured Accounts v2 objects (generally available for Connect users, public preview for everyone else); subscriptions then take customer_account and meter summaries take the account ID, but I have stuck with plain Customers here.
Step 5: Send meter events with a deterministic identifier
This is the step that decides whether your invoices are right. The meter event endpoint takes an optional identifier and enforces uniqueness for "a rolling period of at least 24 hours". Derive it from your own data so that retries and replays map to the same value.
# send_usage.py: report usage with a stable identifier (stripe-python 16.1.0).
import os
import time
from stripe import StripeClient
client = StripeClient(os.environ["STRIPE_SECRET_KEY"], max_network_retries=3)
def report_usage(customer_id: str, tokens: int, source_event_id: str, ts: int | None = None):
return client.v1.billing.meter_events.create({
"event_name": "llm_tokens",
# Same source event -> same identifier -> Stripe dedupes the retry.
"identifier": f"llm-{source_event_id}", # max 100 characters
"timestamp": ts or int(time.time()), # past 35 days .. +5 minutes
"payload": {
"stripe_customer_id": customer_id,
"value": str(tokens), # payload values are strings
},
})
if __name__ == "__main__":
ev = report_usage(os.environ["CUSTOMER_ID"], 3000, "req_01J9Z6X0TEST")
print(ev.identifier, ev.payload)
Expected output:
llm-req_01J9Z6X0TEST {'stripe_customer_id': 'cus_...', 'value': '3000'}
A 200 response means "accepted for processing", not "valid". Validation happens later and errors arrive by webhook (Step 8). Live-mode limits are 1,000 calls a second per account and one concurrent call per customer per meter (Stripe docs); in a sandbox the calls count against your normal rate limit. Ship usage from a transactional outbox rather than inline in request handlers; the blog post on usage-based billing has a full outbox drainer.
To fix a bad event, cancel it by identifier, but only within 24 hours of sending:
curl https://api.stripe.com/v1/billing/meter_event_adjustments \
-u "$STRIPE_SECRET_KEY:" \
-d type=cancel \
-d event_name=llm_tokens \
-d "cancel[identifier]=llm-req_01J9Z6X0TEST"
Step 6: Use v2 meter event streams for high throughput
Above about 1,000 events a second, or when you would rather not pre-aggregate, use the v2 meter event stream. You create a session with your secret key, get an authentication_token valid for 15 minutes, and post batches of up to 100 events to a separate host. It accepts up to 10,000 requests a second, live mode only, with up to 200,000 available through sales.
# stream_usage.py: high-throughput meter events via API v2 (stripe-python 16.1.0).
import os
from datetime import datetime, timedelta, timezone
from stripe import StripeClient
admin = StripeClient(os.environ["STRIPE_SECRET_KEY"])
_session, _stream = None, None
def stream_client() -> StripeClient:
"""Return a client authenticated with a fresh meter event session token."""
global _session, _stream
now = datetime.now(timezone.utc)
# Refresh a minute early so in-flight batches never carry an expired token.
# expires_at is an RFC 3339 string in stripe-python 16; fromisoformat needs Python 3.11+.
if _session is None or datetime.fromisoformat(_session.expires_at) - timedelta(minutes=1) <= now:
_session = admin.v2.billing.meter_event_session.create()
_stream = StripeClient(_session.authentication_token)
return _stream
def send_batch(events: list[dict]):
assert len(events) <= 100, "meter_event_stream accepts at most 100 events per request"
stream_client().v2.billing.meter_event_stream.create({"events": events})
send_batch([{
"event_name": "llm_tokens",
"identifier": "llm-req_01J9Z6X0TEST2",
"timestamp": datetime.now(timezone.utc).isoformat(), # v2 uses RFC 3339 strings
"payload": {"stripe_customer_id": os.environ["CUSTOMER_ID"], "value": "25"},
}])
The equivalent raw request, if you are not using an SDK:
curl -X POST https://meter-events.stripe.com/v2/billing/meter_event_stream \
-H "Authorization: Bearer $SESSION_TOKEN" \
-H "Stripe-Version: 2026-10-28.endive" \
--json '{"events":[{"identifier":"llm-req_01","event_name":"llm_tokens",
"timestamp":"2026-10-31T12:00:00.000Z",
"payload":{"stripe_customer_id":"cus_123","value":"25"}}]}'
# Response: {} (an expired token returns 401 billing_meter_event_session_expired)
Stream requests do not appear in the Workbench logs tab, so log your own batch IDs and response codes.
Step 7: Preview the invoice and read meter summaries
Meter events are processed asynchronously, so give them a little time before you check totals.
# What would this subscription's next invoice look like right now?
curl https://api.stripe.com/v1/invoices/create_preview \
-u "$STRIPE_SECRET_KEY:" \
-d "subscription=$SUBSCRIPTION_ID"
# Aggregated usage for one customer over a window (Unix seconds).
curl -G "https://api.stripe.com/v1/billing/meters/$METER_ID/event_summaries" \
-u "$STRIPE_SECRET_KEY:" \
-d "customer=$CUSTOMER_ID" \
-d start_time=1761955200 \
-d end_time=1762041600
create_preview replaced the old upcoming-invoice endpoint. If you show customers a live usage page, read from your own outbox totals and use summaries as a reconciliation check, since basic meters do not offer real-time visibility.
Step 8: Handle meter error webhooks and usage alerts
Two kinds of events matter. Meter validation errors are thin events (v1.billing.meter.error_report_triggered and v1.billing.meter.no_meter_found) that you must subscribe to with the Thin payload style in Workbench. Invoice and alert events (invoice.created, invoice.finalized, invoice.payment_failed, billing.alert.triggered) are ordinary snapshot events on a normal endpoint.
# webhook.py: thin meter-error events (stripe-python 16.1.0, Flask 3.1.3).
import os
from flask import Flask, request, jsonify
from stripe import StripeClient
app = Flask(__name__)
client = StripeClient(os.environ["STRIPE_SECRET_KEY"])
THIN_SECRET = os.environ["THIN_WEBHOOK_SECRET"]
@app.post("/webhooks/stripe-thin")
def thin_events():
notif = client.parse_event_notification(
request.data, request.headers.get("Stripe-Signature"), THIN_SECRET
)
if notif.type == "v1.billing.meter.error_report_triggered":
event = notif.fetch_event()
# e.g. "There is 1 invalid event"; page someone, then fix and resend.
app.logger.error("meter %s: %s", notif.related_object.id,
event.data.developer_message_summary)
elif notif.type == "v1.billing.meter.no_meter_found":
app.logger.error("no meter: %s", notif.fetch_event().data.developer_message_summary)
return jsonify(ok=True), 200
Test it locally:
# CLI 1.55.x deprecates --forward-thin-to and --thin-events; use --all-thin with --forward-to.
stripe listen --all-thin --forward-to localhost:4242/webhooks/stripe-thin
stripe trigger v1.billing.meter.error_report_triggered
stripe listen prints a whsec_... signing secret when it starts; put that in THIN_WEBHOOK_SECRET for local testing. Thin and snapshot events cannot share one forwarding destination, so run a second stripe listen --forward-to for the invoice events below.
For "tell me when a tenant passes 1M tokens", create a usage alert and listen for billing.alert.triggered:
curl https://api.stripe.com/v1/billing/alerts \
-u "$STRIPE_SECRET_KEY:" \
-d "title=1M tokens" \
-d alert_type=usage_threshold \
-d "usage_threshold[meter]=$METER_ID" \
-d "usage_threshold[gte]=1000000" \
-d "usage_threshold[recurrence]=one_time" \
-d "usage_threshold[filters][0][type]=customer" \
-d "usage_threshold[filters][0][customer]=$CUSTOMER_ID"
Finally, check your invoice finalisation grace period. The default is 1 hour and you can raise it to 72 hours for invoices with metered prices. If your outbox can lag by more than the grace period, late usage is accepted but never billed.
Step 9: Test a full billing cycle with test clocks
Test clocks let you run a month in minutes. Create the clock at the current time, attach a new customer (you cannot add existing customers to a clock), record usage, then advance.
NOW=$(date +%s)
curl https://api.stripe.com/v1/test_helpers/test_clocks \
-u "$STRIPE_SECRET_KEY:" -d frozen_time=$NOW -d "name=Metered month"
# => {"id": "clock_...", "status": "ready", ...}
curl https://api.stripe.com/v1/customers \
-u "$STRIPE_SECRET_KEY:" -d test_clock=$CLOCK_ID \
-d payment_method=pm_card_visa -d "invoice_settings[default_payment_method]=pm_card_visa"
curl https://api.stripe.com/v1/subscriptions \
-u "$STRIPE_SECRET_KEY:" -d "customer=$CUSTOMER_ID" -d "items[0][price]=$PRICE_ID"
# Record usage now (python send_usage.py with CUSTOMER_ID set), then jump 31 days and 2 hours
# so the cycle invoice is created and the default 1-hour grace period passes.
curl https://api.stripe.com/v1/test_helpers/test_clocks/$CLOCK_ID/advance \
-u "$STRIPE_SECRET_KEY:" -d frozen_time=$((NOW + 31*86400 + 7200))
# Poll until status is "ready", then inspect the invoice.
curl -G https://api.stripe.com/v1/invoices -u "$STRIPE_SECRET_KEY:" -d customer=$CUSTOMER_ID
Expect a finalised invoice with one metered line whose quantity matches your summed tokens divided by 100 and rounded up. A clock can hold three customers, you can advance at most two billing intervals at a time, and usage alerts do not fire on test clocks. Stripe's docs do not spell out how meter event timestamps interact with a clock's simulated time, so record usage before advancing, keep timestamps inside the real-time window, and verify the result in your sandbox rather than assuming.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Invoice shows 0 usage, API calls returned 200 | Events failed async validation (customer not found, wrong payload key) | Subscribe to v1.billing.meter.error_report_triggered; check customer_mapping.event_payload_key matches your payload |
no_meter errors |
event_name typo, or meter deactivated (archived_meter) |
List meters with GET /v1/billing/meters; event names are case-sensitive and map to one meter |
timestamp_too_far_in_past or timestamp_in_future |
Event older than 35 days, or clock skew over 5 minutes | Send usage promptly; sync NTP; omit timestamp to default to now |
| Customer billed twice for the same usage | Replay outside the 24 hour identifier window, or a random UUID per retry | Derive identifier from your source event; keep a sent marker in your own database |
| Usage reported but missing from the invoice | Arrived after finalisation, or a mid-cycle price change skipped the grace period | Raise the grace period (up to 72 hours) for metered invoices; alert on outbox lag |
| HTTP 429 on meter events | Over 1,000/s per account or concurrent calls for one customer and meter | Pre-aggregate per customer per minute, add backoff, or move to v2 streams |
401 billing_meter_event_session_expired |
v2 session token older than 15 minutes | Refresh the session before expires_at (Step 6) |
meter_event_dimension_count_too_high |
Too many unique dimension combinations (10,000 per meter per hour, 100 per customer) | Drop high-cardinality keys (request IDs, user IDs) from dimensions |
AttributeError: ... no attribute 'parse_thin_event' |
Code copied from older docs; stripe-python 16 uses parse_event_notification |
Use client.parse_event_notification(body, sig, secret) |
Legacy UsageRecord calls fail |
Removed in API version 2025-03-31.basil | Migrate to meters; pin 2025-02-24.acacia only while migrating |
FAQ
Can I still use Stripe usage records in 2026?
Only by pinning API version 2025-02-24.acacia or earlier, since usage records were removed in 2025-03-31.basil. Stripe's migration guide lets you send both in parallel while you move subscriptions to meter-backed prices. Treat it as a migration window, not a steady state.
How does Stripe prevent duplicate meter events?
Stripe deduplicates on the meter event identifier for a rolling period of at least 24 hours. If you omit it, Stripe generates one, which means retries are never deduplicated. Always set it from your own event ID.
What is the difference between v1 meter events and v2 meter event streams?
v1 is a normal authenticated API call, limited to 1,000 calls a second per account in live mode. v2 streams use a 15-minute session token, accept batches of up to 100 events, and allow 10,000 requests a second in live mode only. Both validate asynchronously.
Should I use Billing Meters or Metronome for a new project?
Stripe recommends Metronome for new usage-based integrations, especially with prepaid credits, commits or real-time balances. Billing Meters still make sense for simple pay-as-you-go and for Connect platforms, which Metronome does not support. Basic metered usage is included in Stripe Billing pricing, with up to 100M events a month.
When I wire this up in production, the first thing I check is not the price: it is whether the meter error webhook reaches a human, and whether the age of the oldest unsent usage row is alerted on well inside the grace period. Billing is the one pipeline where "eventually consistent" has an invoice date. Keep Stripe's recording usage, meter configuration and test clocks docs open while you build; they changed more in 2026 than in the previous two years.
Related: FinOps for AI in 2026, Multi-Tenant SaaS Architecture Explained and Seats Are Dying. What the 2026 SaaS Repricing Looks Like From the Compute Layer.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Reduce AWS Costs: Step-by-Step Checklist With CLI (2026)
Cut your AWS bill step by step in 2026: break down EC2-Other in Cost Explorer, export CUR 2.0 to Athena, enable Compute Optimizer and Cost Optimization Hub, find idle EBS, snapshots, IPs and load balancers with the AWS CLI, swap NAT for VPC endpoints, add lifecycle rules, budgets and anomaly alerts.
16 minOct 31, 2026How to Implement SOC 2 Controls on AWS with Terraform (2026)
Step-by-step SOC 2 technical controls on AWS with Terraform: organization CloudTrail, AWS Config and a custom conformance pack, GuardDuty, Security Hub CSPM with CIS v5.0.0, Identity Center MFA, encryption defaults, AWS Backup vault lock, 400-day retention and evidence export.
18 minOct 31, 2026Usage-Based Billing in 2026: Stripe Bought Metronome, Adyen Bought Orb
How usage-based billing for SaaS works in 2026: the metering pipeline, pay-as-you-go vs credits vs commits, billing for AI tokens, Stripe Meters vs Metronome vs Orb vs Lago vs OpenMeter, and the idempotency and late-event bugs that cost real money.
12 min