Skip to content

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 ──▶ background
background ── POST /billing/checkout ──▶ Cloud Function
Cloud Function ──▶ Stripe Checkout opens in a tab
Stripe ── webhook ──▶ POST /billing/webhook
webhook ──▶ writes Firestore customers/{uid} (the ONLY writer)
└─▶ mirrors `paid` into custom claims
background Firestore listener ──▶ storage.local.entitlements
useEntitlement('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.

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.

Terminal window
cp .env.example .env # non-secret knobs (return URLs, trial days); edit it
firebase 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 secret
pnpm firebase:deploy # ONE deploy: the api function + rules, with both secrets
pnpm doctor # verifies the whole chain end to end

Keep 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:stripe creates the catalog by lookup key: premium_monthly, premium_yearly, premium_lifetime, premium_metered_monthly, and the packs credits_pack_small / credits_pack_large. Change amounts freely in the Stripe dashboard; the backend resolves by lookup_key only. The keys must match site.config.ts → pricing.plans.
  • stripe:webhook registers …/api/billing/webhook with the events the backend handles and stores the signing secret. Rerunning is a safe no-op.
  • doctor checks 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:

Terminal window
WXT_API_URL=https://us-central1-<project>.cloudfunctions.net/api
VITE_PREMIUM=true
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.

4242 4242 4242 4242 (success), 4000 0000 0000 9995 (declined), 4000 0027 6000 3184 (3DS challenge). Any future expiry/CVC.

Terminal window
# backend/functions/.secret.local (gitignored)
STRIPE_SECRET_KEY=sk_test_…
STRIPE_WEBHOOK_SECRET=whsec_… # printed by `pnpm stripe:listen` on start
Terminal window
pnpm serve # functions + firestore + auth emulators
pnpm stripe:listen # forwards test-mode events to the emulated webhook

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

  • 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, or ignored.
Terminal window
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.