Toolaby

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, and toolaby.d.ts, its types.
  • The Toolaby popup, toolaby-popup.html and toolaby-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.

WXTCRXJS with manifest.config.tsPlasmo
You pasteNothingLines for manifest.config.ts, vite.config.ts and an existing backgroundmanifest in package.json, and lines for an existing background
The Toolaby popupIn front of your popupIn front of your popupNone: your popup draws the paywall
The account barAdded by the moduleThe tag, by handThe tag, by hand
Built folder.output/chrome-mv3distbuild/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, the storage permission and Toolaby's address under externally_connectable into 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.js says accountBar: false;
  • copies the Toolaby popup, its side panel, toolaby.js and toolaby.config.js into 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:

entrypoints/background.ts
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:

entrypoints/popup/main.ts
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-mv3

WXT 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({ … }):

manifest.config.ts
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:

vite.config.ts
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:

src/background.ts
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:

src/popup/main.ts
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 dist

Plasmo

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:

package.json
"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:

popup.tsx
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 IndexPopup

At 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-prod

Check 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 pack

WXT 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

On this page