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.
Enable the model
Section titled “Enable the model”Pick it in the setup wizard (or credits-only):
pnpm create extstart --billing-model hybrid-creditshybrid-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.
Seed the metered prices
Section titled “Seed the metered prices”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.
Meter a feature
Section titled “Meter a feature”List the feature with its cost, then use the same access call as every
other feature:
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.
How credits move
Section titled “How credits move”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.
Test it
Section titled “Test it”- Firestore →
customers/{uid}: the three credit fields, written by the webhook and/credits/consumeonly. Rules deny all client writes, including thecredit_ledgersubcollection. On Supabase, the same fields sit in thecustomerstable, and row level security denies client writes to it and tocredit_ledger. GET /credits/balance(Bearer token) →{ balance, allowance }.pnpm run doctorverifies that every lookup keysite.config.tssells is on sale with its credit amount, and that the webhook subscribes toinvoice.paid.STRIPE_SECRET_KEY=sk_test_… pnpm test:lifecycleruns 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.