API
Look up whether a person holds a tool, grant or revoke access, count a signed-in buyer's uses, and read a tool's Analytics, from your own server.
The API is REST over HTTPS, at your workspace's address. Requests carry an API key as a bearer token; bodies and responses are JSON. The OpenAPI document is at /api/v1/openapi.json, and a generated reference to read at toolaby.app/api/v1/docs.
https://<workspace>.toolaby.app/api/v1On Test, the base is your workspace's Test address with /test, like https://quiet-otter-4172.toolaby.app/test/api/v1. Configure → API shows yours on each side.
Authentication
Create a key under Configure → API in the dashboard. It is shown once. Test keys (wk_test_…) act on Test's data; live keys (wk_live_…) on Live's.
curl https://northwind.toolaby.app/api/v1/tools/invoice-grabber/customers/maya@example.com \
-H "Authorization: Bearer wk_live_…"const res = await fetch('https://northwind.toolaby.app/api/v1/tools/invoice-grabber/customers/maya@example.com', {
headers: { Authorization: `Bearer ${process.env.TOOLABY_API_KEY}` },
});
const customer = await res.json();There is no SDK; the API is nine endpoints, called with fetch. A key acts only on tools in its workspace. A tool in another workspace, or one that does not exist, answers 404.
{tool} in a path is either of the tool's ids: the full one, as Tool id on the tool's page shows it (northwind--invoice-grabber), or the short one (invoice-grabber). Responses name the tool by its full id.
Rate limits
600 requests a minute per key and 1,200 per workspace, all its keys together, in the clock's minute (UTC). Above either, 429 with rate_limit_error.
- List customers: 30 pages a minute per workspace, all its keys together. Each page reads the tool's whole list, and the rows it reads count toward the workspace's 300,000 a day, the dashboard's Customers page with them: ask for 500 a page to read a long list in few. See Limits.
- Retrieve a customer: 20,000 a day per workspace, all its keys together, counted before it reads. List grants is a lookup by address too, counted with it. The day is the 24 hours from the first lookup in it. To know every customer, page through the list; to keep up, use webhooks: see Sync customers.
- Uses: each buyer's asks are counted per minute, and their free uses per day, as an extension's are: see Limits.
- Create a grant: 1,000 a day per tool, counted over the last 24 hours, however the grants were made. Past it, the API and the command line refuse. Each grant is also one of the workspace's 20,000 additions a day, counted before it is written: see Limits. Bring more in with the tool's Import page.
Errors
Every error has the shape:
{ "error": { "type": "invalid_request_error", "message": "days: Too big: expected number to be <=3650", "param": "days" } }| Status | type | |
|---|---|---|
| 400 | invalid_request_error | A parameter is missing or wrong; param names it. |
| 400 | idempotency_error | An Idempotency-Key first used for another count: an action of its own needs a key of its own. |
| 401 | authentication_error | No key, a malformed key, a revoked one, or one whose workspace's owners are suspended. For uses: a site token that is not valid for the tool, or has ended. |
| 403 | plan_limit_error | The workspace's plan does not allow it. On Hobby a workspace holds 100 passes at once: Plans. On Test a workspace holds 100 people, whatever its plan: Test and Live. Analytics in the API, and some of its parts, are on Pro: Analytics. |
| 403 | invalid_request_error | Toolaby switched the tool off. The message says to write to hello@toolaby.app. |
| 404 | not_found | No such tool, grant or person in this workspace. |
| 409 | idempotency_error | A request with the same Idempotency-Key is still being answered. |
| 429 | rate_limit_error | The key's minute is used up, this address sent too many wrong keys, the workspace read 30 pages of customers this minute or its lists' rows for today, it looked up 20,000 customers today, the tool made 1,000 grants in the last 24 hours, or the workspace made its 20,000 additions today. |
| 500 | api_error | Toolaby's fault, or it cannot take additions just now. Retry. |
| 503 | api_error | Toolaby cannot read or count a buyer's uses just now. Retry. |
Endpoints
List customers
GET /tools/{tool}/customers
Everyone who holds the tool or has signed in to it, newest first: the newest 2,000 of each kind, as in the dashboard. A customer beyond them answers Retrieve a customer.
| Parameter | ||
|---|---|---|
tool | path | The tool id. |
limit | query, optional | 1 to 500. Default 50. |
cursor | query, optional | next_cursor from the previous page. |
entitled | query, optional | true: only customers who hold the tool now; false: only those who do not. |
Returns a page.
{
"data": [
{ "email": "maya@example.com", "account": true, "entitled": true, "via": "subscription", "since": "2026-09-18T13:33:55.000Z", "last_active_at": "2026-09-20T08:12:01.000Z", "devices": 2 }
],
"has_more": true,
"next_cursor": "NTA"
}let cursor = null;
do {
const url = new URL('https://northwind.toolaby.app/api/v1/tools/invoice-grabber/customers?limit=500');
if (cursor) url.searchParams.set('cursor', cursor);
const page = await (await fetch(url, { headers: { Authorization: `Bearer ${key}` } })).json();
for (const c of page.data) await upsertCustomer(c);
cursor = page.next_cursor;
} while (cursor);Retrieve a customer
GET /tools/{tool}/customers/{email}
Whether the address holds the tool now, and how. An address with no account still answers for a licence bought under it. Each lookup is one of the workspace's 20,000 a day: see Rate limits.
| Parameter | ||
|---|---|---|
tool | path | The tool id. |
email | path | The address, URL-encoded. Another spelling of a Gmail address, with or without dots, finds the same account. |
Returns a Customer.
{
"email": "maya@example.com",
"account": true,
"entitled": true,
"via": "subscription",
"paid_at": "2026-09-18T09:38:35.000Z",
"plan": { "id": "yearly", "name": "Yearly", "billing": "subscription", "amountCents": 2900, "currency": "eur", "interval": "year" },
"subscription": { "status": "active", "periodEnd": "2027-09-18T09:38:35.000Z", "cancelAtPeriodEnd": false, "cancelAt": null },
"trial": null
}List grants
GET /tools/{tool}/grants?email={email}
The address's grants and trials for the tool, newest first, up to 100.
| Parameter | ||
|---|---|---|
tool | path | The tool id. |
email | query | The address. |
Returns { "data": [Grant, …] }.
Create a grant
POST /tools/{tool}/grants
Grants access without payment. An unknown address gets an account; the grant applies when they sign in. The extension unlocks at its next check.
| Parameter | ||
|---|---|---|
email | string, required | The address, at most 254 characters. |
days | integer, optional | 1 to 3650. Absent or 0: until revoked. |
note | string, optional | Up to 200 characters, shown in the dashboard. A NUL character is refused. |
curl -X POST https://northwind.toolaby.app/api/v1/tools/invoice-grabber/grants \
-H "Authorization: Bearer wk_live_…" \
-H "Content-Type: application/json" \
-d '{ "email": "maya@example.com", "days": 30, "note": "Beta tester" }'const grant = await (await fetch('https://northwind.toolaby.app/api/v1/tools/invoice-grabber/grants', {
method: 'POST',
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'maya@example.com', days: 30, note: 'Beta tester' }),
})).json();Returns the Grant with status 201. On Hobby, a grant made at the same moment as others can pass the 100 passes: it is made and held, with live: false.
Revoke a grant
DELETE /tools/{tool}/grants/{id}
Ends the grant. The extension stops at its next check, within 15 minutes. Revoking twice succeeds.
Returns { "id", "tool", "revoked_at", "live": false }.
Retrieve a buyer's uses
GET /tools/{tool}/uses
What the signed-in buyer a site token names holds of the tool, and how many of its free uses are left, counting nothing: for your page to say before the first use. Your server sends its key, and the buyer's site token: Toolaby.token({ tool }) on your page, or toolaby.siteToken({ audience }) in your extension (Your server).
| Parameter | ||
|---|---|---|
tool | path | The tool id. |
Toolaby-Site-Token | header | The buyer's site token. It lasts five minutes. |
Returns a Usage.
Count a buyer's uses
POST /tools/{tool}/uses
Counts count of the buyer's uses, for an action your server does for them. It is allowed and counted, or refused whole, with why (reason) and what remains: a refusal is an answer, 200 with allowed: false, not an error. A buyer who holds the tool, or a tool open to everyone, counts nothing.
| Parameter | ||
|---|---|---|
tool | path | The tool id. |
Toolaby-Site-Token | header | The buyer's site token. |
Idempotency-Key | header, optional | 1 to 255 characters naming this one action: make it from what the action counts, never from what the browser sends. A retry 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, idempotency_error. A refusal counts nothing and is not kept: a buyer who pays in between is let through. |
count | body, optional | How many uses the action takes: 1 to 1,000, 1 by default. |
curl https://northwind.toolaby.app/api/v1/tools/invoice-grabber/uses \
-H "Authorization: Bearer wk_live_…" \
-H "Toolaby-Site-Token: $SITE_TOKEN" \
-H "Idempotency-Key: $ACTION_KEY" \
-H "Content-Type: application/json" \
-d '{ "count": 3 }'Returns a Usage.
{ "object": "usage", "allowed": true, "entitled": false, "used": 3, "remaining": 7, "limit": 10, "per": "ever" }Analytics summary
GET /tools/{tool}/analytics?range={range}
The figures of the tool's Analytics Overview for the period: devices active today, in 7 days and in 30 days, new devices; the funnel from a new device to a purchase, which leaves out the devices that have the tool another way and counts them in has_access_elsewhere; what was paid and refunded in each currency, with monthly recurring revenue; and uninstalls. Analytics in the API is on Pro (Analytics).
| Parameter | ||
|---|---|---|
tool | path | The tool id. |
range | query, optional | 7d, 30d (the default), 90d or 12m: that many days to today. Hobby sees 30 days back, Pro thirteen months. |
channel | query, optional | app, the extension's devices, or web, a web app's signed-in buyers. Both together where absent. Revenue is the tool's whole either way, and uninstalls are the extension's. The answer says it back as channel. |
Returns an AnalyticsSummary. A part the plan does not hold is null; parts says what each part is to your workspace.
{
"tool": "northwind--invoice-grabber",
"range": { "id": "30d", "from": "2026-09-17", "to": "2026-10-16", "days": 30, "clamped": false },
"counting": true,
"parts": { "overview": "open", "revenue": "open", "devices": "open", "funnel": "open", "features": "open", "uninstalls": "open", "versions": "open", "experiments": "off", "export": "open", "history": "open" },
"devices": { "active": 1163, "active_7_days": 2775, "active_30_days": 5202, "new": 3401 },
"funnel": { "new_devices": 3363, "met_paywall": 742, "opened_checkout": 210, "paid": 99, "median_days_to_paid": 2, "has_access_elsewhere": 38 },
"revenue": [{ "currency": "eur", "purchased": 412300, "renewed": 25100, "refunded": 0, "net": 437400, "mrr": 26400, "subscriptions": 88 }],
"uninstalls": { "removed": 234, "paying": 3, "answered": 141, "median_days_kept": 9 }
}Analytics table
GET /tools/{tool}/analytics/{table}?range={range}
One table of the tool's Analytics as rows, the CSV file's own: each row an object keyed by its columns. A table is open where its part is.
| Parameter | ||
|---|---|---|
tool | path | The tool id. |
table | path | devices and cohorts (Devices), revenue (Revenue), features (Features), versions (Versions) or uninstalls (Uninstalls). |
range | query, optional | As for the summary. |
channel | query, optional | As for the summary. |
Returns { "part", "range", "channel", "data": [ … ] }.
{
"part": "features",
"range": { "id": "30d", "from": "2026-09-17", "to": "2026-10-16", "days": 30, "clamped": false },
"data": [
{ "opened_by": "_limit", "paywall_opens": 6512, "checkouts": 98, "purchases": 72, "currency": "eur", "revenue": 216000 },
{ "opened_by": "export", "paywall_opens": 1349, "checkouts": 21, "purchases": 15, "currency": "eur", "revenue": 45000 }
]
}| Table | Columns |
|---|---|
devices | day, active, active_7_days, active_30_days, new_devices, met_paywall, checkouts |
cohorts | week, new_devices, and for days 1, 7 and 28: day_N_due (devices that met the tool that long ago) and day_N_back (of them, active that day) |
revenue | day, currency, purchases, purchased, renewals, renewed, refunds, refunded, mrr, subscriptions, trialing |
features | opened_by (a feature's key, _limit for free uses spent, _paid for a paid-only tool), paywall_opens, checkouts, purchases, currency, revenue |
versions | version, first_seen, active_now, new_devices, devices_first_on_it, of_them_paid, purchases, uninstalls |
uninstalls | day, tool, version, paying, plan, days_used, reason, comment |
Money is in minor units of currency. Days are UTC dates.
Objects
CustomerListItem
| Attribute | Type | |
|---|---|---|
email | string | |
account | boolean | An account exists for the address: it signed in, or you granted it access or added it. |
entitled | boolean | Holds the tool now. |
via | enum, nullable | licence, subscription, trial, grant, or null. |
since | string | When they first held the tool, or first signed in to it. |
last_active_at | string, nullable | The last check-in from any of their devices. |
devices | integer | Devices signed in or activated. |
Customer
| Attribute | Type | |
|---|---|---|
email | string | |
account | boolean | An account exists for the address: it signed in, or you granted it access or added it. |
entitled | boolean | Holds the tool now: by a licence, subscription, trial, grant, or a bundle that names it. |
via | enum, nullable | licence, subscription, trial, grant. null when not entitled. |
paid_at | string, nullable | The licence's purchase date, or the current period's start. |
plan | object, nullable | id, name, billing (one_time or subscription), amountCents (nullable), currency, interval (month, year or null). |
subscription | object, nullable | status (Stripe's), periodEnd, cancelAtPeriodEnd, cancelAt. Present whatever the status, so a lapsed subscription can be explained. |
trial | object, nullable | endsAt, active. Present after the trial ended, with active: false. |
Grant
| Attribute | Type | |
|---|---|---|
id | string | |
tool | string | The tool's full id. |
email | string | |
kind | enum | grant or trial. |
starts_at | string | |
ends_at | string, nullable | When the grant ends by itself. null for a grant that lasts until revoked. |
revoked_at | string, nullable | |
note | string, nullable | |
created_at | string | |
live | boolean | Entitles the address now. |
Usage
| Attribute | Type | |
|---|---|---|
object | string | usage. |
allowed | boolean | Whether the action may go ahead. On POST, its uses are counted when it may. |
entitled | boolean | The buyer holds the tool: bought, subscribed, on a trial or granted. Nothing is counted. |
unlimited | boolean, optional | Nothing limits the buyer: they hold the tool, or it is open to everyone. |
used, remaining, limit | integer, optional | The buyer's free uses, in the period the tool counts them in. |
per | enum, optional | ever, day, week or month. |
window, resetsAt | string, optional | For a period: when the current one began, and when the next one begins. |
reason | enum, optional | Why not: limit, the free uses are spent, or paid, the tool is sold only. |
sharedUses | boolean, optional | The buyer's address is a throwaway or unverified one: its uses are counted with every other such address's. |
deviceLimit | object, optional | For an extension's token: its device is past the account's device limit, allowed and held, and is counted as a free one. |
Dates are ISO 8601 strings.