Skip to content
revenue-sdk
Esc
navigateopen⌘Jpreview
On this page

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

PropType
customerId?string

The provider's customer identifier — Customer.id or Subscription.customerId, never your own user ID.

Typestring
eventName?string

The meter event name. It must match the meter configuration exactly — providers match case-sensitively.

Typestring
value?number

Shorthand for a value entry in the event properties, the key meters aggregate on by default.

Typenumber
metadata?Record<string, string | number | boolean>

Event properties the meter can filter or aggregate on.

TypeRecord<string, string | number | boolean>
idempotencyKey?string

Deduplicates replays. Sent as external_id (Polar), identifier (Stripe), event_id (Dodo Payments).

Typestring
timestamp?Date

When the usage occurred. Defaults to receipt time; the accepted window differs sharply per provider.

TypeDate
signal?AbortSignal

Abort the request.

TypeAbortSignal

The 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_idrequired 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 400validation 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);
}
PropType
id?string

The meter's identifier.

Typestring
name?string

The meter name as configured in the provider.

Typestring
consumedUnits?number

Units consumed in the current meter period.

Typenumber
creditedUnits?number

Units granted upfront — an included allowance or credits.

Typenumber
amount?number

Amount accrued for this meter so far this period, in the currency's minor units.

Typenumber
raw?unknown

The untouched provider payload.

Typeunknown

Testing

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 no usage.getCurrent.
  • Normalized amounts for metered and tiered prices — Price.amount stays null outside fixed, see Products & prices.
  • Batch reporting, tier tables, and seat management.

Last updated on August 8, 2026

Was this page helpful?