Payments for WXT, Plasmo and CRXJS extensions
Wire a WXT, Plasmo or CRXJS extension to Toolaby, gate the paid action, show the paywall, then build, check and pack it.
This guide adds payments to an extension built with WXT, Plasmo or CRXJS. At the end, the project carries Toolaby's client, its background starts Toolaby, and the paid action shows the paywall when it is refused. Buyers pay through Stripe Checkout, on your Stripe account. You load the built folder in Chrome, check it, and pack the zip the Chrome Web Store takes.
Before you begin
- A tool registered in the dashboard, and its tool id: Tool id on the tool's page, or
npx -y toolaby@latest tools. See the Quickstart. - Node.js 20 or later, for the command line.
- A plan under the tool's Pricing, and Access set to Free uses or Nothing free, to see the paywall.
- Nothing is needed from Stripe on Test: Toolaby makes a Stripe test account for your workspace.
What every framework gets
In the project's folder, run:
npx -y toolaby@latest wire <tool-id>The first run shows a code to approve in your browser. On Test, the command writes the tk_test_… tool key. It writes the files a plain extension gets, where your framework keeps them:
toolaby.js: the client and the paywall, one ES module file. It is not an npm package.toolaby.config.js, which holds the tool key, andtoolaby.d.ts, its types.- The Toolaby popup,
toolaby-popup.htmlandtoolaby-popup.js, and its side panel when you have one.
The command prints one row per change: ✓ done, ! worth a look, → a step for you, with the exact code.
| WXT | CRXJS with manifest.config.ts | Plasmo | |
|---|---|---|---|
| You paste | Nothing | Lines for manifest.config.ts, vite.config.ts and an existing background | manifest in package.json, and lines for an existing background |
| The Toolaby popup | In front of your popup | In front of your popup | None: your popup draws the paywall |
| The account bar | Added by the module | The tag, by hand | The tag, by hand |
| Built folder | .output/chrome-mv3 | dist | build/chrome-mv3-prod |
In these frameworks, content scripts are bundled too, so a content script can import toolaby.js. It calls the same methods, and each asks the background. In a content script, showPaywall() opens the checkout page in a new tab, or the sign-in page for sign_in.
WXT
wire writes the files at the project root, with toolaby.background.js, and a WXT module, modules/toolaby.wxt.ts. WXT loads modules from modules/ by itself. At build time, the module:
- merges the extension's
key, thestoragepermission and Toolaby's address underexternally_connectableinto your manifest; - puts the Toolaby popup in front of your popup, and Toolaby's side panel in front of your side panel;
- starts Toolaby in your background, after your own code has run;
- adds the account bar at the foot of your popup and side panel, unless
toolaby.config.jssaysaccountBar: false; - copies the Toolaby popup, its side panel,
toolaby.jsandtoolaby.config.jsinto the build output.
wxt.config.ts and entrypoints/background.ts stay as you had them. A project with no background gets Toolaby's own, toolaby.background.js.
To gate an action, call startBackground() with your handlers. A call of yours wins over the module's. From the example project, shortened:
import { toolaby } from '../toolaby.js';
export default defineBackground(() => {
toolaby.startBackground({
handlers: {
async countWords() {
const permit = await toolaby.gate();
if (!permit.allowed) return permit;
const words = await countWordsOnPage(); // the work you charge for
return { allowed: true, words };
},
},
});
});The popup asks the background, and answers a refusal with the paywall:
import { toolaby } from '../../toolaby.js';
const out = document.getElementById('out')!;
document.getElementById('count')!.addEventListener('click', async () => {
const r = await chrome.runtime.sendMessage({ action: 'countWords' });
if (!r.allowed) return toolaby.showPaywall(r);
out.textContent = `${r.words} words`;
});The popup's index.html loads it as a module: <script type="module" src="./main.ts"></script>. A side panel page sets <html data-toolaby-surface="sidepanel">, so the paywall opens at the panel's width. Since your code calls gate(), wire writes countOpens: false: opening the popup spends no free use.
Build and check the build, then load .output/chrome-mv3 at chrome://extensions (Developer mode, then Load unpacked). pack finds the same folder by itself.
npm run build
npx -y toolaby@latest check --built .output/chrome-mv3WXT builds every entrypoint, whether or not the manifest names it, so your popup stays built and the Toolaby popup opens it. npx -y toolaby@latest upgrade <tool-id> replaces the module with the current one. The complete example, built in Toolaby's CI, is wxt-word-count.zip.
CRXJS
A CRXJS project with manifest.json at the root is wired as a plain extension. With manifest.config.ts, wire writes the files and toolaby.manifest.js, then prints what to add inside defineManifest({ … }):
import { manifest as toolaby } from './toolaby.manifest.js';
...toolaby,
action: { default_popup: 'toolaby-popup.html' },toolaby.manifest.js derives the extension's id, the storage permission and Toolaby's address from toolaby.config.js. The lines carry no values: swap in the Live key, and the manifest follows. wire remembers your popup as appPopup in toolaby.config.js, and the Toolaby popup opens it once the device may.
CRXJS builds only the pages the manifest names. Keep your own popup built as a Vite input:
export default defineConfig({
plugins: [crx({ manifest })],
build: { rollupOptions: { input: { popup: 'src/popup/index.html' } } },
});With a side panel, the command also prints side_panel: { default_path: 'toolaby-sidepanel.html' } for the manifest, and sidepanel: 'src/sidepanel/index.html' for the inputs.
For the background, it prints two lines for the top of src/background.ts, or writes the file with them when there is none. To gate an action, pass your handlers to the same call:
import { toolaby } from '../toolaby.js';
toolaby.startBackground({
handlers: {
async exportAll() {
const permit = await toolaby.gate();
if (!permit.allowed) return permit;
return { allowed: true, file: await buildExport() };
},
},
});Your popup answers a refusal with the paywall, as in WXT:
import { toolaby } from '../../toolaby.js';
document.getElementById('export')!.addEventListener('click', async () => {
const r = await chrome.runtime.sendMessage({ action: 'exportAll' });
if (!r.allowed) return toolaby.showPaywall(r);
save(r.file);
});Add <toolaby-account></toolaby-account> at the foot of your popup by hand: the tag works on any page that loads toolaby.js. A content security policy of your own needs Toolaby's address. Wrap it as extension_pages: cspWithToolaby("…"), imported from toolaby.manifest.js.
Build and check, then load dist. pack finds it by itself.
npm run build
npx -y toolaby@latest check --built distPlasmo
wire writes the files at the project root. With no background.ts, it creates one with the two lines that start Toolaby, and Plasmo picks it up. With one, it prints the lines to add at its top.
Plasmo's manifest is JSON in package.json, which cannot import, so the printed lines carry the values:
"manifest": {
"key": "<the extension's key, from the printed lines>",
"permissions": ["storage"],
"externally_connectable": { "matches": ["https://<workspace>.toolaby.app/*"] }
}Plasmo's manifest override replaces action whole, so the Toolaby popup cannot stand in front of yours. Your popup decides at the open, as the Toolaby popup does, and draws the paywall with functions from toolaby.js:
import { useEffect, useRef, useState } from "react"
import { toolaby, loadPaywall, applyBrand, renderPaywall, refusalOf } from "./toolaby.js"
function IndexPopup() {
const panel = useRef<HTMLDivElement>(null)
const [open, setOpen] = useState(false)
useEffect(() => {
;(async () => {
const user = await toolaby.refresh()
const mayNot = (!user.paid && user.access === "paid") || (user.signInRequired && !user.account?.linked)
if (!mayNot) return setOpen(true)
const config = await chrome.runtime.sendMessage({ action: "toolaby.config" })
const paywall = await loadPaywall(config)
applyBrand(paywall)
renderPaywall(panel.current!, paywall, user, config, {
buy: (plan) => toolaby.openPaymentPage(plan),
signIn: (provider?: "google") => toolaby.openLoginPage(provider),
account: () => toolaby.openAccountPage()
}, refusalOf(user, null))
})()
}, [])
const run = async () => {
const permit = await toolaby.gate()
if (!permit.allowed) return toolaby.showPaywall(permit)
// your work
}
return open ? <main><button onClick={run}>Do the thing</button></main> : <div ref={panel} />
}
export default IndexPopupAt the open, mayNot is true when nothing is free and no plan is held. It is also true when an account is required and none is signed in. Only then does the paywall show. Opening spends nothing: gate() counts the free use at the work. Plasmo builds no page of Toolaby's, so showPaywall() opens the checkout page in a new tab. Put <toolaby-account> at the foot of your popup for the free uses left and Upgrade.
Build and check, then load build/chrome-mv3-prod. pack finds it by itself.
npm run build
npx -y toolaby@latest check --built build/chrome-mv3-prodCheck and pack the store build
check --built <dir> checks a build: its manifest, its tool key and side, the code its background runs and the scripts its pages load. Without --built, check warns BUILT_NOT_CHECKED, since the framework generates the manifest. Add --browser to load the build in Chromium and ask its background. When a loaded copy runs, step 2 of the tool's Set up page says Checked in.
For the Chrome Web Store, put the Live key in, build, and pack:
npx -y toolaby@latest wire <tool-id> --live --force
npm run build
npx -y toolaby@latest packWXT and CRXJS take the Live address from the key at build time. In Plasmo, replace the manifest in package.json with the lines the command prints. pack finds the built folder and writes <name>-<version>.zip, without the manifest's key. It refuses a build that carries a tk_test_ key, which would sell on Test with test cards.
Next steps
Add a paywall to a Chrome extension
Choose what is free, gate the paid action, show the paywall, sell features and add the account bar to a Chrome extension.
Monetize a Chrome extension
The ways to charge for a Chrome extension, from a lifetime licence or a subscription to free uses, trials, seats and bundles, set up with Toolaby.