Payments
Set up Stripe end to end: products, webhook, one deploy, and UI that knows who paid. How money flows through the kit:
popup/sidepanel UI ── billingCheckout message ──▶ backgroundbackground ── POST /billing/checkout ──▶ Cloud FunctionCloud Function ──▶ Stripe Checkout opens in a tabStripe ── webhook ──▶ POST /billing/webhookwebhook ──▶ writes Firestore customers/{uid} (the ONLY writer) └─▶ mirrors `paid` into custom claimsbackground Firestore listener ──▶ storage.local.entitlementsuseEntitlement('paid') flips in every surface (no reload)The extension never talks to Stripe and never holds a secret. Who paid is recorded server-side as an entitlement, written only by the Stripe webhook. Trials are trialUsed-enforced server-side, and prices resolve by lookup_key. The client never sends a price ID or amount.
One-time setup (test mode)
Section titled “One-time setup (test mode)”You’ll need the Firebase CLI and your project on the Blaze plan; see setting up your Firebase project.
Run everything from backend/functions/, after firebase use <your-project-id>. No Stripe CLI needed: the scripts drive the Stripe API directly, reading the key from Secret Manager.
cp .env.example .env # non-secret knobs (return URLs, trial days); edit itfirebase functions:secrets:set STRIPE_SECRET_KEY # sk_test_… (the only Stripe input)pnpm seed:stripe # creates the template products/prices by lookup_key (idempotent)pnpm stripe:webhook # registers the webhook endpoint + stores the signing secretpnpm firebase:deploy # ONE deploy: the api function + rules, with both secretspnpm doctor # verifies the whole chain end-to-endThe order matters. stripe:webhook computes the function URL deterministically from your project id (https://us-central1-<project>.cloudfunctions.net/api), so the webhook and its signing secret exist before the first deploy. That one deploy binds both secrets. Deploying earlier makes the Firebase CLI prompt for the missing secret.
What each step does:
seed:stripecreates the template catalog by lookup key:premium_monthly,premium_yearly,premium_lifetime,premium_metered_monthly, and the top-up packscredits_pack_small/credits_pack_large. Change amounts freely in the Stripe dashboard; the backend resolves prices bylookup_keyonly. The keys must matchapps/extension/site.config.ts→pricing.plans; unused products are harmless.stripe:webhookregisters the endpoint at…/api/billing/webhookwith the events the backend handles (checkout.session.completed,customer.subscription.created/updated/deleted,invoice.paid,charge.refunded) and stores the signing secret. Rerunning is a safe no-op.doctorchecks project, secrets, deployed API, webhook registration, and seeded prices, and points at the fix for anything red. Run it after setup or whenever billing misbehaves.
Non-secret knobs are plain env on the function, read from backend/functions/.env (gitignored; that’s why you copy .env.example first): BILLING_SUCCESS_URL, BILLING_CANCEL_URL, BILLING_PORTAL_RETURN_URL, BILLING_TRIAL_DAYS, BILLING_AUTOMATIC_TAX, BILLING_ALLOW_PROMO_CODES. Keep BILLING_TRIAL_DAYS in sync with site.config.ts → pricing.trialDays: the extension’s CTA advertises one number, Stripe enforces the other, and pnpm doctor flags a mismatch.
If you dropped the billing module in the wizard, set BILLING_DISABLED=true in backend/functions/.env instead of any of the above: the deploy skips the Stripe secret bindings entirely and the /billing/* routes answer 501.
Finally, point the extension at your backend in apps/extension/.env:
WXT_API_URL=https://us-central1-<project>.cloudfunctions.net/apiVITE_PREMIUM=trueShow who paid in your UI
Section titled “Show who paid in your UI”The standard check, available in every React surface:
import { useEntitlement } from "@extensionstart/core-billing/react";
const { entitled, loading } = useEntitlement("paid");Also available: useCredits() (balance + allowance + exhausted, all null when credits aren’t in use) and useBillingState() (the states-matrix key: free/trial/active/past_due/cancel_pending/lifetime). All are useSyncExternalStore views of the background-written storage snapshot. No component ever talks to Firestore or the billing API directly.
The credit-allowance model with top-up packs has its own page: credits.
Test cards
Section titled “Test cards”4242 4242 4242 4242 (success), 4000 0000 0000 9995 (declined), 4000 0027 6000 3184 (3DS challenge). Any future expiry/CVC.
Local dev loop (emulators)
Section titled “Local dev loop (emulators)”# backend/functions/.secret.local (gitignored)STRIPE_SECRET_KEY=sk_test_…STRIPE_WEBHOOK_SECRET=whsec_… # printed by `pnpm stripe:listen` on startpnpm serve # functions + firestore + auth emulatorspnpm stripe:listen # forwards test-mode events to the emulated webhookPoint WXT_API_URL at http://127.0.0.1:5001/<project>/us-central1/api while testing locally. Prefer real Checkout sessions with test cards over stripe trigger; they exercise the uid-metadata path end to end.
Verify the setup
Section titled “Verify the setup”- Firestore →
customers/{uid}:paid,plan,status,cancelAtPeriodEnd,currentPeriodEnd,customerId,updatedAt, written only by the server. billing_events/{eventId}: one marker per processed event. Replaying a webhook returns{"outcome":"duplicate"}and changes nothing.- In the extension: service-worker console →
await chrome.storage.local.get("entitlements"). - Stripe dashboard → Webhooks shows each delivery plus the backend’s JSON response:
{"outcome":"applied","uids":[…]},duplicate, orignored.
Automated suites
Section titled “Automated suites”pnpm test # unit + adapter contract suite (offline)pnpm test:rules # Firestore rules against the emulator (needs Java)STRIPE_SECRET_KEY=sk_test_… pnpm test:lifecycle # real Stripe test-clock lifecycle: trial → active → # cancel → canceled (~3 min)The billing core is port-based: an alternative provider (Polar, Paddle, Chargebee) implements the same adapter contract and must pass the same suite; see backend/functions/test/adapter-contract.ts.
