Usage-based billing
Report metered usage with usage.report — provider support, the deliberate no-retry rule, idempotency keys, backdating windows, and Subscription.meters.
Metered pricing bills for what a customer consumed, so the provider needs a stream of usage events from
your application. client.usage.report sends one event to the meter that matches its eventName.
await client.usage.report({
customerId: 'cus_123',
eventName: 'api_request',
value: 25,
metadata: { region: 'eu' },
idempotencyKey: 'evt_abc',
timestamp: new Date(),
});
It resolves to void: providers acknowledge ingestion, they do not hand back a usage record.
Parameters
customerId?string
The provider's customer identifier — Customer.id or Subscription.customerId, never your own user ID.
stringeventName?string
The meter event name. It must match the meter configuration exactly — providers match case-sensitively.
stringvalue?number
Shorthand for a value entry in the event properties, the key meters aggregate on by default.
numbermetadata?Record<string, string | number | boolean>
Event properties the meter can filter or aggregate on.
Record<string, string | number | boolean>idempotencyKey?string
Deduplicates replays. Sent as external_id (Polar), identifier (Stripe), event_id (Dodo Payments).
stringtimestamp?Date
When the usage occurred. Defaults to receipt time; the accepted window differs sharply per provider.
Datesignal?AbortSignal
Abort the request.
AbortSignalThe client rejects an empty customerId, an empty eventName, and any non-finite number — whether it
arrives as value or as a numeric metadata entry — with RevenueError { code: 'validation' }
before any request is made. Every numeric entry is checked, not just value, because a meter
aggregates on a key you configure: a NaN under any name is a corrupted billed quantity, and it would
otherwise reach the provider as null or "NaN". A provider without the usageReporting capability
throws unsupported, also pre-flight.
value versus metadata
value is shorthand for a value entry in the event properties — the key every provider’s meters
aggregate on by default. These two calls send the same payload:
await client.usage.report({ customerId, eventName: 'api_request', value: 25 });
await client.usage.report({ customerId, eventName: 'api_request', metadata: { value: 25 } });
An explicit value wins over a metadata.value. Everything else in metadata is passed through
untouched, which is what meter filters and non-default aggregation keys read.
Provider support
Usage reporting is gated by the usageReporting capability:
| Provider | usageReporting |
Endpoint | Customer key | Idempotency parameter |
|---|---|---|---|---|
| Polar | true |
POST /v1/events/ingest |
customer_id |
external_id — permanent unique index |
| Lemon Squeezy | false |
— | — | — |
| Stripe | true |
POST /v1/billing/meter_events |
payload[stripe_customer_id] |
identifier — rolling window of at least 24 h |
| Paddle | false |
— | — | — |
| Dodo Payments | true |
POST /events/ingest |
customer_id |
event_id — required by the API |
if (client.capabilities.usageReporting) {
await client.usage.report({ customerId, eventName: 'api_request', value: 1 });
}
All three supporting providers expose a batch ingest endpoint; usage.report sends a one-event
batch. Batch reporting is not part of the unified API — see Out of scope.
Lemon Squeezy and Paddle have no safely idempotent ingestion upstream, so usageReporting is false
there and usage.report throws unsupported — see Lemon Squeezy and
Paddle. Polar caps metadata and can key events by your own customer ID; see
Polar.
Usage writes are never retried
Every other client method gets one bounded rate_limited retry. usage.report
deliberately bypasses it.
If you want retries, own them — and pass a stable idempotencyKey that does not change between
attempts:
import { RevenueError } from 'revenue-sdk';
const idempotencyKey = crypto.randomUUID(); // generated once, reused by every attempt
for (let attempt = 0; ; attempt++) {
try {
await client.usage.report({ customerId, eventName: 'api_request', value: 1, idempotencyKey });
break;
} catch (error) {
if (attempt === 2 || !(error instanceof RevenueError) || !error.retryable) {
throw error;
}
await sleep((error.retryAfter ?? 2 ** attempt) * 1000);
}
}
A key derived from the thing you are billing for — a request ID, a job ID, a log line ID — is better than a random one: it also deduplicates across process restarts and queue redeliveries.
Backdating windows differ sharply
timestamp is not portable. The three supporting providers disagree on how far into the past an event
may be dated, and on what a backdated event means for money:
| Provider | Accepted timestamp |
Out of range | What timestamp affects |
|---|---|---|---|
| Polar | any past timestamp | accepted | reporting only — never the invoice |
| Stripe | past 35 calendar days, up to 5 minutes ahead | error | which billing period the usage lands in |
| Dodo Payments | past 1 hour, up to 5 minutes ahead | 400 → validation |
which billing period the usage lands in |
Dodo Payments is the one to design around: a queue that has been down for two hours has lost its
billable window, and there is no way to backfill it. Omitting timestamp — reporting at the moment the
usage happens — is the only behavior that is the same everywhere.
Reading usage back: Subscription.meters
Subscription carries an optional meters array with the current period’s usage per meter:
const subscription = await client.subscriptions.get({ id: 'SUBSCRIPTION_ID' });
for (const meter of subscription.meters ?? []) {
console.log(meter.name, meter.consumedUnits, meter.amount);
}
id?string
The meter's identifier.
stringname?string
The meter name as configured in the provider.
stringconsumedUnits?number
Units consumed in the current meter period.
numbercreditedUnits?number
Units granted upfront — an included allowance or credits.
numberamount?number
Amount accrued for this meter so far this period, in the currency's minor units.
numberraw?unknown
The untouched provider payload.
unknownTesting
The in-memory provider records every reported event on state.usageEvents, so you can assert on what
your code would have billed, and capabilities: { usageReporting: false } exercises the Lemon Squeezy
and Paddle fallback path. See Testing provider.
Out of scope
- Meter CRUD and management — configure meters in the provider dashboard.
- Reading usage back beyond Polar’s inline
Subscription.meters; there is nousage.getCurrent. - Normalized amounts for metered and tiered prices —
Price.amountstaysnulloutsidefixed, see Products & prices. - Batch reporting, tier tables, and seat management.