CRXKit

Payments working in ten minutes

The licensing, the three store builds and the review paperwork are done. You fill in one file, then put your feature where the placeholder is.

lib/config.ts
export const PROVIDER_CONFIG = {
  active: 'polar',                          // or 'lemonsqueezy'

  polar: {
    organizationId: 'YOUR_POLAR_ORGANIZATION_ID',
    checkoutUrl: 'https://polar.sh/YOUR_ORG/YOUR_PRODUCT',
    licenseKeyPrefix: '',
  },
};

Three values and a provider name. Nothing else in the kit needs editing to take money.

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.

WhatWhy
Node 20 or newerWXT builds on it. CI runs 22.
A Polar or Lemon Squeezy accountIssues the license keys the extension checks.
Chrome, Edge or FirefoxTo 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.

  1. Install and run

    npm install
    npm run dev

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

  2. 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-XXXXXXXXXXXX and 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.

  3. Fill in lib/config.ts

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

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

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

SituationPlan
No key storedfree
Past the provider's own expiryfree, immediately
Checked within 24 hourspro
Stale, still inside the 14-day grace windowpro, flagged as on grace
Grace expired and 3 consecutive checks failedfree
Provider says revoked or refundedfree, 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 zips

Builds 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 pick manifest.json.

Before you submit

Five edits and one declaration. Everything here is something a reviewer or a buyer will notice if you skip it.

FileChange
public/_locales/*/messages.jsonName and description. The toolbar tooltip, popup header and options title all read from here.
public/icon/*.pngYour icons, all five sizes.
wxt.config.tsThe Firefox gecko.id, based on a domain you own.
wxt.config.tsdata_collection_permissions — see below.
entrypoints/content.tsmatches: 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-mv3

AMO 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=high

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

WhatWhen
Dependency pull requestsMondays, minor and patch grouped into one
Action version pull requestsMonthly
Type-check, three builds, AMO lint, store zipsEvery 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.