Before you start
Two accounts and one runtime. Ten minutes assumes you already have a payment account; creating one takes longer than the code does.
| What | Why |
|---|---|
| Node 20 or newer | WXT builds on it. CI runs 22. |
| A Polar or Lemon Squeezy account | Issues the license keys the extension checks. |
| Chrome, Edge or Firefox | To load the development build. |
Store developer accounts can wait until you are ready to publish: Chrome charges $5 once, Edge and Firefox are free.
Ten-minute setup
Do these in order. At the end you will have activated a real license key against a real payment provider.
Pick your provider — this page follows the one you choose, and it is also the value of
active in the config file.
-
Install and run
npm install npm run devChrome opens with the extension loaded. The popup shows a FREE badge and a counter reading 0/10. That counter is the free tier, and it is real — it is already wired to storage.
-
Create the product and its license keys
Create the product and enable licensing
In Polar, create a product, then add the License Keys benefit to it. If you set a prefix on that benefit, note it down — issued keys will look like
MYAPP-XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXXand the kit needs to know.Copy two things: your organization ID from Settings, and the checkout link from the product page.
In Lemon Squeezy, create a product and turn on license keys for its variant. Copy the Buy Now link for that variant.
Lemon Squeezy validates keys against your store automatically, so there is no organization ID to copy.
-
Fill in
lib/config.tsexport const PROVIDER_CONFIG = { active: 'polar', polar: { organizationId: 'org_xxxxxxxxxxxxxxxx', checkoutUrl: 'https://polar.sh/acme/pro', licenseKeyPrefix: 'ACME', // leave '' if you set no prefix }, };export const PROVIDER_CONFIG = { active: 'lemonsqueezy', lemonsqueezy: { checkoutUrl: 'https://acme.lemonsqueezy.com/buy/9f2c...', }, };The prefix matters more than it looks. The kit checks the shape of a key before spending a network request, and a prefix it does not expect makes a genuine key fail instantly.
-
Activate a real key
Buy your own product, or issue yourself a key from the provider's dashboard. Open the extension's options page, paste the key, and press Activate.
The badge turns PRO and the quota meter disappears. That is the whole loop working: checkout, key, validation, plan.
-
Put your feature in
The popup's "Run action" button is a placeholder that consumes one unit of quota and reports back. Replace its body with your product, and keep the two lines around it — that is the gate.
Gating a feature
One call, before the work. It returns false when a free user is out of quota and true for everyone on Pro.
import { consume } from '@/lib/quota';
async function exportReport() {
if (!(await consume(1))) return showUpgrade();
// ... the paid work
}From a content script
A content script shares a process with the page, which puts it outside the trust boundary. Ask the background script instead of deciding locally:
const { allowed } = await browser.runtime.sendMessage({ type: 'quota:consume', n: 1 });
if (!allowed) return;The background script answers three messages: license:state,
quota:get and quota:consume.
How the plan is decided
currentState() reads the cache and never touches the network, so
any part of the extension can call it as often as it likes.
| Situation | Plan |
|---|---|
| No key stored | free |
| Past the provider's own expiry | free, immediately |
| Checked within 24 hours | pro |
| Stale, still inside the 14-day grace window | pro, flagged as on grace |
| Grace expired and 3 consecutive checks failed | free |
| Provider says revoked or refunded | free, cache cleared |
Why the grace window exists
An extension that silently drops to free on a plane or a subway produces a refund request, and so does one that downgrades during a provider outage. So a failed check is recorded as "can't tell", not "invalid": the cached record survives, a failure counter goes up, and only the combination of an expired window and three consecutive failures downgrades anyone.
Being explicitly revoked is different, and clears the cache on the spot.
Re-checking
An alarm fires every six hours; revalidate() does nothing unless the record is
older than recheckIntervalHours. Alarms rather than timers, because MV3 service
workers are torn down constantly and setInterval dies with them.
All three numbers live in LICENSE_POLICY:
export const LICENSE_POLICY = {
offlineGraceDays: 14,
recheckIntervalHours: 24,
failuresBeforeDowngrade: 3,
};The free tier
A daily counter that resets on its own when the date rolls over. Pro users short-circuit it entirely.
export const FREE_TIER = {
dailyActions: 10,
savedItems: 5,
};dailyActions is what consume() spends. savedItems is
a second limit for you to enforce where your product stores things — the constant is there so
both numbers sit in one place, but nothing counts it for you.
Rename both to match your product. A note-taking extension has notes, not "actions".
Building for three stores
One MV3 codebase. Per-browser differences exist in exactly one place,
wxt.config.ts, and today that is only the Firefox block.
npm run build # Chrome
npm run build:firefox
npm run build:edge
npm run build:all
npm run zip:all # store-ready zipsBuilds land in .output/<browser>-mv3/. The Firefox zip comes with a
sources zip beside it, which AMO asks for whenever the submitted code is minified.
Loading a build by hand
- Chrome and Edge: the extensions page, Developer mode, Load unpacked.
- Firefox:
about:debugging, This Firefox, Load Temporary Add-on, then pickmanifest.json.
Before you submit
Five edits and one declaration. Everything here is something a reviewer or a buyer will notice if you skip it.
| File | Change |
|---|---|
public/_locales/*/messages.json | Name and description. The toolbar tooltip, popup header and options title all read from here. |
public/icon/*.png | Your icons, all five sizes. |
wxt.config.ts | The Firefox gecko.id, based on a domain you own. |
wxt.config.ts | data_collection_permissions — see below. |
entrypoints/content.ts | matches: only the domains you actually need. |
Permissions
The kit asks for storage and alarms, plus host permissions for the
two license APIs. Asking for more than you use is the most common reason a review drags on.
Once you have picked a provider you can delete the other host permission — a switched-off
adapter still leaves its host in the manifest otherwise.
The Firefox data collection declaration
AMO has required data_collection_permissions on new listings since
3 November 2025, and Firefox shows it to the user at install time. As shipped the kit declares
authenticationInfo, which covers the license key travelling to your payment
provider, and nothing else. The email the provider sends back is cached locally and never
transmitted.
data_collection_permissions: { required: ['authenticationInfo'] },If your product sends anything else — analytics, crash reports, page content — declare it.
Telemetry belongs in optional as technicalAndInteraction, which is
the one category Firefox refuses to make required.
The declaration is read from Firefox 140 on, which is why
strict_min_version is 140 rather than the 109 that MV3 itself needs. A key the
browser cannot read is a consent screen the user never sees. Listing on Firefox for Android
needs 142.
Check it the way AMO will
npx web-ext lint --source-dir .output/firefox-mv3AMO rejects on errors, not warnings. A clean run here is worth more than a careful reading of the policy page.
When something goes wrong
These are the failures that actually happen, in the order people hit them.
A genuine key is rejected instantly, with no network request
The shape check ran before the request. On Polar this is almost always
licenseKeyPrefix: you set a prefix on the License Keys benefit but left the
config empty, or the other way round. Make the two match exactly.
The check exists so a typo does not cost a round trip. It is not a security boundary, so when in doubt leave the prefix empty — the kit then accepts any prefix and lets the server decide.
Polar returns 403 or 404 for every key
Wrong organizationId. It is in Settings under General,
and it is not the organization's name or slug.
A paying user was dropped to free
This should take an expired 14-day window and three consecutive failed checks. If it happened faster, the provider explicitly answered "revoked" — a refund, a chargeback, or a key deleted in the dashboard.
Check local:license and local:licenseFailures in extension
storage to see which path it took.
The toolbar tooltip shows the wrong name
WXT builds action.default_title from the popup's
<title>, and a value set in wxt.config.ts is silently
overwritten. The title is __MSG_extName__ so it reads your locale file —
change the name in public/_locales/, not in the HTML.
AMO rejects a new listing
Nearly always the missing data collection declaration, and
occasionally a host permission you no longer use. Run web-ext lint first.
npm audit reports high-severity vulnerabilities
Check whether they ship. The build tooling pulls in an advisory chain through
addons-linter whose only offered fix is a major downgrade of WXT and
web-ext, which is the worse trade. None of it is in the extension.
npm audit --omit=dev --audit-level=highThat is the check CI runs, and it is the one that matters for what you publish.
The service worker seems to forget everything
It should — MV3 tears it down constantly. Keep state in storage, not in module scope, and schedule with alarms rather than timers. The kit already does both.
What this cannot do
License validation in a browser extension runs on the client. Someone determined to bypass it will bypass it.
Obfuscation only costs you debugging time. What protects revenue in practice is convenience and a steady update cadence, not a lock — people who would have paid keep paying, and people who would not were never revenue. If a capability genuinely must not be copied, it belongs on a server, and the license check becomes the thing that authorises the request.
You will discover this in week two. Better to plan around it now.
Staying current
A starter kit that lags a version behind is the first thing buyers notice, so the upkeep runs on a schedule.
| What | When |
|---|---|
| Dependency pull requests | Mondays, minor and patch grouped into one |
| Action version pull requests | Monthly |
| Type-check, three builds, AMO lint, store zips | Every push and pull request |
Nothing merges on its own. CI is what turns a dependency bump into a one-click decision: if it breaks any of the three stores, it fails there instead of in your inbox. Majors for WXT and React arrive as their own pull requests, because those change the build.
Changes that affect you are listed in CHANGELOG.md.