Website JavaScript
Read the buyer's account and sell from your own code with window.Toolaby, in plain JavaScript, React or Next.js.
toolaby-web.js defines window.Toolaby: the buyer's account as state you can read and subscribe to, and the actions the website components perform, as methods.
This page is the reference for window.Toolaby, then React and Next.js. It assumes the script is on the page; see Your website.
window.Toolaby
window.Toolaby exists once the script has run. The toolaby:ready event on window fires when the first answer arrives; event.detail is Toolaby.state.
window.addEventListener('toolaby:ready', () => {
const { signedIn, email, tools } = Toolaby.state;
if (signedIn && tools['word-count']) showProFeatures();
});| Member | Returns | |
|---|---|---|
Toolaby.state | object | The last answer: ready, mode (cookie, proxy, dev or link), signedIn, email, name, image, and tools, where tools[id] is true for each tool the buyer holds, under both of its ids: the short one and the full one. |
Toolaby.onChange(fn) | function | Calls fn(state) now and after every change. Returns a function that stops it. |
Toolaby.refresh() | Promise<state> | Asks Toolaby again. |
Toolaby.entitlements() | Promise<object> | { signedIn, email, name, image, tools }, straight from Toolaby. |
Toolaby.session() | Promise<object | null> | The signed-in account, or null. On your domain, the session as the sign-in library returns it ({ session, user }). Through a proxy or in development the page reaches only the site's endpoints, so it is { user: { email, name, image } }. |
Toolaby.signIn({ email, next }) | Promise | With email, on your domain, sends a sign-in link to it. Without, or through a proxy or in development, opens the sign-in page. next is where the buyer lands; the default is this page. |
Toolaby.signInUrl(next) | string | The sign-in page's address, returning to next or this page. |
Toolaby.accountUrl() | string | The account page's address. |
Toolaby.signOut() | Promise | Signs the buyer out, then refreshes. |
Toolaby.checkout({ tool, plan, email, ext, seats, feature, web }) | Promise | Opens Stripe Checkout for the tool. seats buys a team licence or subscription on a plan sold per seat. feature is what opened your paywall, for Analytics. web: true counts the purchase as your web app's; a page that checked in has it already. A subscription signed out signs in first and continues. |
Toolaby.startTrial({ tool, ext }) | Promise | Starts the tool's free trial for the signed-in buyer, then opens the after-purchase page. The trial lasts as long as the longest trial a plan offers and unlocks that plan's features: see Website components. Signed out, signs in first and continues. Refused, it rejects with the reason: no trial, an unverified or a disposable address. |
Toolaby.portal({ subscriptionId }) | Promise | Opens the Stripe customer portal. |
Toolaby.token({ tool }) | Promise<string | null> | A site token for your own server: it names the signed-in buyer, the tool and this page. Lasts five minutes and is reused until shortly before it ends. null signed out. See Your server. |
Toolaby.checkIn({ tool, version }) | Promise<boolean> | Counts the signed-in buyer's use of your web app in Analytics: their day, and version, dotted numbers such as the day the app was deployed. Call it when a page of the app opens. false signed out, or when it was not taken. |
Toolaby.paywallShown({ tool, opened }) | Promise<boolean> | Counts one view of your web app's paywall, by what opened it: a feature's key, _limit or _paid. |
Toolaby.purchase() | Promise<object> | On your after-purchase page: { status, tool, email, amount, inAccount, devices } for the purchase Stripe returned the buyer from. devices is how many devices the plan bought allows. |
Toolaby.origin is where the script sends its requests: your workspace's address, or your proxy's path on your own domain.
A method that fails rejects with an Error whose message is fit to show, and status is the HTTP status when there was one. With no connection (mode is link), the methods that need the session reject; checkout() and startTrial() open the checkout page, and portal() the account page, instead.
Selling from code
document.querySelector('#buy').addEventListener('click', async (event) => {
event.target.disabled = true;
try {
await Toolaby.checkout({ tool: 'word-count', plan: 'yearly' }); // the plan's id, as its card under Pricing shows it
} catch (e) {
event.target.disabled = false;
showError(e.message);
}
});checkout() sends the page to Stripe Checkout. A failure rejects with the reason; a subscription asked for signed out goes to the sign-in page first and continues on return. After payment the buyer lands on the after-purchase page. Toolaby's own signs the extension that sent them (ext) in to the account the purchase is on: at once, if the buyer paid signed in, or through its sign-in, if they paid signed out. On your own after-purchase page (Your own pages), the extension unlocks when the buyer signs in inside it.
React
Read the state with a hook:
'use client';
import { useEffect, useState } from 'react';
type ToolabyState = { ready: boolean; mode: 'cookie' | 'proxy' | 'dev' | 'link' | null; signedIn: boolean; email: string | null; name: string | null; image: string | null; tools: Record<string, true> };
declare global { interface Window { Toolaby?: { state: ToolabyState; onChange(fn: (s: ToolabyState) => void): () => void; checkout(o: { tool: string; plan?: string; ext?: string; seats?: number }): Promise<unknown>; startTrial(o: { tool: string; ext?: string }): Promise<unknown>; signInUrl(next?: string): string; accountUrl(): string } } }
const initial: ToolabyState = { ready: false, mode: null, signedIn: false, email: null, name: null, image: null, tools: {} };
export function useToolaby(): ToolabyState {
const [state, setState] = useState<ToolabyState>(initial);
useEffect(() => {
let stop = () => {};
const attach = () => { stop = window.Toolaby!.onChange(setState); };
if (window.Toolaby) attach(); else window.addEventListener('toolaby:ready', attach, { once: true });
return () => { stop(); window.removeEventListener('toolaby:ready', attach); };
}, []);
return state;
}Render nothing for the signed-in part until ready is true: before it, the state is unknown.
export function ProBadge() {
const { ready, tools } = useToolaby();
if (!ready || !tools['word-count']) return null;
return <span className="badge">Pro</span>;
}The account button works in JSX as it is. For TypeScript, declare it once:
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'toolaby-user': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & { 'sign-in-url'?: string; 'account-url'?: string; 'after-sign-out-url'?: string; 'sign-in-label'?: string; theme?: 'light' | 'dark'; align?: 'start' | 'end' };
}
}
}Next.js
Load the script once, in the root layout:
import Script from 'next/script';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Script src="https://accounts.example.com/client/toolaby-web.js" strategy="afterInteractive" />
</body>
</html>
);
}The buyer's account is known in the browser, not on your server: useToolaby() belongs in client components. A server component renders the signed-out shape and the client fills it in. Through a proxy, add the route the dashboard shows as app/__toolaby/[...path]/route.ts and load the script from /__toolaby/client/toolaby-web.js.
To decide on your server who holds a tool — to render private content, or to answer an API of your own — ask Toolaby from the server, with an API key: see Sync customers. For the buyer making a request, use a site token: see Your server.
Your server
A site token tells your server which buyer sent a request, and, with your workspace's secret key, lets it count that buyer's uses of your tool. Use it for a web app that does its work on your server: it can check who is asking, unlock a buyer, and count free uses as your extension counts them.
Your extension can send the same server a token too, with siteToken(): the buyer signed in on that device. An extension that does its work through your server spends the same count as the page, and an edited copy has no count of its own to skip.
1. Send a token with each request
const token = await Toolaby.token({ tool: 'invoices' });
if (!token) return Toolaby.signIn(); // signed out
await fetch('/api/export', { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body });2. Check it on your server
The token is an ES256 JWT signed with your workspace's key. Check it with the keys Toolaby publishes for the tool, which follow every key rotation. With jose:
import { createRemoteJWKSet, jwtVerify } from 'jose';
const keys = createRemoteJWKSet(new URL('https://northwind.toolaby.app/api/tools/northwind--invoices/keys'));
const { payload } = await jwtVerify(token, keys, { algorithms: ['ES256'], audience: 'https://www.northwind.example' });
if (payload.use !== 'site' || payload.tool !== 'northwind--invoices') throw new Error('Not a site token for this tool');A token your extension sent names it in azp. Check it against the request's Origin header, and keep what only that device may use bound to its ins:
const fromExtension = typeof payload.azp === 'string';
if (fromExtension && request.headers.get('origin') !== payload.azp) throw new Error('Not this extension');| Claim | Type | Description |
|---|---|---|
use | 'site' | Always site. A licence token never has it. |
sub | string | The buyer's account id in your workspace. |
tool | string | The tool's full id. |
aud | string | The origin of the page the token was made for. |
ent | boolean | Whether the buyer held the tool when the token was made: bought, subscribed, on a trial or granted. |
feat | string[] | The features the buyer's plan holds. Absent when the plan holds everything, or the buyer holds nothing. |
azp | string | Made for an extension: its origin, chrome-extension://<id>. Absent in a page's token. |
ins | string | Made for an extension: the device's install id, 64 hex characters. Absent in a page's token. |
exp | number | When it ends, five minutes after iat. |
An extension's token carries ent: false on a device past the account's device limit, whatever the account holds.
3. Count uses
To count free uses, your server asks Toolaby with your workspace's secret key and the buyer's token. Toolaby reads what the buyer holds again, and counts nothing for a buyer who holds the tool. Create the key under Configure → API, or with npx -y toolaby@latest api-keys create --env-file .env, and keep it on your server.
POST https://northwind.toolaby.app/api/v1/tools/northwind--invoices/uses
Authorization: Bearer <your secret key, wk_live_…>
Toolaby-Site-Token: <the buyer's site token>
Idempotency-Key: <made by your server from what it counts>
Content-Type: application/json
{ "count": 12 }| Parameter | Type | Description |
|---|---|---|
count | integer | Uses to count, 1 to 1,000. Refused whole when fewer remain. |
Idempotency-Key | header | Optional. Names one action: make it on your server from what that action counts, such as a hash of the items, and never pass along a key the browser sends as it is. A retried request with the same key and count answers what the first did and counts nothing more, for 24 hours; the same key with another count is refused. A refusal counts nothing and is not kept: the same key asks again, so a buyer who pays in between is let through. |
{ "object": "usage", "allowed": true, "entitled": false, "used": 3, "remaining": 7, "limit": 10, "per": "ever" }| Field | Description |
|---|---|
allowed | Whether to do the work. |
entitled | The buyer holds the tool: nothing was counted. unlimited is true beside it. |
used, remaining, limit | The free uses counted and left in the period, and how many it gives. |
per | The period: ever, day, week or month. |
window, resetsAt | For a day, week or month: when the period began and when it ends, ISO 8601, UTC. |
reason | When refused: limit (the free uses are spent) or paid (the tool has no free uses). |
sharedUses | true when the address is disposable or unverified: such accounts share one count. |
deviceLimit | { allowed, held }, for an extension's token: the account holds the tool, and this device is past its device limit. Its uses are counted as free ones. |
To read the count without counting, as a page or an extension opens, ask with GET. The answer has the same fields; allowed says whether one use would be.
GET https://northwind.toolaby.app/api/v1/tools/northwind--invoices/uses
Authorization: Bearer <your secret key>
Toolaby-Site-Token: <the buyer's site token>Pass the answer to your extension's noteUses() so its account line says what is left.
Free uses are counted per account: by the account's inbox, in the tool's period, on the same count your extension uses when the tool requires an account. Errors are the API's (API): 401 for a missing or wrong key, or a site token that is missing, changed, ended or another tool's; 400 for a count out of range, or idempotency_error for an Idempotency-Key first used with another count; 409 while a request with the same Idempotency-Key is being answered; 429 when a buyer asks too fast.
A key that comes from the browser unchanged lets anyone replay one counted action for other items: Toolaby answers it as the first time and counts nothing. Make the key from the action itself, for example the browser's key and the items' ids hashed together, so that other items are another action.
The secret key is what makes a count your server's. A site token is made for your page, and anything that runs there could read it: a tag manager's script, an extension, a log. Without the key, it cannot count anyone's uses at Toolaby. It can still reach your own server as the buyer for five minutes, so keep third-party scripts off the pages that use it.
Moving from /api/me/uses. Until 23 October 2026 your server counted with the site token alone, at POST and GET /api/me/uses. That route still answers, with Deprecation and Sunset headers, until 1 December 2026, and is refused after it. Send the same requests to /api/v1/tools/{tool}/uses with your secret key, and the token as Toolaby-Site-Token.