Usage-based billing with Stripe: an allowance each month, then a price per unit
A flat price fits most features. It does not fit the ones that cost you money each time someone uses them, like a call to a language model. The usual answer is an allowance with a price beyond it: 500 messages a month included, then 2 cents each. On the free plan, you stop at the allowance instead.
Stripe can count usage for you with Meters, and it is tempting to send it every unit and let it work out the bill. That leaves your app unable to answer the questions it needs to answer before doing the work: is this one free, charged, or refused? And it cannot show the customer how much is left.
So count usage in your own database, decide there, and send Stripe only the units it should charge for. This guide does that in NestJS with Postgres, with Stripe Meters for the charge.
1. Put the allowance in the plan
Declare it where your plans live, in code, so the API, the pricing page and Stripe all read the same numbers:
interface MeteredPrice { /** Units included each month. */ included: number; /** Cents for each unit beyond. Null: refuse beyond. */ pricePerUnit: number | null;}
const PLANS = { free: { price: 0 }, // no usage: free stops at its allowance pro: { price: 1200, usage: { aiMessages: { included: 500, pricePerUnit: 2 } }, }, business: { price: 2900, usage: { aiMessages: { included: 2000, pricePerUnit: 1 } }, },};pricePerUnit: null is a hard limit: the plan includes some, and then nothing more until next month.
2. Record each use before doing the work
Count a unit before the expensive call, so a refusal costs you nothing. One table holds every use:
export const usageEvents = pgTable('usage_events', { id: uuid().primaryKey().defaultRandom(), workspaceId: text().notNull(), meter: text().notNull(), quantity: integer().notNull(), /** The units beyond the allowance: what Stripe charges. */ billable: integer().notNull().default(0), /** Set by the caller, so a repeat counts once. */ key: text(), createdAt: timestamp({ withTimezone: true }).notNull(), /** When the billable units reached Stripe. */ reportedAt: timestamp({ withTimezone: true }),}, (t) => [unique().on(t.workspaceId, t.key)]);Recording decides three things in one transaction: whether this use was already counted, how many of its units are beyond the allowance, and whether those can be charged.
async record(workspaceId: string, meter: Meter, key?: string) { await this.db.transaction(async (tx) => { await lockMeter(tx, workspaceId, meter); if (key && (await hasKey(tx, workspaceId, key))) return;
const { plan, canCharge } = await billingOf(tx, workspaceId); const price = PLANS[plan].usage?.[meter]; const used = await usedThisPeriod(tx, workspaceId, meter); const billable = billableUnits(price, used, 1, canCharge); if (billable === null) throw new UsageLimitException(meter);
const [row] = await tx.insert(usageEvents) .values({ workspaceId, meter, quantity: 1, billable, key, createdAt: new Date() }) .returning({ id: usageEvents.id });
if (billable > 0) await enqueue(tx, 'usage.report', row.id); });}The rule for what is billable is small enough to test on its own:
function billableUnits( price: MeteredPrice, used: number, quantity: number, canCharge: boolean,): number | null { const beyond = Math.max(0, used + quantity - Math.max(used, price.included)); if (beyond === 0) return 0; return canCharge && price.pricePerUnit !== null ? beyond : null; // null: refuse}canCharge is false on the free plan, and when the workspace has no Stripe customer: there is nobody to send the bill to, so it stops at the allowance whatever the price says.
Three details matter here:
-
Lock the meter. Two requests at the same moment can both read “499 used” and both take the last free unit. A Postgres advisory lock on the workspace and meter, held until the transaction ends, makes them take turns:
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`usage:${workspaceId}:${meter}`}, 0))`); -
Refuse with 402.
402 Payment Requiredtells the client this is about the plan, not a bug, and the message can say when the allowance comes back. -
Take the key from your own data, like
ai:<messageId>, never from the request. Otherwise a client can resend the same key and never be counted.
3. Count by the subscription’s month, not the calendar’s
“This month” for a paid plan is the subscription’s period, which starts on the day the customer subscribed. Read it from Stripe’s webhook and store it with the subscription. In current API versions it sits on the subscription item (item.current_period_start), not on the subscription.
Two cases catch people out:
- Yearly plans. The allowance is still monthly. Count months forward from the start of the period, and clamp to the end of short months: a period starting on January 31 renews on February 28.
- The renewal webhook arrives late. Until it does, your stored period has ended. Keep counting months forward from the old start instead of waiting, so usage at the turn of the month lands in the new one.
The free plan has no subscription: use the calendar month in UTC.
4. Send the extra units to Stripe, exactly once
Only rows with billable > 0 go to Stripe, each by a job queued in the same transaction as the row (the outbox pattern), so a refused or rolled-back use never reaches it. The job sends a Meter event:
async report(id: string) { const row = await findWithCustomer(id); if (!row || row.reportedAt || row.billable === 0) return;
await this.stripe.billing.meterEvents.create({ event_name: row.meter, identifier: row.id, // Stripe drops a second event with it timestamp: Math.floor(row.createdAt.getTime() / 1000), payload: { stripe_customer_id: row.customerId, value: String(row.billable), }, }); await markReported(id);}identifieris what makes it exactly once. If the worker crashes after Stripe took the event but beforemarkReported, the retry sends it again, and Stripe refuses the duplicate. Treat that refusal as success.timestampis when it was used, not when it was sent, so a late report still lands in the right invoice.- Retry for a long time. If Stripe is down, keep the row and try again with growing delays. Meter events are accepted for usage from some weeks back, so a day of retries is plenty.
Stripe never sees the free units at all, so its metered price needs no free tier of its own.
5. Set up the meter and the price in Stripe
Create one meter per thing you count, summing the values it receives:
const meter = await stripe.billing.meters.create({ display_name: 'AI messages', event_name: 'aiMessages', default_aggregation: { formula: 'sum' }, customer_mapping: { type: 'by_id', event_payload_key: 'stripe_customer_id', }, value_settings: { event_payload_key: 'value' },});Then a metered price on each plan’s product, in cents (decimals are fine):
await stripe.prices.create({ product: proProductId, currency: 'usd', unit_amount_decimal: '2', lookup_key: 'pro_month_aiMessages', recurring: { interval: 'month', usage_type: 'metered', meter: meter.id, },});Add it to checkout next to the plan’s own price, with no quantity: Stripe charges what the meter counted.
line_items: [ { price: planPrice.id, quantity: 1 }, { price: meteredPrice.id },],Create all of this from your plans with a script you can run again, rather than by hand in the dashboard. Then changing a price is a change in code.
Mistakes to avoid
- Existing subscribers have no metered price. Add metering later and their extra usage is sent but not charged until you move them to the new prices.
- Yearly plans are invoiced yearly. The allowance resets monthly, but the extra units are charged on the next invoice, at the end of the year.
- One event per use adds up. At millions of uses a month, price in bulk rather than one by one, or count per batch.
Test it
With Stripe’s test mode and stripe listen running, use up the allowance on a test workspace, go one past it, and check three things: the database has one billable row, the meter in Stripe’s dashboard shows one unit for that customer, and running the report job again changes neither.
This is how SaaS Stack does usage billing, for Stripe and for Polar, with tests for the limit under concurrent requests, a report Stripe could not take, and a repeated report. The usage billing post shows it in the app.
All of this, already built
SaaS Stack comes with this done and tested, along with auth, teams, billing, emails, a phone app and deploy. Click through it in the live demo.