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:
pnpm create extstart --billing-model hybrid-credits # or credits-onlyhybrid-credits is subscription + allowance + packs; credits-only
sells packs without a subscription. Both default the gate preset to
metered, which raises a dismissible top-up wall the
moment the balance hits 0.
Seed the metered prices
Section titled “Seed the metered prices”pnpm seed:stripe (part of the
billing setup) 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”Consume first, then work:
const result = await sendMessage("creditsConsume", { feature: "summarize" });if (!result.ok) return; // exhausted (top-up wall is up) or offline; stop// … do the metered work …The background attaches the ID token, calls POST /credits/consume, and
mirrors the fresh balance into storage.local.entitlements. Accounts
without credits in play get ok: true, so instrumented features run free
under the other billing models.
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 creditsConsume message → POST /credits/consume → Firestore transaction: decrement + ledger entry → 402 when exhausted → the metered preset raises the top-up wallrefunded 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. Cancelling
the subscription drops the remaining allowance but keeps pack credits.
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 Firestore (customers/{uid}:
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. GET /credits/balance(Bearer token) →{ balance, allowance }.pnpm doctorverifies the lookup keys referenced bysite.config.tsexist with credit metadata, 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 the CreditMeter for any metered plan and never gate the balance UI itself; users must always be able to see what they are spending.