Skip to content
Get the kit

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:

  1. Popup or side panelbillingCheckout messageBackground
  2. BackgroundPOST /billing/checkoutYour API
  3. Your APIopens in a new tabProvider checkout
  4. Providerwebhook · POST /billing/webhookYour API
  5. Webhook handlerwritesEntitlement, server-sidethe only writer
  6. Background listenermirrors intostorage.local.entitlements
  7. useEntitlement('paid')flips in every surfaceno reloadFeature unlocked
extensionyour serverpayment providerThe extension never decides who paid. The server does.

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.

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.

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:

Terminal window
pnpm create extstart --payments-setup --payments stripe

A 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 and module.json), then backend/functions/src/billing/polar.ts and backend/functions/test/polar-contract.test.ts on Firebase, or backend/supabase/functions/api/core/polar.ts on Supabase.
  • Paddle: backend/payments/paddle/ (its seed and module.json), apps/site/src/pages/pay.astro, then backend/functions/src/billing/paddle.ts and backend/functions/test/paddle-contract.test.ts on Firebase, or backend/supabase/functions/api/core/paddle.ts on 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.

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

modelwhat customers buypaywall recipe it defaults to
subscriptionmonthly, yearly, and lifetime plans (the default)freemium
lifetime-onlyone one-time purchase, no recurring billingfreemium
hybrid-creditsa subscription with a monthly credit allowance, plus top-up packscredits
credits-onlyprepaid credit packs, no subscriptioncredits
freenothing: the wizard removes checkout, the plans, and the paywallnone

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.

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 unavailable if 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 doctor names it; pnpm seed:stripe reactivates 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. note and cta on 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.guarantee is 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:

Terminal window
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=true

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

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

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 included
const { 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 trial

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

  1. In the popup, trigger the paywall (the Try a premium feature demo button, or your own Pro feature).
  2. The wall shows your plans. Pick one and click its checkout button. Your provider’s checkout opens in a tab.
  3. Pay with the test card 4242 4242 4242 4242 (any future expiry/CVC; all three providers accept it in test mode).
  4. 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, or ignored.
  • On the Firebase stack, pnpm run doctor names any broken link in the chain, on every provider.
Terminal window
pnpm test
pnpm test:rules
STRIPE_SECRET_KEY=sk_test_… pnpm test:lifecycle

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

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.

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.

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.