Payments
This guide sets up Stripe end to end: products, webhook, one deploy, and UI that knows who paid. How money flows:
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 webhook.
Prices resolve by lookup_key on the server, so the client never sends a
price ID or amount, and trials are enforced server-side by trialUsed.
One-time setup (test mode)
Section titled “One-time setup (test mode)”Prerequisites: the Firebase CLI and your project on the Blaze plan (Firebase setup).
Run everything from backend/functions/, after
firebase use <your-project-id>. No Stripe CLI needed; the scripts drive
the Stripe API directly.
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 products/prices by lookup_key (idempotent)pnpm stripe:webhook # registers the webhook + stores the signing secretpnpm firebase:deploy # ONE deploy: the api function + rules, with both secretspnpm doctor # verifies the whole chain end to endKeep the order: stripe:webhook computes the function URL from your
project ID, so the webhook and its secret exist before the first deploy
binds them. What each step does:
seed:stripecreates the catalog by lookup key:premium_monthly,premium_yearly,premium_lifetime,premium_metered_monthly, and the packscredits_pack_small/credits_pack_large. Change amounts freely in the Stripe dashboard; the backend resolves bylookup_keyonly. The keys must matchsite.config.ts → pricing.plans.stripe:webhookregisters…/api/billing/webhookwith the events the backend handles and stores the signing secret. Rerunning is a safe no-op.doctorchecks project, secrets, deployed API, webhook, and seeded prices, and points at the fix for anything red. Run it whenever billing misbehaves.
Non-secret knobs live in backend/functions/.env:
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 CTA advertises one number,
Stripe enforces the other, and pnpm doctor flags a mismatch.
If you dropped the billing module, set BILLING_DISABLED=true here
instead: the deploy skips the Stripe secrets and /billing/* answers
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”import { useEntitlement } from "@extensionstart/core-billing/react";
const { entitled, loading } = useEntitlement("paid");Also available: useCredits() (balance, allowance, exhausted) and
useBillingState() (free/trial/active/past_due/cancel_pending/lifetime).
All read the background-written storage snapshot; no component talks to
Firestore or the billing API directly.
useEntitlement renders UI. The feature itself is protected by the
server: Firestore rules deny all client writes to customers/{uid}, so
editing extension code changes what a copy displays, never what it is
entitled to.
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. 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 and the backend’s
response:
applied,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 (~3 min)The billing core is port-based: an alternative provider implements the
same adapter contract and must pass the same suite; see
backend/functions/test/adapter-contract.ts.