Skip to content

Charge for usage

A plan can include so many units of something each month, such as AI messages, and charge for each unit beyond. The assistant is the example: Pro includes 500 messages a month, then 2¢ each. It works the same with Stripe and Polar. Putting a whole feature on some plans is Charge for a feature.

In packages/contracts/src/billing.ts, name the meter and put it on the plans:

export const METERS = ['aiMessages', 'exports'] as const;
export const METER_LABELS: Record<MeterId, { name: string; unit: string; units: string }> = {
// ...
exports: { name: 'Exports', unit: 'export', units: 'exports' },
};
pro: {
// ...
usage: { exports: { included: 100, pricePerUnit: 5 } }, // 5¢ each beyond 100
},
  • included is per month, on yearly plans too.
  • pricePerUnit is in cents, and can be a fraction: 0.5 is half a cent. null stops the workspace at included until the next month instead.
  • On the free plan there is nobody to charge, so it always stops at included.
  • A plan without the meter does not limit it.

Then run bun run billing:sync. It creates the meter at the provider and a metered price beside each plan’s price. Checkout adds that price to new subscriptions.

Call UsageService where the work happens, before doing it. Any module can inject it:

await this.usage.record(workspace.id, 'exports');
  • Past an allowance that cannot be exceeded it throws a 402, with a sentence saying when more are available, and records nothing. Calling it first means a refusal costs you nothing.
  • quantity records more than one unit: { quantity: rows.length }.
  • key makes it safe to repeat, for work that may run twice, such as a job: pass export:<its id>. Never take the key from the client, or people can choose not to be counted.
  • Inside a transaction, the units count only if it commits.

The kit declares one more meter, apiCalls, on no plan. Put it on a plan’s usage and every request made with an API key counts as one, recorded by the workspace guard once the request is let through. Requests from people signed in are not counted. Past an allowance that cannot be exceeded, keys get 402 until the next month.

pro: {
// ...
usage: { aiMessages: { included: 500, pricePerUnit: 2 }, apiCalls: { included: 100_000, pricePerUnit: 0.01 } },
},

Each request is a row in usage_events, and each one beyond the allowance a job, so price them in bulk rather than one by one if your customers make millions.

Usage is counted in the usage_events table, so the allowance and the 402 are decided by the API and are the same with either provider. Only the units beyond the allowance are sent to the provider. Each is sent by a job with the row’s id, which the provider uses to drop a repeat. If the provider cannot be reached, the job tries again for about a day, so no usage is lost.

The provider adds them to the next invoice. On a yearly plan that is at the end of the year.

GET /api/workspaces/:workspaceId/usage answers with this month’s usage of each meter on the plan. Any member can read it. The web app shows it on the overview, the billing page and the assistant (UsageMeters in features/billing/usage.tsx), and the mobile app on its overview and assistant. usageAllowances(plan) in the contracts puts “100 exports a month, then $0.05 each” on the pricing page and the plan picker.

Give the test its own allowances, then record and read:

const t = await createTestApp({
teams: false,
plans: { free: { usage: { exports: { included: 2, pricePerUnit: null } } } },
});
const usage = t.app.get(UsageService);
await usage.record(workspaceId, 'exports', { quantity: 2 });
expect(usage.record(workspaceId, 'exports')).rejects.toMatchObject({ status: 402 });

With stripe: new FakeStripe() and a paid plan, run the queue with t.app.get(JobsService).drain() and read what was charged from stripe.meterEvents; with Polar, polar.ingested. See apps/api/test/usage.e2e.test.ts.

billing:sync replaces a metered price as it does a plan’s. People already subscribed keep the prices they bought. A subscription from before you added a meter has no metered price: the units beyond its allowance are counted and sent, but not charged. Move it to the new prices in Stripe or Polar to charge for them.