Client
The methods of toolaby.js, what they return, and whether they make a request.
toolaby.js is the client the wiring writes beside your code, typed by toolaby.d.ts. It has fifteen methods and two events. Everything it answers comes from a token Toolaby signed for this device and the client verified with the workspace's public key; a method marked no request answers from the device alone.
import { toolaby } from './toolaby.js';The same import works in the background service worker, the popup, a side panel and a content script. In the background the methods run; elsewhere they are forwarded to the background over chrome.runtime.sendMessage and answer the same.
toolaby.config.js
The whole configuration, written by the wiring beside toolaby.js.
| Field | |
|---|---|
key | The tool key: tk_test_… while you try it, tk_live_… when it ships, tk_dev_… on a development server. It carries the platform's address, the tool, the public key your tokens are verified against, and what the Free plan held when it was written — what a new install shows until Toolaby's first word, never what it decides a use by. It works only from this extension's id. From the tool's Set up page, or npx -y toolaby@latest wire. |
appPopup | Your own popup, which the platform's opens on to once the device may. '' means none stands in front — either you have no popup, or your popup draws the panel itself. upgrade keeps it as it is. |
appSidePanel | Your own side panel, the same way. See Surfaces. |
countOpens | Whether opening the Toolaby popup spends a free use. false where your code calls toolaby.gate() at the paid action: the popup then opens yours at once unless the person may not use the extension yet. true where your code calls no gate(), so the open is the use, except in an extension moved from ExtensionPay, whose ExtPay calls say what is paid: there it is always false. wire and upgrade write it from your code; absent, as in a file written before 3 October 2026, it is true. |
uninstallPage | Whether Toolaby sets the page Chrome opens once the extension is removed (Analytics). true: Toolaby sets it while the tool's uninstall page is on, and clears only one it set. false, where your code calls chrome.runtime.setUninstallURL() itself, or absent, as in a file written before 16 October 2026: Toolaby never sets or clears it. wire and upgrade write it from your code. |
manifestKey | The extension's identity — the key the manifest carries, so every copy has the same id, which the tool key is bound to. Made once by the platform, or adopted from a manifest that already had one. Keep it; changing it changes the extension's id. |
accountBar | Whether the account bar sits at the foot of your popup and side panel. true: in a plain extension wire and upgrade write it into your pages, between Toolaby's markers; in a WXT project the module docks it as it builds. false keeps it out, and the next upgrade takes Toolaby's block out of your pages. upgrade keeps what you set; absent, it is true. |
Swapping the key for the live one is the whole of shipping to the store; nothing else in the file changes. See Test and Live.
startBackground()
startBackground(options?: BackgroundOptions): voidStarts the client in the service worker: the message listeners the other contexts use, the handoffs from the sign-in and checkout pages, and the version check. Call it once, synchronously, at the top level of background.js. Throws outside the background.
| Option | Type | |
|---|---|---|
handlers | Record<string, (message, sender) => unknown> | Your { action } messages, by action name. The return value is the reply. |
externalHandlers | same | Messages from web pages (externally_connectable). |
claimUnknown | boolean, default true | false leaves actions you do not handle to another onMessage listener of yours. |
skip | string[] | Startup checks to skip, by name. |
contentScriptStorage | boolean, default false | true lets your content scripts read and write chrome.storage.local. By default only the extension's pages and its background can, since the account link and the licence key are kept there: a content script asks the background for what it needs. Chrome 102 and later; other browsers are unchanged. |
contentScriptActions | boolean, default false | true lets your content scripts ask for the account's actions: activateLicense, signOut, resendLicense, listActivations, deactivate, capability. By default only the extension's own pages can (the popup, the side panel, an options page), since a content script runs in the web page's own process; asked from anywhere else, each answers { ok: false }. Reading (getUser, has, policy) and gate() are open to content scripts either way. |
The sign-in and purchase handoffs are taken only from a page of Toolaby's own address, never from another extension.
toolaby.startBackground({
handlers: {
exportSheet: async ({ tab }) => {
const permit = await toolaby.gate();
if (!permit.allowed) return permit;
return exportTableFrom(tab);
},
},
});A handler runs inside the message that called it. An API that needs the click that sent the message, such as chrome.sidePanel.open(), works when the handler calls it before its first await.
ensureBackground()
ensureBackground(): voidstartBackground() unless something already called it. The Toolaby background the wiring puts in front of yours (toolaby.background.js), and the WXT module it writes (modules/toolaby.wxt.ts), call this after your own code has run, and so does ExtPay's startBackground() once switched. A background that calls startBackground({ handlers }) itself keeps its handlers, and one that never mentions Toolaby still starts it. When ensureBackground() ran first, as when ExtPay starts in a module your background imports, your own startBackground() takes over with its options. Not something you call.
getUser()
getUser(): Promise<User>What the device knows now. No request. user.token says when the answer was issued. See User.
getPlans()
getPlans(): Promise<ToolPlan[]>The plans the tool sells, in the order Pricing shows them: each a Plan with its tier and its trialDays. Asks Toolaby at most once a minute, also when the service worker stops and starts again in between; a page of the extension asks its background. Rejects when Toolaby cannot be reached, in every context.
refresh()
refresh(): Promise<User>Asks Toolaby, then answers as getUser() does. Call it when the popup opens, not on every action. Offline, answers what the device has.
| Case | Request |
|---|---|
| Toolaby was asked under five minutes ago on Live, or ten seconds ago on Test, whatever it answered: unpaid, or nothing at all | none |
| The person has been to the checkout page, the sign-in page or the account page since | one |
| Otherwise | one |
A purchase, a sign-in and a licence key are asked about when they happen, not at the next refresh(). Signing in as someone else, signing out or putting a licence key in forgets Toolaby's last answer, so the five minutes never hold one account to what Toolaby said about another.
gate()
gate(arg?: number | { count?: number; feature?: string }): Promise<Permit>May this device do the thing, now? Call it before the action, not when the popup opens.
| Argument | |
|---|---|
count | Uses to spend: a whole number from 1 to 1000. Default 1. |
feature | A feature key from Pricing. |
| Case | Answer | Request |
|---|---|---|
| A paid plan, trial or grant | { allowed: true, isPremium: true, unlimited: true } | none |
| A feature the plan does not hold | { allowed: false, reason: 'feature', feature, needs }. needs lists the ids of the plans that sell it. | none on the Free plan; one on a paid plan, in case it changed |
| Free plan, uses remaining | { allowed: true, isPremium: false, remaining, limit, used } | one, to count the use |
| Free plan, uses spent | { allowed: false, reason: 'limit', remaining: 0, limit } | none |
| Free plan requires sign-in, or holds nothing | { allowed: false, reason: 'sign_in' } or 'paid' | none |
A count that is not a whole number from 1 to 1000 | { allowed: false, reason: 'count' }. Nothing is spent, online or off. | none |
| Toolaby refuses the request: an extension that is not the tool's, too many at once, a device it does not know | { allowed: false, reason: 'refused' }. Only when Toolaby does not answer does the device count on its own. An empty 429, Toolaby's word for a device past its own pace, is no answer: the device counts on its own. | one |
| An answer that allows a paid plan's use without a token Toolaby signed for this device, or with one whose plan does not hold the feature | { allowed: false, reason: 'refused' }. The device unlocks nothing. | one |
| An answer with no token that allows a use the Free plan does not give, by Toolaby's latest signed word: a feature a plan sells, a use past the free uses, or any use before Toolaby's first word | { allowed: false, reason: 'refused' }. The device counts nothing. | one, then the version check before refusing (on Live, at most every five minutes) |
| Build below the minimum version | { allowed: false, blocked: true, message } | none |
| A new install that has not heard from Toolaby yet | The first call waits for Toolaby's word, a few seconds at most, then answers as above. Offline: { allowed: false, reason: 'limit', offline: true, unknown: true }: nothing is given on a guess. | one |
Offline, the device counts and answers with offline: true. See Permit.
has()
has(feature: string): Promise<boolean>Whether the device holds the feature: always for a Free plan feature, otherwise when the last token lists it. A tool with no features holds everything. No request, no use spent.
For display — the button drawn, greyed or badged. It stops nothing by itself: enforce a feature with gate({ feature }) at the action.
signOut()
signOut(): Promise<void>Signs this device out and fires onChange. Its link is revoked on the account, so the account page no longer lists the device, and the entitlement and any site tokens go from the device. The account is untouched. Offline, the device signs out and the link lapses on its own.
siteToken()
siteToken(options: { audience: string; fresh?: boolean }): Promise<string | null>A site token for one of your own pages: the buyer signed in on this device, for that page's server. Use it when your extension does its work through your web app's server: the server checks who is asking and counts the buyer's uses with the token, as it does for the page. See Your server.
| Argument | |
|---|---|
audience | The origin of one of your own pages, exactly as listed under Your own pages: https://app.example.com. The token's aud. |
fresh | true asks Toolaby again whatever is kept: for a server that refused the token it was sent. Default false. |
| Case | Answer | Request |
|---|---|---|
| Signed in, a token kept with more than 30 seconds left | the token | none |
| Signed in | a new token, five minutes long | one, signed by the device |
| No account signed in on this device, or its link was revoked | null | one, or none when no link is kept |
An audience that is not a listed page | throws, with Toolaby's message | one |
| Toolaby does not answer | throws | one |
The token carries a page token's claims and two more: azp, this extension's origin (chrome-extension://<id>), and ins, this device's install id. Toolaby reads what the account holds at each token; a device past the account's device limit gets ent: false. Signing out or signing in as someone else drops every token kept. Not from a content script.
noteUses()
noteUses(usage: UsesAnswer): Promise<boolean>Takes Toolaby's answer from /api/v1/tools/{tool}/uses, as your server passed it on, into this device's meter: getUser().usage and the account line then say what is left. Call it after each answer your server sends back from a count, or from a GET of the same route when the popup opens.
| Argument | |
|---|---|
usage | The answer as Toolaby gave it: { object: 'usage', used, per, window, … }. |
Returns true when the meter took it. The meter only ever counts more uses, never fewer, and takes an answer only for a tool that requires an account, with one signed in, in the period Toolaby last signed. It gives no use: what is allowed stays your server's answer. Not from a content script.
versionState()
versionState(options?: { fresh?: boolean }): Promise<VersionState>The version check's last answer, kept across the service worker's restarts. { fresh: true } asks Toolaby again: on Test at every call, on Live when the answer is five minutes old or more. See VersionState.
openPaymentPage()
openPaymentPage(planId?: string): Promise<void>Opens the checkout page in a tab, with the plan preselected when named. A purchase is on the buyer's account, and the device holds it through the account the extension is signed in to. After payment, Toolaby's after-purchase page signs the extension in to that account at once, if the buyer paid signed in. Bought signed out, the page's one button is the sign-in that does it, with the address the purchase was made with. onPaid fires when the device unlocks.
openLoginPage()
openLoginPage(provider?: 'google'): Promise<void>Opens the sign-in page. Signed in, the page hands the session to the extension. With 'google' — the popup's Continue with Google — the page goes straight to Google instead of asking again. Google sign-in is on for every workspace, through Toolaby's Google client or your own from Configure → Sign-in.
openAccountPage()
openAccountPage(): Promise<void>Opens the buyer's account page.
showPaywall()
showPaywall(permit: Permit | { feature: string; needs?: string[] }): Promise<void>Answers a refused permit with the paywall, from your popup or your side panel. The page gives way to the paywall for that reason, at that surface's width, in your brand and the buyer's language. A Back button returns to the page. Once the device may continue (bought, signed in, the feature held), it returns there by itself. Showing it spends nothing.
const permit = await toolaby.gate({ feature: 'export' });
if (!permit.allowed) return toolaby.showPaywall(permit);| Permit | The paywall shows |
|---|---|
reason: 'feature' | Export is in Pro, and only the plans that hold the feature, needs first. |
reason: 'limit' | The free-use meter, then the plans. |
reason: 'paid' | The plans. |
reason: 'sign_in' | The sign-in doors. |
deviceLimit set | In use on 2 devices already, and the way to the account page, where the buyer signs another device out. |
blocked: true | Your message from Versions. |
allowed: true | Nothing: it returns without showing anything. |
{ feature: 'export' } on its own asks for a feature's plans without calling gate() first — for a button drawn locked because has() said no.
The paywall opens in the Toolaby popup's own page, toolaby-popup.html or toolaby-sidepanel.html, which wire and create put beside every popup and side panel. An extension without it — a build that left it out — gets the checkout page in a new tab instead, never a missing page. A side panel of your own says it is one with <html data-toolaby-surface="sidepanel">, or the paywall opens at popup width.
From a page of the extension open in a tab, such as an editor or a report, navigating away would lose its work. There it opens the checkout page in a new tab instead: the sign-in page for sign_in, nothing for blocked. A content script, whose page is not the extension's, does the same. The purchase reaches the extension as any other does: onPaid fires, and has() answers yes at the page's next call. In the background it throws, since there is no page to show it in: return the permit to the page and call it there. See Gate your extension.
onPaid, onChange
onPaid: { addListener(cb: (user: User) => void): void; removeListener(cb): void }
onChange: { addListener(cb: (user: User) => void): void; removeListener(cb): void }Background only. onPaid fires when the device goes from unpaid to paid. onChange fires on every change of user.paid. Neither fires for the first observation after the worker wakes. A popup reads getUser() when it opens instead.
ExtensionPay's calls
toolaby.js also exports ExtPay, which answers ExtensionPay's calls (getUser(), openPaymentPage(), onPaid and the rest) in ExtensionPay's shapes, for an extension moving from it. See Move from ExtensionPay.
Errors
No method throws on a network failure; each answers from the device and marks the answer offline where it matters. startBackground() throws outside the background; Toolaby({ key }) throws at startup when key is not a tool key. A method called from a page rejects with Toolaby is not running in this extension's background when nothing started it there. See Troubleshoot an extension.