Payments
The kit includes a full payments and subscription system that works with three payment providers. It handles checkout, webhooks, the customer portal, trials, and entitlements, all through one provider-agnostic port, so your extension code stays the same whichever provider you use.
How a purchase becomes a feature the user can see:
- Popup or side panel
billingCheckout messageBackground - Background
POST /billing/checkoutYour API - Your API
opens in a new tabProvider checkout - Provider
webhook · POST /billing/webhookYour API - Webhook handler
writesEntitlement, server-sidethe only writer - Background listener
mirrors intostorage.local.entitlements - useEntitlement('paid')
flips in every surfaceno reloadFeature unlocked
The extension never talks to the provider and never holds a secret. Who
paid is recorded server-side as an entitlement, written only by the
webhook. Prices resolve by the kit’s lookup keys on the server, so the
client never sends a price ID or amount, and trials are enforced
server-side by trialUsed.
Set up a payment provider
Section titled “Set up a payment provider”The accounts are yours. You sign up for Stripe, Polar, or Paddle, and you create the Firebase project or Supabase project the backend deploys to; the kit is the code that wires them together, not a hosted service. Those providers bill you on their own terms, separately from the one-time price of the kit: the payment provider takes a cut of every sale you make, and Firebase and Supabase are free to start and metered past that. Their pricing pages are the current numbers.
Pick one in the wizard (--payments stripe|polar|paddle). Your clone
keeps that provider, and Stripe always ships as the default; the wizard
removes the other providers’ adapters, seed scripts, and pages, so the
billing code you read is the code you run. One PAYMENT_PROVIDER env
selects the adapter per deploy, and only that provider’s secrets are
needed. The wizard’s
payments step connects each one in
test mode, ending in a checkout that flips the popup to Pro. What differs:
- Stripe: the default. You are the merchant: most control, sales tax is yours (Stripe Tax can automate it).
- Polar: merchant of record. Polar owns global sales tax. Hosted checkout, built-in trial-abuse prevention.
- Paddle: merchant of record. Paddle owns global sales tax, PCI, and chargebacks. Cardless trials.
Switch providers
Section titled “Switch providers”Stripe always stays in your clone, and so does the provider you picked,
so moving between them is configuration: seed the catalog, store the
provider’s two secrets, and set PAYMENT_PROVIDER. The payments step
does all three:
pnpm create extstart --payments-setup --payments stripeA provider the wizard removed comes back into your clone first, from the kit repository you cloned. Copy its own files:
- Polar:
backend/payments/polar/(its seed andmodule.json), thenbackend/functions/src/billing/polar.tsandbackend/functions/test/polar-contract.test.tson Firebase, orbackend/supabase/functions/api/core/polar.tson Supabase. - Paddle:
backend/payments/paddle/(its seed andmodule.json),apps/site/src/pages/pay.astro, thenbackend/functions/src/billing/paddle.tsandbackend/functions/test/paddle-contract.test.tson Firebase, orbackend/supabase/functions/api/core/paddle.tson Supabase.
Then restore its lines in the shared files. In the kit each one sits in
a module:payments-polar (or module:payments-paddle) marker block, so
a search for the module name finds them all: the provider switch in
provider.ts, the doctor scripts, the content script’s
excludeMatches, and on Paddle the paddle block of
apps/site/src/site.ts. On Firebase, add its seed:polar or
seed:paddle script back to backend/functions/package.json too. Run
pnpm typecheck and pnpm test, then the payments step above with the
new provider.
Choose a billing model
Section titled “Choose a billing model”The wizard’s “Billing model” question decides what customers buy. It
writes the plans array in site.config.ts (the plans your picker offers;
the amounts come from your provider, see below) and tells you which lookup
keys your model sells.
Headless: --billing-model <name>.
| model | what customers buy | paywall recipe it defaults to |
|---|---|---|
subscription | monthly, yearly, and lifetime plans (the default) | freemium |
lifetime-only | one one-time purchase, no recurring billing | freemium |
hybrid-credits | a subscription with a monthly credit allowance, plus top-up packs | credits |
credits-only | prepaid credit packs, no subscription | credits |
free | nothing: the wizard removes checkout, the plans, and the paywall | none |
The billing model and the paywall recipe are separate questions because
they answer different things: the model is what you sell, the recipe is
what stays free before someone pays and when the paywall shows. A
lifetime-only product can be freemium, free-trial, daily-limit, paid-only,
or manual. The credit models pair with the credits recipe because their
wall comes from the server-side balance rather than a client-side nudge.
Every plan needs a matching product in your provider; the seed scripts
create all of them, and unused ones are harmless.
Prices come from your provider
Section titled “Prices come from your provider”The extension never holds an amount. site.config.ts → pricing.plans
names the plans (a lookup key and a display name each); the amount,
currency, and billing period the picker shows come from
GET /billing/prices, a public route that lists every purchasable price
by lookup key. The background caches the answer in storage.local.prices
on install and on browser start, and refreshes it when the picker opens
and the cache is older than an hour. What follows from that:
- Changing a price is a provider-side change. No extension release, no store review: every install shows the new amount within an hour of its next picker open. In Stripe an amount is immutable, so create the new price and move the lookup key to it, then archive the old one. Existing subscriptions keep the price they signed up at.
- A plan that cannot be bought shows as unavailable in the picker
instead of failing at checkout, and checkout answers
409 plan unavailableif it is tried anyway. The usual cause is an archived product behind a live price: the price keeps its lookup key, the dashboard’s Active filter hides it, and only checkout complains.pnpm run doctornames it;pnpm seed:stripereactivates it. - One subscription per account. While an account holds a paid plan
(a trial, an active or past-due subscription, or Lifetime), checkout
for another subscription answers
409 already subscribed; plans change in the customer portal. Credit packs and Lifetime can still be bought. - Copy stays yours.
noteandctaon a plan override the derived line (“Billed monthly”, “Buy 500 credits”) and the button label. The amount cannot be overridden. - The refund line is yours too.
pricing.guaranteeis the sentence under the checkout button (“Not satisfied? Get a full refund within 30 days” by default). Write your own policy there, or leave it empty to show no line.
Moving a Stripe lookup key to a new price, with the Stripe CLI:
stripe prices create -d product=prod_… -d currency=usd -d unit_amount=599 -d "recurring[interval]=month" -d lookup_key=premium_monthly -d transfer_lookup_key=trueThen archive the old price in the dashboard. Polar and Paddle let you change the amount on the existing product or price in their dashboards; the metadata or custom-data key is what must stay.
Working with payments
Section titled “Working with payments”- Set up the paywall: what is free, what is on trial, when to ask, and how to stay inside store policy.
- Usage credits: credit allowances and top-up packs on the same rails.
Show who paid in your UI
Section titled “Show who paid in your UI”To show or hide something by plan, ask the paywall: a running no-card trial is Pro too, and only the paywall snapshot knows about it.
import { useFeature, usePlan } from "@extensionstart/paywall/react";
const { plan } = usePlan(); // "free" | "pro", trials includedconst { locked } = useFeature("export-pdf");For billing itself (who paid, the subscription state):
import { useEntitlement } from "@extensionstart/core-billing/react";
const { entitled, loading } = useEntitlement("paid"); // paid or a card trial, never a no-card trialAlso available: useEntitlements() (the whole snapshot, credit balance
included) and useBillingState()
(free/trial/active/past_due/cancel_pending/lifetime). All read the
background-written storage snapshot; no component talks to the database
or the billing API directly.
useEntitlement renders UI. The feature itself is protected by the
server: the database denies all client writes to entitlements, so
editing extension code changes what a copy displays, never what it is
entitled to.
Verify: the popup flips to premium
Section titled “Verify: the popup flips to premium”- In the popup, trigger the paywall (the Try a premium feature demo button, or your own Pro feature).
- The wall shows your plans. Pick one and click its checkout button. Your provider’s checkout opens in a tab.
- Pay with the test card
4242 4242 4242 4242(any future expiry/CVC; all three providers accept it in test mode). - Back in the popup: the wall clears and the Account tab shows your plan, no reload needed.
Under the hood, in order of depth:
- The entitlement record for your user carries
paid,plan,status,cancelAtPeriodEnd,currentPeriodEnd,customerId,updatedAt. - Processed webhook events are marked once; replaying one returns
{"outcome":"duplicate"}and changes nothing. - In the extension: service-worker console →
await chrome.storage.local.get("entitlements"). - Your provider’s dashboard shows each webhook delivery and the
backend’s response:
applied,duplicate, orignored. - On the Firebase stack,
pnpm run doctornames any broken link in the chain, on every provider.
Automated suites
Section titled “Automated suites”pnpm testpnpm test:rulesSTRIPE_SECRET_KEY=sk_test_… pnpm test:lifecycletest is the unit and adapter contract suite (offline). test:rules
runs the Firestore rules against the emulator (needs Java).
test:lifecycle runs a real Stripe test-clock lifecycle (about 3
minutes).
The billing core is port-based: every provider implements the same
adapter contract and passes the same canonical suite: subscription
lifecycle, trials, refunds, credits, replay, and out-of-order webhooks;
see backend/functions/test/adapter-contract.ts.
Frequently asked questions
Section titled “Frequently asked questions”Which payment providers does the kit support?
Section titled “Which payment providers does the kit support?”Stripe, Polar, and Paddle, each behind the same adapter contract and the same tests. Stripe is the default and always ships; your clone keeps the provider you pick in the wizard alongside it.
Can I switch providers later?
Section titled “Can I switch providers later?”Yes. The catalog seeds are idempotent scripts per provider, and the
adapter is selected by one environment variable; switching is seeding
the new catalog, setting two secrets, and changing PAYMENT_PROVIDER.
A provider the wizard removed needs its files back from the kit first:
Switch providers lists them.
Existing subscriptions do not migrate between providers; switch before
launch, or run the old provider for existing customers.
Where do entitlements actually live?
Section titled “Where do entitlements actually live?”In your backend’s database (Firestore or Postgres), written only by the provider’s webhook, mirrored into a storage snapshot the extension reads. The extension can never grant itself paid features.