API
Look up whether a person holds a tool, and grant or revoke access, 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/v1Authentication
Create a key under Configure → API in the dashboard. It is shown once. Test keys (wk_test_…) act on the test Wall's data; live keys (wk_live_…) on the live Wall'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.WALL_API_KEY}` },
});
const customer = await res.json();There is no SDK; the API is five 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.
Rate limits
600 requests per minute per key. Above that, 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, and 20,000 across all the workspaces of its owner, 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.
- Create a grant: 1,000 a day per tool, through the API and the command line together. Each grant is also one of the workspace's 20,000 additions a day, and of its owner's 20,000 across their workspaces, 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 must be between 0 and 3650", "param": "days" } }| Status | type | |
|---|---|---|
| 400 | invalid_request_error | A parameter is missing or wrong; param names it. |
| 401 | authentication_error | No key, a malformed key, a revoked one, or one whose workspace's owners are suspended. |
| 403 | plan_limit_error | The workspace's plan does not allow it. On Hobby a workspace holds 100 passes at once: Plans. |
| 404 | not_found | No such tool, grant or person in this workspace. |
| 429 | rate_limit_error | The key's minute is used up, this address sent thirty wrong keys in a minute, the workspace read 30 pages of customers this minute or its lists' rows for today (or its owner's, across their workspaces), it looked up 20,000 customers today (or its owner did), the tool made 1,000 grants today, or the workspace made its 20,000 additions today. |
| 500 | api_error | The Wall's fault, or it cannot count the workspace's additions 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.
| 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.
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 }.
Objects
CustomerListItem
| Attribute | Type | |
|---|---|---|
email | string | |
account | boolean | The address has signed in to your workspace. |
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 | The address has signed in to your workspace. |
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 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. |
Dates are ISO 8601 strings.