Adding Stripe Payments to a Chrome Extension: The Architecture That Passes Review
How to monetize a chrome extension with Stripe: checkout-session redirects, webhook-written entitlements, server-enforced trials, and the storage-mirror pattern for instant UI.
TL;DR: You can’t embed Stripe in an extension: Stripe.js is remote code (banned in MV3) and secret keys can’t live in a public bundle. The architecture that works: extension → your backend creates a Checkout Session → the session URL opens in a normal browser tab → Stripe calls your webhook → the webhook is the only writer of entitlements → the extension mirrors the entitlement into
chrome.storageand UI flips instantly. Prices resolve server-side bylookup_key; trials are enforced server-side; the client never decides anything purchasable.
Monetizing a Chrome extension with Stripe is not like monetizing a web app, for two structural reasons, and once you accept them, the right architecture falls out almost by itself.
Why you can’t just drop in Stripe.js
- MV3 bans remotely hosted code. Loading
https://js.stripe.com/v3inside the extension violates the remote-code rules and your extension’s CSP. There is no compliant way to run Stripe Elements or embedded Checkout inside extension pages. - Extensions are public clients. Anyone can unzip your bundle from the store. A Stripe secret key (or any logic that decides “is this user paid?”) that ships in the bundle is compromised on day one.
There’s also a policy backdrop: Google retired the Chrome Web Store’s own payments system in 2021, so a third-party processor is the sanctioned way to charge, provided your listing discloses paid features and the core functionality you advertise stays usable.
The architecture, end to end
This is the flow ExtensionStart ships; every box exists because one of the constraints above forces it:
popup/sidepanel UI ── "billingCheckout" message ──▶ background
background ── POST /billing/checkout (with the user's ID token) ──▶ backend
backend ──▶ creates a Stripe Checkout Session ──▶ URL opens in a browser tab
user pays on stripe.com (a normal page; Stripe.js is fine THERE)
Stripe ── webhook ──▶ POST /billing/webhook
webhook ──▶ writes customers/{uid} in Firestore (the ONLY writer)
└─▶ mirrors `paid` into Firebase custom claims
background Firestore listener ──▶ chrome.storage.local.entitlements
useEntitlement("paid") flips in every surface, no reload
The extension never talks to Stripe and never holds a secret. Let’s walk the load-bearing parts.
1. Checkout is a redirect, not an embed
The backend creates a Checkout
Session and returns its URL; the
extension opens it in a tab. The critical detail is attribution: the
session carries the user’s uid (as client_reference_id and metadata) so the
webhook can later credit the right account:
// backend/functions/src/billing/stripe-gateway.ts (trimmed)
const session = await stripe.checkout.sessions.create({
mode: params.mode, // "subscription" | "payment"
line_items: [{ price: params.priceId, quantity: 1 }],
client_reference_id: params.clientReferenceId, // the Firebase uid
success_url: params.successUrl,
cancel_url: params.cancelUrl,
metadata: params.metadata,
});
Note what the client sent to get here: a plan name, nothing else.
2. Prices resolve by lookup_key, server-side
The extension never sends a price ID or an amount; a tampered client asking for “premium for $0.01” must be impossible by construction. The backend maps a plan name to a Stripe price via lookup keys:
async findPriceByLookupKey(lookupKey) {
const prices = await stripe.prices.list({ lookup_keys: [lookupKey], limit: 1, active: true });
const price = prices.data[0];
return price ? { id: price.id, type: price.type } : null;
},
Bonus: you can change amounts in the Stripe dashboard freely; the code refers
to stable keys (premium_monthly, premium_yearly, premium_lifetime), not
IDs or numbers.
3. The webhook is the only writer of entitlements
Payment truth arrives via webhooks
(checkout.session.completed, customer.subscription.updated/deleted,
charge.refunded), with the signature verified before anything else. The
handler is the single writer of the entitlement record; Firestore security
rules deny all client writes to customers/{uid}. Three production details
that separate a demo from something you can trust:
- Idempotency: Stripe retries deliveries. Record each processed event ID
and short-circuit replays (
{"outcome":"duplicate"}). - Out-of-order events: retries also reorder. Ignore any event older than
the record’s
updatedAt. - Derived
paid: never set the boolean directly; derive it from status so one function owns the definition, includingpast_dueas a grace period:
// backend/functions/src/billing/entitlements.ts (trimmed)
const PAID_STATUSES = new Set(["trialing", "active", "past_due", "lifetime"]);
export function applyDelta(current: EntitlementDoc, delta: EntitlementDelta): EntitlementDoc {
if (delta.occurredAt < current.updatedAt) return current; // stale event
const next = { ...current, ...delta.patch, updatedAt: delta.occurredAt };
next.paid = isPaidStatus(next.status);
return next;
}
4. Trials are server-enforced
BILLING_TRIAL_DAYS adds trial_period_days to the subscription, with
trial_settings.end_behavior.missing_payment_method: "cancel" so card-less
trials expire instead of converting. The once-per-user rule lives on the
entitlement doc: the webhook sets trialUsed when a trial starts, and later
checkout requests omit the trial server-side. A reinstall gets a fresh
extension but not a fresh server record.
5. The storage mirror makes UI instant
The extension still needs to feel immediate. The background subscribes to
customers/{uid} and mirrors each snapshot into
chrome.storage.local.entitlements, the single writer on the client side.
Every React surface reads that mirror through one hook:
import { useEntitlement } from "@extensionstart/core-billing/react";
const { entitled, loading } = useEntitlement("paid");
When the webhook lands mid-checkout, the Firestore listener fires, storage
updates, and useEntitlement flips in the popup, side panel, and content
scripts simultaneously: no reload, no polling. And to be precise about roles:
useEntitlement renders UI. The feature is protected by the server records
above; an attacker who edits their local copy changes pixels, not entitlements.
Review-safety checklist
Payments interact with Chrome Web Store policy more than any other subsystem:
- Core listed functionality works without paying (or the listing clearly says a subscription is required; hiding it reads as bait-and-switch).
- Paywalls are dismissible and never appear as an un-passable wall at install; review accounts are fresh installs.
- Checkout happens on stripe.com in a tab: no cloned payment forms inside extension UI, no credential collection under your own chrome.
- No remote code anywhere in the bundle; remote data (config, prices for display) is fine.
Build vs. borrow
Everything above (checkout/portal/webhook routes, idempotent entitlement
application with the stale-event guard, server-enforced trials, the Firestore
listener and storage mirror, useEntitlement, plus a doctor command that
verifies the whole chain) ships wired together in ExtensionStart, with a
contract-test suite that runs the same lifecycle against a real Stripe test
clock. If you build it yourself, build it in this order and skip none of the
three webhook details; they’re the ones that bite in production.
Frequently asked questions
Can I embed Stripe Checkout inside a chrome extension popup?
No. Stripe.js is remotely hosted code, which Manifest V3 forbids loading inside the extension, and your secret key can never ship in a bundle anyone can unzip. The working pattern is a redirect: your backend creates a Checkout Session and the extension opens the session URL in a normal browser tab.
Does the Chrome Web Store allow Stripe payments in extensions?
Yes. Google retired the Chrome Web Store's own payments system, so third-party processors like Stripe are the standard route. You must comply with the store's policies: disclose that features are paid, keep the listed core functionality usable, and never inject payment UI deceptively.
How does the extension know the user has paid?
Through your server, never through the client. Stripe calls your webhook, the webhook writes an entitlement record keyed by the user's uid, and the extension's background listens to that record and mirrors it into chrome.storage. UI components read the storage snapshot and flip instantly.
Can't users just edit the extension code to unlock premium features?
They can edit what their copy displays, but not what they're entitled to. Client state is UX; the authority is a server-side record that clients cannot write (Firestore rules deny it), checked again by the server for anything that matters. Never ship a client-trusted purchasable check.
How do I stop users from getting a free trial twice?
Enforce it server-side. When a trial starts, the webhook records it on the user's entitlement document; later checkout requests consult that flag and omit the trial. A reinstall or a client-side edit can't reset a flag the client can't write.
Do I need my own backend server to sell a chrome extension subscription?
Yes; something must hold the Stripe secret key and receive webhooks. It doesn't need to be big: a single serverless function (Cloud Functions, Lambda, or an edge function) that creates checkout sessions and processes webhook events is enough.