Skip to content

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:

Terminal window
pnpm create extstart --billing-model hybrid-credits # or credits-only

hybrid-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.

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.

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.

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 wall
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. 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.

  • 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.
  • GET /credits/balance (Bearer token) → { balance, allowance }.
  • pnpm doctor verifies the lookup keys referenced by site.config.ts exist with credit metadata, 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 the CreditMeter for any metered plan and never gate the balance UI itself; users must always be able to see what they are spending.