Skip to content
Get the kit

Usage credits

This guide sells a subscription with a monthly credit allowance plus top-up packs. It enables the model, creates the Stripe products, and puts your first feature on the meter.

Pick it in the setup wizard (or credits-only):

Terminal window
pnpm create extstart --billing-model hybrid-credits

hybrid-credits is subscription + allowance + packs; credits-only sells packs without a subscription. Both default the paywall recipe to credits, which lists credit features in paywall.config.ts and raises a dismissible top-up wall the moment the balance runs short.

pnpm seed:stripe (part of the payments step) creates premium_metered_monthly (a subscription with a 1,000-credit monthly allowance) and the packs credits_pack_small / credits_pack_large.

Credit amounts live in Stripe price metadata: credits on packs, monthly_credits on the metered plan. The server copies them into checkout metadata, so the webhook can grant without an extra API call. The client never supplies an amount.

List the feature with its cost, then use the same access call as every other feature:

apps/extension/paywall.config.ts
features: { summarize: { credits: 1 }, "ai-image": { credits: 5 } },
const { allowed } = await sendMessage("access", { feature: "summarize" });
if (!allowed) return; // out of credits (top-up wall is up) or offline; stop
// … do the metered work …

The engine consumes before the work runs: the background attaches the ID token, calls POST /credits/consume, and mirrors the fresh balance into storage.local.entitlements. The verdict’s remaining is the new balance; <UsageMeter /> shows it. A credit feature needs a balance on every plan: an account that never bought credits has 0 and gets the top-up wall. Use "pro" or "free" for features that should not be metered.

buy a pack checkout (lookup_key) → webhook grant (+N, idempotent by event id)
monthly renewal invoice.paid → allowance reset (packs kept + fresh allowance)
run a feature access message → POST /credits/consume
→ one transaction: decrement + ledger entry
→ 402 when exhausted → the top-up wall (reason "credits")
refunded pack charge.refunded → credits clawed back (clamped at 0)

Consuming spends the allowance first. Packs roll over forever; unused allowance is replaced, not stacked, on each invoice.paid. The allowance lasts while the subscription is in good standing: when it is canceled, unpaid or paused, the remaining allowance goes and pack credits stay. A renewal the provider is still retrying (past_due) keeps it, and the next paid renewal brings it back.

Offline consumes are denied, not queued; an offline queue would be a client-side free-usage lever. Grants are webhook-written only, and /credits/consume can only lower a balance. Each consume writes a deterministic ledger entry, so a retried request replays its recorded outcome instead of double-spending.

The spendable balance lives in your database (customers/{uid} in Firestore, the customers table on Supabase: creditsRemaining, creditsAllowance, server-only packCreditsRemaining), not in Stripe: the extension needs a synchronous “can this feature run” answer, and Stripe’s credit primitives are invoicing-oriented. Stripe stays the payment rail.

  • Firestore → customers/{uid}: the three credit fields, written by the webhook and /credits/consume only. Rules deny all client writes, including the credit_ledger subcollection. On Supabase, the same fields sit in the customers table, and row level security denies client writes to it and to credit_ledger.
  • GET /credits/balance (Bearer token) → { balance, allowance }.
  • pnpm run doctor verifies that every lookup key site.config.ts sells is on sale with its credit amount, and that the webhook subscribes to invoice.paid.
  • STRIPE_SECRET_KEY=sk_test_… pnpm test:lifecycle runs a real test-clock scenario: the first invoice grants the allowance, a simulated month later the renewal resets it, packs survive.

Show <UsageMeter /> for any metered plan and never gate the balance UI itself; users must always be able to see what they are spending.